BareGit

Add the fullscreen card viewer

- Render read-only card details with canonical IDs and asset fallbacks.
- Adapt the foil renderer for interactive foil and plain card previews.
- Apply clay styling with floating navigation and fullscreen page layouts.
- Document the visual system and responsive overlay behavior.
Author: MetroWind <chris.corsair@gmail.com>
Date: Sat Aug 22 14:28:42 2026 -0700
Commit: 63641cfe671033731c46cd86b68d62ba68dec503

Changes

diff --git a/prd.md b/prd.md
index 03fa7c5..ba57ddf 100644
--- a/prd.md
+++ b/prd.md
@@ -43,6 +43,7 @@ platform.
   system. The operator must install ImageMagick with Magick++ and JPEG, PNG,
   WebP, and AVIF support.
 - HTML is rendered on the server with Inja templates.
+- Refer to `styling.md` for design directions.
 
 ## Games and series
 
diff --git a/src/app.cpp b/src/app.cpp
index bb5cb91..696e5c0 100644
--- a/src/app.cpp
+++ b/src/app.cpp
@@ -174,6 +174,31 @@ void respondInternalError(App::Response& response)
         "text/html; charset=utf-8");
 }
 
+void respondNotFound(App::Response& response)
+{
+    response.status = 404;
+    response.set_content(
+        "<!doctype html><title>Card not found</title>"
+        "<h1>Card not found</h1>",
+        "text/html; charset=utf-8");
+}
+
+bool isRegularFile(const std::filesystem::path& path, std::int64_t card_id)
+{
+    std::error_code filesystem_error;
+    const bool exists = std::filesystem::is_regular_file(
+        path, filesystem_error);
+    if(filesystem_error &&
+       filesystem_error != std::errc::no_such_file_or_directory)
+    {
+        spdlog::warn(
+            "Failed to inspect an asset for card {}: {}",
+            card_id,
+            filesystem_error.message());
+    }
+    return exists;
+}
+
 } // namespace
 
 App::App(
@@ -221,6 +246,163 @@ App::App(
                 arguments);
         });
     card_index_template_ = templates_.parse_template("card_index.html");
+    card_view_template_ = templates_.parse_template("card_view.html");
+}
+
+void App::handleCardView(
+    const Request& request,
+    Response& response)
+{
+    const auto id_parameter = request.path_params.find("id");
+    if(id_parameter == request.path_params.end())
+    {
+        respondNotFound(response);
+        return;
+    }
+
+    auto identity = parsePublicId(id_parameter->second);
+    if(!identity)
+    {
+        respondNotFound(response);
+        return;
+    }
+    auto card_result = data_source_->getCard(*identity);
+    if(!card_result)
+    {
+        spdlog::error(
+            "Failed to load card {}: {}",
+            id_parameter->second,
+            card_result.error().msg());
+        respondInternalError(response);
+        return;
+    }
+    if(!*card_result)
+    {
+        respondNotFound(response);
+        return;
+    }
+    const Card& card = **card_result;
+
+    auto public_id_result = formatPublicId(card.identity);
+    if(!public_id_result)
+    {
+        spdlog::error(
+            "Failed to format card {}: {}",
+            card.id,
+            public_id_result.error().msg());
+        respondInternalError(response);
+        return;
+    }
+    const std::string& public_id = *public_id_result;
+    const std::filesystem::path asset_root =
+        config_.card_storage_root / "published" / public_id;
+    const std::string front_name = "front." + card.front_extension;
+    const bool front_exists = isRegularFile(
+        asset_root / front_name, card.id);
+    const std::string front_url = front_exists
+        ? urlFor(
+            "card-asset",
+            {public_id + "/" + front_name},
+            {{"v", std::to_string(card.revision)}})
+        : urlFor("static", {"card_placeholder.svg"});
+
+    std::optional<std::string> foil_url;
+    bool foil_exists = false;
+    if(card.foil_extension)
+    {
+        const std::string foil_name = "foil." + *card.foil_extension;
+        foil_exists = isRegularFile(asset_root / foil_name, card.id);
+        if(foil_exists)
+        {
+            foil_url = urlFor(
+                "card-asset",
+                {public_id + "/" + foil_name},
+                {{"v", std::to_string(card.revision)}});
+        }
+    }
+
+    auto membership_result = data_source_->getCardSeries(card.id);
+    if(!membership_result)
+    {
+        spdlog::error(
+            "Failed to load series for card {}: {}",
+            card.id,
+            membership_result.error().msg());
+        respondInternalError(response);
+        return;
+    }
+    inja::json series = inja::json::array();
+    for(std::int64_t series_id : *membership_result)
+    {
+        auto series_result = data_source_->getSeries(series_id);
+        if(!series_result)
+        {
+            spdlog::error(
+                "Failed to load series {} for card {}: {}",
+                series_id,
+                card.id,
+                series_result.error().msg());
+            respondInternalError(response);
+            return;
+        }
+        if(*series_result)
+        {
+            series.push_back((**series_result).name);
+        }
+    }
+
+    std::vector<std::string> missing_assets;
+    if(!front_exists)
+    {
+        missing_assets.emplace_back("front artwork");
+    }
+    if(card.foil_extension && !foil_exists)
+    {
+        missing_assets.emplace_back("foil control");
+    }
+
+    const inja::json template_data = {
+        {"asset_warning", !missing_assets.empty()},
+        {"back_url", urlFor("card-index")},
+        {"display_id", uppercaseAscii(public_id)},
+        {"foil_url", foil_url.value_or("")},
+        {"front_url", front_url},
+        {"game", card.identity.game_short_name
+             ? uppercaseAscii(*card.identity.game_short_name)
+             : "Loose card"},
+        {"has_foil", card.foil_extension.has_value()},
+        {"has_long_description", card.long_description.has_value()},
+        {"has_short_description", card.short_description.has_value()},
+        {"long_description", card.long_description.value_or("")},
+        {"missing_assets", missing_assets},
+        {"model_url", urlFor("static", {"foil/model/card.obj"})},
+        {"name", card.name},
+        {"preview_script_url",
+         urlFor("static", {"foil/card_preview.js"})},
+        {"rarity", card.rarity},
+        {"series", std::move(series)},
+        {"shader_fragment_url",
+         urlFor("static", {"foil/frag-shader.glsl"})},
+        {"shader_vertex_url",
+         urlFor("static", {"foil/vert-shader.glsl"})},
+        {"short_description", card.short_description.value_or("")},
+        {"spectral_lut_url",
+         urlFor("static", {"foil/spectral_xyz.bin"})},
+        {"title", card.name + " · Card Collection"},
+    };
+
+    try
+    {
+        response.status = 200;
+        response.set_content(
+            templates_.render(card_view_template_, template_data),
+            "text/html; charset=utf-8");
+    }
+    catch(const std::exception& error)
+    {
+        spdlog::error("Failed to render card {}: {}", card.id, error.what());
+        respondInternalError(response);
+    }
 }
 
 std::string App::urlFor(
@@ -365,6 +547,9 @@ void App::setup()
     server.Get(
         getPath("card-index"),
         std::bind_front(&App::handleCardIndex, this));
+    server.Get(
+        getPath("card", {"id"}),
+        std::bind_front(&App::handleCardView, this));
 }
 
 std::string App::getPath(
diff --git a/src/app.h b/src/app.h
index 1df9ccb..8b19d7c 100644
--- a/src/app.h
+++ b/src/app.h
@@ -38,6 +38,9 @@ public:
     /// Render the card index.
     void handleCardIndex(const Request& request, Response& response);
 
+    /// Render one read-only card page.
+    void handleCardView(const Request& request, Response& response);
+
 private:
     /// Register implemented handlers and static mounts.
     void setup() override;
@@ -55,4 +58,5 @@ private:
     UrlBuilder url_builder_;
     inja::Environment templates_;
     inja::Template card_index_template_;
+    inja::Template card_view_template_;
 };
diff --git a/src/public_id.cpp b/src/public_id.cpp
index eeaf9df..bdc21a7 100644
--- a/src/public_id.cpp
+++ b/src/public_id.cpp
@@ -1,5 +1,6 @@
 #include "public_id.h"
 
+#include <algorithm>
 #include <array>
 #include <charconv>
 #include <cctype>
@@ -32,6 +33,31 @@ bool isDigit(char character)
     return std::isdigit(static_cast<unsigned char>(character)) != 0;
 }
 
+bool isLowercaseAlphaNumeric(std::string_view value)
+{
+    return !value.empty() && std::ranges::all_of(
+        value,
+        [](char character)
+        {
+            return (character >= 'a' && character <= 'z') ||
+                   (character >= '0' && character <= '9');
+        });
+}
+
+template<typename Number>
+mw::E<Number> parseNumber(std::string_view value, int base)
+{
+    Number number = 0;
+    const auto result = std::from_chars(
+        value.data(), value.data() + value.size(), number, base);
+    if(value.empty() || result.ec != std::errc{} ||
+       result.ptr != value.data() + value.size())
+    {
+        return std::unexpected(mw::runtimeError("Invalid public card ID"));
+    }
+    return number;
+}
+
 std::size_t runEnd(std::string_view value, std::size_t begin, bool digit)
 {
     std::size_t end = begin;
@@ -98,6 +124,57 @@ mw::E<std::string> formatPublicId(const CardIdentity& identity)
     return formatNumber(identity.card_number, 36);
 }
 
+mw::E<CardIdentity> parsePublicId(std::string_view public_id)
+{
+    const std::size_t separator = public_id.find('-');
+    CardIdentity identity;
+    if(separator == std::string_view::npos)
+    {
+        if(!isLowercaseAlphaNumeric(public_id))
+        {
+            return std::unexpected(
+                mw::runtimeError("Invalid public card ID"));
+        }
+        auto number = parseNumber<std::uint32_t>(public_id, 36);
+        if(!number)
+        {
+            return std::unexpected(std::move(number.error()));
+        }
+        identity = {std::nullopt, *number};
+    }
+    else
+    {
+        const std::string_view game = public_id.substr(0, separator);
+        const std::string_view number_text = public_id.substr(separator + 1);
+        if(!isLowercaseAlphaNumeric(game) ||
+           number_text.empty() ||
+           number_text.front() == '0' ||
+           !std::ranges::all_of(number_text, isDigit) ||
+           number_text.find('-') != std::string_view::npos)
+        {
+            return std::unexpected(
+                mw::runtimeError("Invalid public card ID"));
+        }
+        auto number = parseNumber<std::int64_t>(number_text, 10);
+        if(!number || *number <= 0)
+        {
+            return std::unexpected(
+                mw::runtimeError("Invalid public card ID"));
+        }
+        identity = {
+            std::string(game),
+            static_cast<std::uint64_t>(*number),
+        };
+    }
+
+    auto canonical_id = formatPublicId(identity);
+    if(!canonical_id || *canonical_id != public_id)
+    {
+        return std::unexpected(mw::runtimeError("Invalid public card ID"));
+    }
+    return identity;
+}
+
 bool naturalPublicIdLess(std::string_view left, std::string_view right)
 {
     std::size_t left_index = 0;
diff --git a/src/public_id.h b/src/public_id.h
index f10067e..552f02a 100644
--- a/src/public_id.h
+++ b/src/public_id.h
@@ -10,5 +10,8 @@
 /// Format the canonical lowercase public ID derived from a card identity.
 mw::E<std::string> formatPublicId(const CardIdentity& identity);
 
+/// Parse a canonical lowercase public ID into its persisted identity.
+mw::E<CardIdentity> parsePublicId(std::string_view public_id);
+
 /// Compare canonical public IDs using numeric runs as integers.
 bool naturalPublicIdLess(std::string_view left, std::string_view right);
diff --git a/static/card_placeholder.svg b/static/card_placeholder.svg
index d049c8f..f58fe7e 100644
--- a/static/card_placeholder.svg
+++ b/static/card_placeholder.svg
@@ -1,18 +1,19 @@
 <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"/>
+            <stop stop-color="#ddd0fb"/>
+            <stop offset=".52" stop-color="#f5d5e7"/>
+            <stop offset="1" stop-color="#ccecf9"/>
         </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"/>
+          fill="none" stroke="#7c3aed" stroke-opacity=".16" stroke-width="3"/>
+    <circle cx="250" cy="320" r="72" fill="#ffffff" fill-opacity=".42"/>
     <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">
+          fill="#7c3aed" fill-opacity=".42"/>
+    <text x="250" y="440" text-anchor="middle" fill="#332f3a"
+          fill-opacity=".62" 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
index ef9c36e..4a989d9 100644
--- a/static/css/styles.css
+++ b/static/css/styles.css
@@ -1,100 +1,274 @@
 :root {
-    color-scheme: dark;
-    font-family: Inter, ui-sans-serif, system-ui, sans-serif;
-    background: #101114;
-    color: #f4f1ea;
+    color-scheme: light;
+    font-family: "DM Sans", ui-sans-serif, system-ui, sans-serif;
+    color: #332f3a;
+    background: #f4f1fa;
+    --canvas: #f4f1fa;
+    --foreground: #332f3a;
+    --muted: #635f69;
+    --violet: #7c3aed;
+    --violet-light: #a78bfa;
+    --pink: #db2777;
+    --blue: #0ea5e9;
+    --page-gutter: clamp(1.25rem, 3vw, 3rem);
+    --main-top: 9rem;
+    --main-bottom: 7rem;
+    --clay-card-shadow:
+        16px 16px 32px rgb(160 150 180 / 20%),
+        -10px -10px 24px rgb(255 255 255 / 90%),
+        inset 6px 6px 12px rgb(139 92 246 / 3%),
+        inset -6px -6px 12px rgb(255 255 255 / 100%);
+    --clay-card-shadow-hover:
+        22px 24px 38px rgb(139 92 246 / 24%),
+        -12px -12px 28px rgb(255 255 255 / 95%),
+        inset 6px 6px 12px rgb(139 92 246 / 5%),
+        inset -6px -6px 12px rgb(255 255 255 / 100%);
+    --clay-button-shadow:
+        12px 12px 24px rgb(139 92 246 / 30%),
+        -8px -8px 16px rgb(255 255 255 / 40%),
+        inset 4px 4px 8px rgb(255 255 255 / 40%),
+        inset -4px -4px 8px rgb(0 0 0 / 10%);
+    --clay-pressed-shadow:
+        inset 10px 10px 20px #d9d4e3,
+        inset -10px -10px 20px #ffffff;
 }
 
 * {
     box-sizing: border-box;
 }
 
+html {
+    min-width: 20rem;
+}
+
 body {
     margin: 0;
     min-height: 100vh;
-    background:
-        radial-gradient(circle at top left, #2e263d 0, transparent 32rem),
-        #101114;
+    overflow-x: hidden;
+    background: var(--canvas);
+    color: var(--foreground);
+    font-weight: 500;
+    line-height: 1.625;
 }
 
 a {
     color: inherit;
 }
 
+.ambient-light {
+    position: fixed;
+    z-index: -1;
+    inset: 0;
+    overflow: hidden;
+    pointer-events: none;
+}
+
+.ambient-blob {
+    position: absolute;
+    width: min(60vh, 42rem);
+    height: min(60vh, 42rem);
+    border-radius: 50%;
+    opacity: 0.11;
+    filter: blur(4rem);
+    animation: clay-float 10s ease-in-out infinite;
+}
+
+.ambient-blob-violet {
+    top: -18%;
+    left: -12%;
+    background: var(--violet);
+}
+
+.ambient-blob-pink {
+    top: 22%;
+    right: -14%;
+    background: var(--pink);
+    animation-name: clay-float-delayed;
+    animation-delay: -3s;
+}
+
+.ambient-blob-blue {
+    bottom: -24%;
+    left: 28%;
+    background: var(--blue);
+    animation-duration: 12s;
+    animation-delay: -6s;
+}
+
 .site-header {
+    position: fixed;
+    z-index: 100;
+    top: 1.5rem;
+    right: var(--page-gutter);
+    left: var(--page-gutter);
     display: flex;
     align-items: center;
     justify-content: space-between;
-    padding: 1.25rem clamp(1.25rem, 4vw, 4rem);
-    border-bottom: 1px solid #ffffff1f;
+    width: auto;
+    min-height: 5rem;
+    margin: 0;
+    padding: 0.8rem 1rem 0.8rem 1.6rem;
+    border: 1px solid rgb(255 255 255 / 75%);
+    border-radius: 2.5rem;
+    background: rgb(255 255 255 / 68%);
+    box-shadow:
+        18px 18px 36px rgb(160 150 180 / 18%),
+        -12px -12px 30px rgb(255 255 255 / 90%),
+        inset 5px 5px 10px rgb(139 92 246 / 3%),
+        inset -5px -5px 10px rgb(255 255 255 / 90%);
+    backdrop-filter: blur(1.25rem);
 }
 
 .site-title {
-    font-size: 1.1rem;
-    font-weight: 750;
+    font-family: Nunito, ui-rounded, sans-serif;
+    font-size: 1.2rem;
+    font-weight: 900;
+    letter-spacing: -0.025em;
     text-decoration: none;
 }
 
 nav {
     display: flex;
-    gap: 1.25rem;
+    align-items: center;
+    gap: 0.25rem;
+}
+
+.nav-link {
+    display: inline-flex;
+    min-height: 2.75rem;
+    align-items: center;
+    justify-content: center;
+    padding: 0.65rem 1rem;
+    border-radius: 1.25rem;
+    color: var(--muted);
+    font-weight: 700;
+    text-decoration: none;
+    transition:
+        color 200ms ease,
+        background 200ms ease,
+        box-shadow 200ms ease,
+        transform 200ms ease;
+}
+
+.nav-link:hover {
+    color: var(--violet);
+    background: rgb(124 58 237 / 8%);
+    transform: translateY(-0.15rem);
 }
 
-nav a,
-.sort-controls a {
-    color: #cbc5d6;
-    text-underline-offset: 0.3rem;
+.nav-link:active {
+    transform: scale(0.92);
+    box-shadow: var(--clay-pressed-shadow);
+}
+
+.nav-link-primary {
+    margin-left: 0.35rem;
+    padding-inline: 1.25rem;
+    background: linear-gradient(145deg, var(--violet-light), var(--violet));
+    color: #ffffff;
+    box-shadow: var(--clay-button-shadow);
+}
+
+.nav-link-primary:hover {
+    background: linear-gradient(145deg, #b7a1fb, #6d28d9);
+    color: #ffffff;
+    transform: translateY(-0.25rem);
 }
 
 main {
-    width: min(90rem, 100%);
-    margin: 0 auto;
-    padding: 3rem clamp(1.25rem, 4vw, 4rem) 5rem;
+    width: 100%;
+    min-height: 100vh;
+    min-height: 100svh;
+    margin: 0;
+    padding:
+        var(--main-top)
+        var(--page-gutter)
+        var(--main-bottom);
 }
 
 .page-heading {
     display: flex;
     align-items: end;
     justify-content: space-between;
-    gap: 2rem;
-    margin-bottom: 2rem;
+    gap: 2.5rem;
+    margin-bottom: 3.25rem;
 }
 
 .eyebrow {
-    margin: 0 0 0.4rem;
-    color: #b9a5db;
+    margin: 0 0 0.25rem;
+    color: var(--violet);
     font-size: 0.75rem;
-    font-weight: 750;
-    letter-spacing: 0.14em;
+    font-weight: 700;
+    letter-spacing: 0.16em;
     text-transform: uppercase;
 }
 
 h1 {
     margin: 0;
-    font-size: clamp(2.25rem, 6vw, 4.5rem);
-    letter-spacing: -0.055em;
+    font-family: Nunito, ui-rounded, sans-serif;
+    font-size: clamp(3rem, 8vw, 5.5rem);
+    font-weight: 900;
+    line-height: 1.05;
+    letter-spacing: -0.06em;
 }
 
 .sort-controls {
     display: flex;
     flex-wrap: wrap;
-    gap: 0.75rem;
+    align-items: center;
+    gap: 0.4rem;
+    padding: 0.55rem;
+    border-radius: 1.5rem;
+    background: #efebf5;
+    box-shadow: var(--clay-pressed-shadow);
     font-size: 0.9rem;
 }
 
-.sort-controls span {
-    color: #77727f;
+.sort-label {
+    padding: 0 0.65rem;
+    color: var(--muted);
+    font-size: 0.78rem;
+    font-weight: 700;
+}
+
+.sort-button {
+    display: inline-flex;
+    min-height: 2.75rem;
+    align-items: center;
+    padding: 0.55rem 0.9rem;
+    border-radius: 1.1rem;
+    color: var(--muted);
+    font-weight: 700;
+    text-decoration: none;
+    transition:
+        color 200ms ease,
+        box-shadow 200ms ease,
+        transform 200ms ease;
+}
+
+.sort-button:hover {
+    color: var(--violet);
+    transform: translateY(-0.12rem);
+}
+
+.sort-button:active {
+    transform: scale(0.92);
 }
 
-.sort-controls [aria-current="page"] {
+.sort-button[aria-current="page"] {
+    background: linear-gradient(145deg, var(--violet-light), var(--violet));
     color: #ffffff;
-    font-weight: 700;
+    box-shadow:
+        7px 7px 14px rgb(139 92 246 / 24%),
+        -5px -5px 12px rgb(255 255 255 / 60%),
+        inset 3px 3px 6px rgb(255 255 255 / 35%),
+        inset -3px -3px 6px rgb(0 0 0 / 8%);
 }
 
 .card-grid {
     display: grid;
-    grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
-    gap: clamp(1rem, 2.5vw, 2rem);
+    grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
+    gap: clamp(1.5rem, 3vw, 2.5rem);
     margin: 0;
     padding: 0;
     list-style: none;
@@ -102,16 +276,33 @@ h1 {
 
 .card-tile {
     overflow: hidden;
-    border: 1px solid #ffffff1a;
-    border-radius: 0.85rem;
-    background: #191a1f;
-    box-shadow: 0 1rem 2.5rem #00000035;
+    border: 1px solid rgb(255 255 255 / 85%);
+    border-radius: 2rem;
+    background: rgb(255 255 255 / 72%);
+    box-shadow: var(--clay-card-shadow);
+    backdrop-filter: blur(1.25rem);
+    transition:
+        box-shadow 500ms ease,
+        transform 500ms cubic-bezier(0.2, 0.8, 0.2, 1);
+}
+
+.card-tile:hover {
+    box-shadow: var(--clay-card-shadow-hover);
+    transform: translateY(-0.5rem) scale(1.01);
 }
 
 .card-image-link {
     display: block;
     aspect-ratio: 5 / 7;
-    background: #24212b;
+    margin: 0.75rem;
+    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%);
 }
 
 .card-image-link img {
@@ -119,39 +310,439 @@ h1 {
     width: 100%;
     height: 100%;
     object-fit: cover;
+    transition: transform 500ms cubic-bezier(0.2, 0.8, 0.2, 1);
+}
+
+.card-tile:hover .card-image-link img {
+    transform: scale(1.025);
 }
 
 .card-details {
-    padding: 1rem;
+    padding: 0.2rem 1.35rem 1.5rem;
 }
 
 .card-id {
-    color: #b9a5db;
+    display: inline-flex;
+    min-height: 2.75rem;
+    align-items: center;
+    margin-left: -0.5rem;
+    padding: 0.35rem 0.6rem;
+    border-radius: 1rem;
+    color: var(--violet);
     font-family: ui-monospace, monospace;
     font-size: 0.8rem;
     font-weight: 700;
     letter-spacing: 0.06em;
     text-decoration: none;
+    transition:
+        background 200ms ease,
+        transform 200ms ease;
+}
+
+.card-id:hover {
+    background: rgb(124 58 237 / 8%);
+    transform: translateY(-0.1rem);
+}
+
+.card-id:active {
+    transform: scale(0.92);
 }
 
 .card-details h2 {
-    margin: 0.35rem 0 0;
-    font-size: 1rem;
+    margin: 0.1rem 0 0;
+    font-family: Nunito, ui-rounded, sans-serif;
+    font-size: 1.15rem;
+    font-weight: 800;
+    line-height: 1.3;
 }
 
 .empty-state {
-    min-height: 14rem;
     display: grid;
+    min-height: 16rem;
     place-items: center;
-    border: 1px dashed #ffffff26;
-    border-radius: 0.85rem;
-    color: #918b98;
+    padding: 2rem;
+    border-radius: 2rem;
+    background: #efebf5;
+    box-shadow: var(--clay-pressed-shadow);
+    color: var(--muted);
+    font-weight: 700;
+}
+
+.card-view {
+    position: relative;
+    min-height: 100vh;
+    min-height: 100svh;
+    margin:
+        calc(0rem - var(--main-top))
+        calc(0rem - var(--page-gutter))
+        calc(0rem - var(--main-bottom));
+    overflow: hidden;
+}
+
+.card-preview-stage {
+    position: absolute;
+    inset: 0;
+    display: grid;
+    min-height: 100%;
+    overflow: hidden;
+    background:
+        radial-gradient(circle at 22% 16%,
+                        rgb(255 255 255 / 80%), transparent 35%),
+        linear-gradient(145deg, #ede7f8, #e2d9f2);
+    isolation: isolate;
+}
+
+.card-preview-stage::before,
+.card-preview-stage::after {
+    position: absolute;
+    z-index: -1;
+    width: 16rem;
+    height: 16rem;
+    border-radius: 50%;
+    content: "";
+    filter: blur(3.5rem);
+    opacity: 0.22;
+}
+
+.card-preview-stage::before {
+    top: -5rem;
+    right: -4rem;
+    background: var(--pink);
+}
+
+.card-preview-stage::after {
+    bottom: -5rem;
+    left: -4rem;
+    background: var(--blue);
+}
+
+.preview-heading {
+    position: absolute;
+    z-index: 2;
+    top: var(--main-top);
+    right: calc(var(--page-gutter) + 31rem);
+    left: var(--page-gutter);
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 1rem;
+    pointer-events: none;
+}
+
+.preview-heading .eyebrow {
+    margin: 0;
+}
+
+.preview-status,
+.preview-hint {
+    margin: 0;
+    border: 1px solid rgb(255 255 255 / 70%);
+    border-radius: 1rem;
+    background: rgb(255 255 255 / 64%);
+    box-shadow:
+        7px 7px 16px rgb(134 119 158 / 16%),
+        -5px -5px 14px rgb(255 255 255 / 70%),
+        inset 3px 3px 7px rgb(139 92 246 / 3%),
+        inset -3px -3px 7px rgb(255 255 255 / 75%);
+    color: var(--muted);
+    font-size: 0.78rem;
+    font-weight: 700;
+}
+
+.preview-status {
+    padding: 0.35rem 0.65rem;
+}
+
+.preview-status[data-kind="success"] {
+    color: var(--violet);
+}
+
+.preview-status[data-kind="error"] {
+    color: #a21d53;
+}
+
+#GLCanvas {
+    display: block;
+    width: calc(100% - 30rem);
+    height: 100%;
+    min-height: 100vh;
+    min-height: 100svh;
+    cursor: move;
+    touch-action: none;
+}
+
+.preview-hint {
+    position: absolute;
+    right: calc(var(--page-gutter) + 31rem);
+    bottom: 1.5rem;
+    left: var(--page-gutter);
+    width: fit-content;
+    max-width: calc(100% - 3rem);
+    margin-inline: auto;
+    padding: 0.5rem 0.8rem;
+    text-align: center;
+    pointer-events: none;
+}
+
+.card-info-panel {
+    position: fixed;
+    z-index: 40;
+    top: var(--main-top);
+    right: var(--page-gutter);
+    bottom: 1.5rem;
+    width: min(
+        29rem,
+        calc(100% - var(--page-gutter) - var(--page-gutter)));
+    overflow: auto;
+    padding: clamp(1.5rem, 4vw, 2.5rem);
+    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);
+}
+
+.back-link {
+    display: inline-flex;
+    min-height: 2.75rem;
+    align-items: center;
+    margin: -0.5rem 0 1.5rem -0.65rem;
+    padding: 0.4rem 0.65rem;
+    border-radius: 1rem;
+    color: var(--muted);
+    font-weight: 700;
+    text-decoration: none;
+    transition: color 200ms ease, transform 200ms ease;
+}
+
+.back-link:hover {
+    color: var(--violet);
+    transform: translateX(-0.2rem);
+}
+
+.card-view-id {
+    margin: 0 0 0.4rem;
+    color: var(--violet);
+    font-family: ui-monospace, monospace;
+    font-size: 0.82rem;
+    font-weight: 800;
+    letter-spacing: 0.08em;
+}
+
+.card-info-panel h1 {
+    overflow-wrap: anywhere;
+    font-size: clamp(2.6rem, 6vw, 4.5rem);
+}
+
+.asset-warning {
+    display: grid;
+    gap: 0.2rem;
+    margin-top: 1.75rem;
+    padding: 1rem 1.1rem;
+    border-radius: 1.35rem;
+    background: #fde7f0;
+    box-shadow:
+        8px 8px 18px rgb(190 90 130 / 13%),
+        -6px -6px 15px rgb(255 255 255 / 72%),
+        inset 3px 3px 8px rgb(190 90 130 / 5%),
+        inset -3px -3px 8px rgb(255 255 255 / 65%);
+    color: #812044;
+    font-size: 0.88rem;
+}
+
+.card-metadata {
+    display: grid;
+    grid-template-columns: repeat(3, 1fr);
+    gap: 0.75rem;
+    margin: 2rem 0 0;
+}
+
+.card-metadata div {
+    min-width: 0;
+    padding: 0.9rem;
+    border-radius: 1.25rem;
+    background: #efebf5;
+    box-shadow: var(--clay-pressed-shadow);
+}
+
+.card-metadata dt {
+    color: var(--muted);
+    font-size: 0.68rem;
+    font-weight: 800;
+    letter-spacing: 0.08em;
+    text-transform: uppercase;
+}
+
+.card-metadata dd {
+    margin: 0.2rem 0 0;
+    overflow-wrap: anywhere;
+    font-family: Nunito, ui-rounded, sans-serif;
+    font-size: 0.95rem;
+    font-weight: 800;
+}
+
+.card-info-section {
+    margin-top: 2rem;
+    padding-top: 1.75rem;
+    border-top: 1px solid rgb(99 95 105 / 12%);
+}
+
+.card-info-section h2 {
+    margin: 0 0 0.75rem;
+    font-family: Nunito, ui-rounded, sans-serif;
+    font-size: 1rem;
+    font-weight: 900;
+}
+
+.muted-copy,
+.description-copy {
+    margin: 0;
+}
+
+.muted-copy {
+    color: var(--muted);
+}
+
+.description-copy {
+    white-space: pre-wrap;
+}
+
+.series-list {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 0.55rem;
+    margin: 0;
+    padding: 0;
+    list-style: none;
+}
+
+.series-list li {
+    padding: 0.45rem 0.75rem;
+    border-radius: 1rem;
+    background: rgb(124 58 237 / 9%);
+    color: var(--violet);
+    font-size: 0.82rem;
+    font-weight: 800;
+}
+
+:focus-visible {
+    outline: 0.25rem solid rgb(124 58 237 / 30%);
+    outline-offset: 0.2rem;
+}
+
+@keyframes clay-float {
+    0%,
+    100% {
+        transform: translateY(0) rotate(0deg);
+    }
+
+    50% {
+        transform: translateY(-1.25rem) rotate(2deg);
+    }
+}
+
+@keyframes clay-float-delayed {
+    0%,
+    100% {
+        transform: translateY(0) rotate(0deg);
+    }
+
+    50% {
+        transform: translateY(-0.95rem) rotate(-2deg);
+    }
 }
 
 @media(max-width: 42rem) {
+    :root {
+        --main-top: 13rem;
+    }
+
+    .site-header {
+        min-height: auto;
+        padding: 1rem;
+        border-radius: 2rem;
+    }
+
     .site-header,
     .page-heading {
         align-items: flex-start;
         flex-direction: column;
     }
+
+    nav {
+        width: 100%;
+        flex-wrap: wrap;
+    }
+
+    .nav-link {
+        flex: 1;
+        padding-inline: 0.7rem;
+    }
+
+    .nav-link-primary {
+        flex-basis: 100%;
+        margin: 0.35rem 0 0;
+    }
+
+    .page-heading {
+        margin-bottom: 2.5rem;
+    }
+
+    .sort-controls {
+        width: 100%;
+    }
+
+    .sort-label {
+        flex-basis: 100%;
+        padding-bottom: 0.2rem;
+    }
+
+    .sort-button {
+        flex: 1;
+        justify-content: center;
+    }
+
+    .card-grid {
+        grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
+    }
+
+    .card-metadata {
+        grid-template-columns: 1fr;
+    }
+}
+
+@media(max-width: 62rem) {
+    .preview-heading {
+        right: var(--page-gutter);
+    }
+
+    #GLCanvas {
+        width: 100%;
+    }
+
+    .preview-hint {
+        right: var(--page-gutter);
+        bottom: calc(44svh + 2rem);
+    }
+
+    .card-info-panel {
+        top: auto;
+        right: 1rem;
+        bottom: 1rem;
+        left: 1rem;
+        width: auto;
+        max-height: 44vh;
+        max-height: 44svh;
+    }
+}
+
+@media(prefers-reduced-motion: reduce) {
+    *,
+    *::before,
+    *::after {
+        scroll-behavior: auto !important;
+        animation-duration: 0.01ms !important;
+        animation-iteration-count: 1 !important;
+        transition-duration: 0.01ms !important;
+    }
 }
diff --git a/static/foil/card_preview.js b/static/foil/card_preview.js
new file mode 100644
index 0000000..0beb3bd
--- /dev/null
+++ b/static/foil/card_preview.js
@@ -0,0 +1,241 @@
+/** Material mode added by Card Collection for artwork without foil. */
+const CARD_ARTWORK_MATERIAL_KIND = 2;
+
+/** Load and parse one OBJ resource during preview initialization. */
+function loadModel(url)
+{
+    const request = new XMLHttpRequest();
+    request.open("GET", url, false);
+    request.send(null);
+    if(request.status != 200 && request.status != 0)
+    {
+        throw(new Error(`Failed to load ${url}: HTTP ${request.status}`));
+    }
+    return parseOBJ(request.responseText);
+}
+
+/** Shade a card front with its artwork and no foil contribution. */
+class ArtworkMaterial
+{
+    /** Load the front artwork used by a plain card. */
+    constructor(gl, artwork_url)
+    {
+        this.gl = gl;
+        this.artwork = new Texture(gl, artwork_url, {flip_y: true});
+        this.ready = this.artwork.ready;
+    }
+
+    /** Select plain artwork shading and bind the artwork texture. */
+    use(program)
+    {
+        this.gl.uniform1i(
+            program.uniform("u_material_kind"),
+            CARD_ARTWORK_MATERIAL_KIND);
+        this.artwork.use(program, "u_artwork", 0);
+    }
+
+    /** Release the artwork texture. */
+    dispose()
+    {
+        this.artwork.dispose();
+    }
+}
+
+/** Construct shared front and shell materials and partitioned models. */
+function initScene(gl, program, obj_model, page_data)
+{
+    const front_material = page_data.foil_url == null
+        ? new ArtworkMaterial(gl, page_data.front_url)
+        : new PhysicalFoilMaterial(
+            gl,
+            page_data.front_url,
+            page_data.foil_url,
+            page_data.spectral_lut_url);
+    const shell_material = new SolidColorMaterial(gl, [0.5, 0.5, 0.5]);
+    const models = [];
+    let front_triangle_count = 0;
+    let shell_triangle_count = 0;
+    for(const geometry of obj_model.geometries)
+    {
+        const partition = partitionCardGeometry(geometry, CARD_MESH_LAYOUT);
+        const front_vertices = partition.front.data.position == null
+            ? 0
+            : partition.front.data.position.length / 3;
+        const shell_vertices = partition.shell.data.position == null
+            ? 0
+            : partition.shell.data.position.length / 3;
+        if(front_vertices > 0)
+        {
+            models.push(new Model(
+                gl, program, partition.front, front_material));
+            front_triangle_count += front_vertices / 3;
+        }
+        if(shell_vertices > 0)
+        {
+            models.push(new Model(
+                gl, program, partition.shell, shell_material));
+            shell_triangle_count += shell_vertices / 3;
+        }
+    }
+    if(front_triangle_count == 0 || shell_triangle_count == 0)
+    {
+        throw(new Error(
+            "Card model must contain both front and shell triangles."));
+    }
+    return {
+        scene: new Scene(models),
+        front_material,
+    };
+}
+
+/** Build the projection, view, and interactive card-model matrices. */
+function calculateMatrices(canvas, camera_rotation)
+{
+    const mat4 = glMatrix.mat4;
+    const aspect = canvas.clientWidth / canvas.clientHeight;
+    const projection_matrix = mat4.perspective(
+        mat4.create(), Math.PI / 8.0, aspect, 0.1, 100.0);
+    const view_matrix = mat4.translate(
+        mat4.create(), mat4.create(), [0.0, 0.0, -5.0]);
+    const model_matrix = mat4.create();
+    mat4.rotateX(model_matrix, model_matrix, Math.PI / 2.0);
+    mat4.rotateX(model_matrix, model_matrix, camera_rotation[0]);
+    mat4.rotateZ(model_matrix, model_matrix, camera_rotation[1]);
+    return {projection_matrix, view_matrix, model_matrix};
+}
+
+/** Normalize a pointer position around the center of the short canvas axis. */
+function getNormalizedMousePos(move_event, canvas)
+{
+    const rect = canvas.getBoundingClientRect();
+    const size = Math.min(rect.width, rect.height);
+    const mouse_x = (move_event.clientX - rect.left
+                    - (rect.width - size) * 0.5) / size - 0.5;
+    const mouse_y = (rect.height - (move_event.clientY - rect.top)
+                    - (rect.height - size) * 0.5) / size - 0.5;
+    return [mouse_x, mouse_y];
+}
+
+/** Smoothly bound an unbounded pointer coordinate to a half turn. */
+function asymptoticBound(value)
+{
+    return Math.atan(value) / Math.PI;
+}
+
+/** Internal compile-time shader variants for renderer quality. */
+const RENDER_QUALITY = Object.freeze({
+    LOW: Object.freeze({
+        LIGHT_SAMPLE_COUNT: 2,
+        ORDER_COUNT: 3,
+        SPECTRAL_TAP_COUNT: 1,
+    }),
+    DEFAULT: Object.freeze({
+        LIGHT_SAMPLE_COUNT: 4,
+        ORDER_COUNT: 4,
+        SPECTRAL_TAP_COUNT: 9,
+    }),
+    HIGH: Object.freeze({
+        LIGHT_SAMPLE_COUNT: 8,
+        ORDER_COUNT: 8,
+        SPECTRAL_TAP_COUNT: 9,
+    }),
+});
+
+const FOIL_CALIBRATION = Object.freeze({
+    FOIL_INTENSITY: 0.5,
+});
+
+const OUTPUT_CALIBRATION = Object.freeze({
+    LEVELS_BLACK_POINT: 0.0,
+    LEVELS_WHITE_POINT: 0.88,
+    LEVELS_MIDTONE: 1.0,
+});
+
+/** Read the non-executable page-data block emitted by the server. */
+function readCardPageData()
+{
+    return JSON.parse(document.getElementById("CardPageData").textContent);
+}
+
+/** Update the accessible card-preview status. */
+function showPreviewStatus(message, kind)
+{
+    const status = document.getElementById("PreviewStatus");
+    if(status != null)
+    {
+        status.textContent = message;
+        status.dataset.kind = kind;
+    }
+}
+
+/** Initialize and run the physical card preview copied from foil. */
+function main()
+{
+    try
+    {
+        const page_data = readCardPageData();
+        const canvas = document.getElementById("GLCanvas");
+        const gl = canvas.getContext("webgl2", {alpha: true});
+        if(!gl)
+        {
+            throw(new Error("Failed to initialize WebGL 2."));
+        }
+
+        const shader_definitions = Object.assign(
+            {}, RENDER_QUALITY.DEFAULT,
+            FOIL_CALIBRATION, OUTPUT_CALIBRATION);
+        const program = new ShaderProgram(
+            gl,
+            page_data.vertex_shader_url,
+            page_data.fragment_shader_url,
+            shader_definitions);
+        const renderer = new Renderer(gl, program);
+        const card = initScene(
+            gl,
+            program,
+            loadModel(page_data.model_url),
+            page_data);
+        card.front_material.ready.then(function reportReady()
+        {
+            showPreviewStatus(
+                page_data.foil_url == null
+                    ? "Standard finish"
+                    : "Foil finish",
+                "success");
+        }).catch(function reportTextureFailure(error)
+        {
+            console.error(error);
+            showPreviewStatus(error.message, "error");
+        });
+
+        const light = new DiskLight(
+            [0, 0, 2.0],
+            [0, 0, -2.0],
+            1,
+            3.6);
+        const camera_rotation = [0.0, 0.0];
+
+        /** Draw one animation frame using the latest pointer rotation. */
+        function render()
+        {
+            const matrices = calculateMatrices(canvas, camera_rotation);
+            renderer.draw(card.scene, matrices, light);
+            requestAnimationFrame(render);
+        }
+
+        canvas.addEventListener("mousemove", function rotateCard(event)
+        {
+            const position = getNormalizedMousePos(event, canvas);
+            camera_rotation[0] = -asymptoticBound(position[1] * 10.0);
+            camera_rotation[1] = -asymptoticBound(position[0] * 10.0);
+        });
+        requestAnimationFrame(render);
+    }
+    catch(error)
+    {
+        console.error(error);
+        showPreviewStatus(error.message, "error");
+    }
+}
+
+window.addEventListener("load", main);
diff --git a/static/foil/foil_math.js b/static/foil/foil_math.js
new file mode 100644
index 0000000..22e15b4
--- /dev/null
+++ b/static/foil/foil_math.js
@@ -0,0 +1,473 @@
+// CPU reference equations for the physically based foil shader.
+
+const GROOVE_SPACING_MIN_UM = 0.55;
+const GROOVE_SPACING_MAX_UM = 3.20;
+const VISIBLE_WAVELENGTH_MIN_UM = 0.380;
+const VISIBLE_WAVELENGTH_MAX_UM = 0.780;
+const ZERO_ORDER_ENERGY = 0.20;
+const TOTAL_DIFFRACTION_ENERGY = 0.12;
+const TRANSMITTED_PRINT_ENERGY = 0.60;
+const ABSORBED_ENERGY = 0.08;
+const ORDER_DECAY = 3.00;
+const SPECTRAL_SAMPLE_COUNT = 401;
+const MAX_GRATING_TILT_RADIANS = Math.PI / 12.0;
+const FOIL_DISORDER = 1.0;
+
+/** Decode one sRGB component into linear light. */
+function srgbToLinear(value)
+{
+    if(value <= 0.04045)
+    {
+        return value / 12.92;
+    }
+    return Math.pow((value + 0.055) / 1.055, 2.4);
+}
+
+/** Encode one nonnegative linear-light component as sRGB. */
+function linearToSrgb(value)
+{
+    if(value <= 0.0031308)
+    {
+        return 12.92 * value;
+    }
+    return 1.055 * Math.pow(value, 1.0 / 2.4) - 0.055;
+}
+
+/** Apply display-referred input Levels to one encoded component. */
+function applyLevels(value, black_point, white_point, midtone)
+{
+    if(!Number.isFinite(black_point) || !Number.isFinite(white_point)
+       || !Number.isFinite(midtone) || black_point < 0.0
+       || white_point > 1.0 || black_point >= white_point
+       || midtone <= 0.0)
+    {
+        throw(new Error("Invalid output Levels calibration."));
+    }
+    const normalized = Math.min(Math.max(
+        (value - black_point) / (white_point - black_point), 0.0), 1.0);
+    return Math.pow(normalized, 1.0 / midtone);
+}
+
+/** Convert D65 CIE XYZ values to linear sRGB. */
+function xyzToLinearSrgb(xyz)
+{
+    return [
+        3.24096994 * xyz[0] - 1.53738318 * xyz[1]
+            - 0.49861076 * xyz[2],
+        -0.96924364 * xyz[0] + 1.87596750 * xyz[1]
+            + 0.04155506 * xyz[2],
+        0.05563008 * xyz[0] - 0.20397696 * xyz[1]
+            + 1.05697151 * xyz[2],
+    ];
+}
+
+/** Return the real cube root of a possibly negative scalar. */
+function signedCubeRoot(value)
+{
+    return Math.sign(value) * Math.pow(Math.abs(value), 1.0 / 3.0);
+}
+
+/** Convert linear sRGB components to OKLab. */
+function linearSrgbToOklab(linear_rgb)
+{
+    const lms = [
+        0.4122214708 * linear_rgb[0]
+            + 0.5363325363 * linear_rgb[1]
+            + 0.0514459929 * linear_rgb[2],
+        0.2119034982 * linear_rgb[0]
+            + 0.6806995451 * linear_rgb[1]
+            + 0.1073969566 * linear_rgb[2],
+        0.0883024619 * linear_rgb[0]
+            + 0.2817188376 * linear_rgb[1]
+            + 0.6299787005 * linear_rgb[2],
+    ];
+    const root_lms = lms.map(signedCubeRoot);
+    return [
+        0.2104542553 * root_lms[0] + 0.7936177850 * root_lms[1]
+            - 0.0040720468 * root_lms[2],
+        1.9779984951 * root_lms[0] - 2.4285922050 * root_lms[1]
+            + 0.4505937099 * root_lms[2],
+        0.0259040371 * root_lms[0] + 0.7827717662 * root_lms[1]
+            - 0.8086757660 * root_lms[2],
+    ];
+}
+
+/** Convert OKLab components to linear sRGB without gamut clipping. */
+function oklabToLinearSrgb(lab)
+{
+    const root_lms = [
+        lab[0] + 0.3963377774 * lab[1] + 0.2158037573 * lab[2],
+        lab[0] - 0.1055613458 * lab[1] - 0.0638541728 * lab[2],
+        lab[0] - 0.0894841775 * lab[1] - 1.2914855480 * lab[2],
+    ];
+    const lms = root_lms.map(function cube(value)
+    {
+        return value * value * value;
+    });
+    return [
+        4.0767416621 * lms[0] - 3.3077115913 * lms[1]
+            + 0.2309699292 * lms[2],
+        -1.2684380046 * lms[0] + 2.6097574011 * lms[1]
+            - 0.3413193965 * lms[2],
+        -0.0041960863 * lms[0] - 0.7034186147 * lms[1]
+            + 1.7076147010 * lms[2],
+    ];
+}
+
+/** Return whether a linear RGB color lies inside the sRGB display cube. */
+function isInSrgbGamut(linear_rgb)
+{
+    return linear_rgb.every(function testChannel(value)
+    {
+        return value >= 0.0 && value <= 1.0;
+    });
+}
+
+/** Apply the shader's fixed-lightness OKLab chroma compression. */
+function perceptualGamutMap(linear_rgb)
+{
+    const lab = linearSrgbToOklab(linear_rgb);
+    lab[0] = Math.min(Math.max(lab[0], 0.0), 1.0);
+    const chroma = Math.hypot(lab[1], lab[2]);
+    if(chroma < 1e-5)
+    {
+        return oklabToLinearSrgb([lab[0], 0.0, 0.0]);
+    }
+
+    const hue = [lab[1] / chroma, lab[2] / chroma];
+    let lower_chroma = 0.0;
+    let upper_chroma = 0.5;
+    for(let iteration = 0; iteration < 10; ++iteration)
+    {
+        const candidate_chroma = 0.5 * (lower_chroma + upper_chroma);
+        const candidate = oklabToLinearSrgb([
+            lab[0], hue[0] * candidate_chroma,
+            hue[1] * candidate_chroma,
+        ]);
+        if(isInSrgbGamut(candidate))
+        {
+            lower_chroma = candidate_chroma;
+        }
+        else
+        {
+            upper_chroma = candidate_chroma;
+        }
+    }
+
+    const knee_chroma = 0.55 * lower_chroma;
+    const target_chroma = 0.85 * lower_chroma;
+    let mapped_chroma = chroma;
+    if(chroma > knee_chroma)
+    {
+        const compression_range = Math.max(
+            target_chroma - knee_chroma, 1e-5);
+        const excess = (chroma - knee_chroma) / compression_range;
+        mapped_chroma = knee_chroma + compression_range
+                      * (1.0 - Math.exp(-excess));
+    }
+    return oklabToLinearSrgb([
+        lab[0], hue[0] * mapped_chroma, hue[1] * mapped_chroma,
+    ]).map(function clampChannel(value)
+    {
+        return Math.min(Math.max(value, 0.0), 1.0);
+    });
+}
+
+/** Decode normalized red into physical groove spacing in micrometers. */
+function decodeGrooveSpacing(encoded_spacing)
+{
+    return GROOVE_SPACING_MAX_UM * Math.pow(
+        GROOVE_SPACING_MIN_UM / GROOVE_SPACING_MAX_UM,
+        encoded_spacing);
+}
+
+/** Decode normalized green into an unoriented two-dimensional axis. */
+function decodeGratingAxis(encoded_orientation)
+{
+    const angle = Math.PI * encoded_orientation;
+    return [Math.cos(angle), Math.sin(angle)];
+}
+
+/** Interpolate unoriented axes with doubled-angle circular arithmetic. */
+function interpolateGratingAxis(encoded_values, weights)
+{
+    let axis_x = 0.0;
+    let axis_y = 0.0;
+    for(let i = 0; i < encoded_values.length; ++i)
+    {
+        const doubled_angle = 2.0 * Math.PI * encoded_values[i];
+        axis_x += weights[i] * Math.cos(doubled_angle);
+        axis_y += weights[i] * Math.sin(doubled_angle);
+    }
+
+    const length = Math.hypot(axis_x, axis_y);
+    if(length < 1e-8)
+    {
+        return decodeGratingAxis(encoded_values[0]);
+    }
+
+    const angle = 0.5 * Math.atan2(axis_y / length, axis_x / length);
+    return [Math.cos(angle), Math.sin(angle)];
+}
+
+/** Decode normalized blue into signed local grating tilt in radians. */
+function decodeGratingTilt(encoded_tilt)
+{
+    return MAX_GRATING_TILT_RADIANS * (2.0 * encoded_tilt - 1.0);
+}
+
+/** Rotate a microscopic normal and grating axis around the groove axis. */
+function tiltGratingFrame(normal, grating_axis, tilt)
+{
+    const cosine = Math.cos(tilt);
+    const sine = Math.sin(tilt);
+    return {
+        normal: normal.map(function tiltNormal(value, index)
+        {
+            return cosine * value - sine * grating_axis[index];
+        }),
+        grating: grating_axis.map(function tiltGrating(value, index)
+        {
+            return cosine * value + sine * normal[index];
+        }),
+    };
+}
+
+/** Calculate the wavelength selected by one diffraction-order magnitude. */
+function diffractionWavelength(spacing_um, order_coordinate, order)
+{
+    return spacing_um * Math.abs(order_coordinate) / order;
+}
+
+/** Project incident and outgoing directions into a local grating frame. */
+function tangentProjection(light_direction, view_direction, normal,
+                           grating_axis, groove_axis)
+{
+    const direction_sum = light_direction.map(
+        function addDirection(value, index)
+        {
+            return value + view_direction[index];
+        });
+    const normal_component = direction_sum.reduce(
+        function dotNormal(total, value, index)
+        {
+            return total + value * normal[index];
+        }, 0.0);
+    const tangent_sum = direction_sum.map(
+        function removeNormal(value, index)
+        {
+            return value - normal_component * normal[index];
+        });
+
+    return {
+        u: tangent_sum.reduce(function dotGrating(total, value, index)
+        {
+            return total + value * grating_axis[index];
+        }, 0.0),
+        v: tangent_sum.reduce(function dotGroove(total, value, index)
+        {
+            return total + value * groove_axis[index];
+        }, 0.0),
+    };
+}
+
+/** Return normalized positive-order weights for an active order count. */
+function orderWeights(order_count)
+{
+    const weights = [];
+    let weight_sum = 0.0;
+    for(let order = 1; order <= order_count; ++order)
+    {
+        const weight = Math.exp(-ORDER_DECAY * (order - 1));
+        weights.push(weight);
+        weight_sum += weight;
+    }
+    return weights.map(function normalizeOrderWeight(weight)
+    {
+        return weight / weight_sum;
+    });
+}
+
+/** Return the energy allocation at one internal foil-intensity setting. */
+function foilEnergyAllocation(foil_intensity = 1.0)
+{
+    if(!Number.isFinite(foil_intensity) || foil_intensity < 0.0
+       || foil_intensity > 6.0)
+    {
+        throw(new Error("Foil intensity must be between 0.0 and 6.0."));
+    }
+    const diffraction = TOTAL_DIFFRACTION_ENERGY * foil_intensity;
+    const print = TRANSMITTED_PRINT_ENERGY
+                - TOTAL_DIFFRACTION_ENERGY * (foil_intensity - 1.0);
+    return {diffraction, print};
+}
+
+/** Return the energy assigned to one sign of an order. */
+function signedOrderEfficiency(order, order_count, foil_intensity = 1.0)
+{
+    return 0.5 * foilEnergyAllocation(foil_intensity).diffraction
+         * orderWeights(order_count)[order - 1];
+}
+
+/** Return the complete material energy allocation for validation. */
+function energyBudget(order_count, foil_intensity = 1.0)
+{
+    const allocation = foilEnergyAllocation(foil_intensity);
+    let diffraction_energy = 0.0;
+    for(let order = 1; order <= order_count; ++order)
+    {
+        diffraction_energy += 2.0
+                            * signedOrderEfficiency(
+                                order, order_count, foil_intensity);
+    }
+    return ZERO_ORDER_ENERGY + diffraction_energy
+         + allocation.print + ABSORBED_ENERGY;
+}
+
+/** Decode blue into the cross-groove projected standard deviation. */
+function decodeCrossGrooveWidth(encoded_disorder)
+{
+    const weight = encoded_disorder * encoded_disorder;
+    return 0.008 + (0.16 - 0.008) * weight;
+}
+
+/** Decode blue into relative groove-period standard deviation. */
+function decodePeriodSpread(encoded_disorder)
+{
+    const weight = encoded_disorder * encoded_disorder;
+    return 0.003 + (0.08 - 0.003) * weight;
+}
+
+/** Approximate one-axis angular standard deviation of a distant disk. */
+function diskAngularSigma(radius, distance)
+{
+    return 0.5 * radius / distance;
+}
+
+/** Combine statistically independent material and source widths. */
+function combineWidths(material_width, source_width)
+{
+    return Math.hypot(material_width, source_width);
+}
+
+/** Return wavelength width from material disorder and disk extent. */
+function diffractionWavelengthWidth(wavelength_um, relative_spread,
+                                    spacing_um, order,
+                                    source_angular_sigma)
+{
+    return combineWidths(
+        wavelength_um * relative_spread,
+        spacing_um * source_angular_sigma / order);
+}
+
+/** Evaluate the normalized cross-groove Gaussian density. */
+function crossGrooveGaussian(value, width)
+{
+    const normalized = value / width;
+    return Math.exp(-0.5 * normalized * normalized)
+         / (Math.sqrt(2.0 * Math.PI) * width);
+}
+
+/** Manually interpolate one source-weighted XYZ spectral table. */
+function sampleSpectralXyz(table, wavelength_um)
+{
+    if(wavelength_um < VISIBLE_WAVELENGTH_MIN_UM ||
+       wavelength_um > VISIBLE_WAVELENGTH_MAX_UM)
+    {
+        return [0.0, 0.0, 0.0];
+    }
+
+    const position = (wavelength_um - VISIBLE_WAVELENGTH_MIN_UM) * 1000.0;
+    const lower_index = Math.min(Math.floor(position),
+                                 SPECTRAL_SAMPLE_COUNT - 1);
+    const upper_index = Math.min(lower_index + 1,
+                                 SPECTRAL_SAMPLE_COUNT - 1);
+    const weight = position - lower_index;
+    const result = [];
+    for(let channel = 0; channel < 3; ++channel)
+    {
+        const lower = table[4 * lower_index + channel];
+        const upper = table[4 * upper_index + channel];
+        result.push(lower + weight * (upper - lower));
+    }
+    return result;
+}
+
+/** Evaluate the isotropic GGX normal distribution. */
+function distributionGgx(normal_half, roughness)
+{
+    const alpha = Math.max(roughness, 0.04);
+    const alpha_squared = alpha * alpha;
+    const denominator = normal_half * normal_half
+                      * (alpha_squared - 1.0) + 1.0;
+    return alpha_squared / (Math.PI * denominator * denominator);
+}
+
+/** Evaluate one GGX Smith masking term. */
+function geometryGgx(normal_direction, roughness)
+{
+    const cosine = Math.max(normal_direction, 0.0);
+    const alpha = Math.max(roughness, 0.04);
+    const root = Math.sqrt(alpha * alpha
+                         + (1.0 - alpha * alpha) * cosine * cosine);
+    return 2.0 * cosine / Math.max(cosine + root, 1e-8);
+}
+
+/** Evaluate correlated Smith visibility including the BRDF denominator. */
+function visibilitySmithGgx(normal_light, normal_view, roughness)
+{
+    const alpha = Math.max(roughness, 0.04);
+    const alpha_squared = alpha * alpha;
+    const view_term = normal_light * Math.sqrt(
+        normal_view * normal_view * (1.0 - alpha_squared)
+        + alpha_squared);
+    const light_term = normal_view * Math.sqrt(
+        normal_light * normal_light * (1.0 - alpha_squared)
+        + alpha_squared);
+    return 0.5 / Math.max(view_term + light_term, 1e-8);
+}
+
+if(typeof module != "undefined")
+{
+    module.exports = {
+        ABSORBED_ENERGY,
+        FOIL_DISORDER,
+        GROOVE_SPACING_MAX_UM,
+        GROOVE_SPACING_MIN_UM,
+        MAX_GRATING_TILT_RADIANS,
+        TOTAL_DIFFRACTION_ENERGY,
+        TRANSMITTED_PRINT_ENERGY,
+        VISIBLE_WAVELENGTH_MAX_UM,
+        VISIBLE_WAVELENGTH_MIN_UM,
+        ZERO_ORDER_ENERGY,
+        applyLevels,
+        crossGrooveGaussian,
+        decodeCrossGrooveWidth,
+        decodeGratingAxis,
+        decodeGratingTilt,
+        decodeGrooveSpacing,
+        decodePeriodSpread,
+        diffractionWavelengthWidth,
+        diffractionWavelength,
+        diskAngularSigma,
+        distributionGgx,
+        energyBudget,
+        foilEnergyAllocation,
+        geometryGgx,
+        interpolateGratingAxis,
+        isInSrgbGamut,
+        linearToSrgb,
+        linearSrgbToOklab,
+        oklabToLinearSrgb,
+        orderWeights,
+        sampleSpectralXyz,
+        signedOrderEfficiency,
+        srgbToLinear,
+        perceptualGamutMap,
+        tangentProjection,
+        tiltGratingFrame,
+        visibilitySmithGgx,
+        combineWidths,
+        xyzToLinearSrgb,
+    };
+}
diff --git a/static/foil/frag-shader.glsl b/static/foil/frag-shader.glsl
new file mode 100644
index 0000000..99cf32f
--- /dev/null
+++ b/static/foil/frag-shader.glsl
@@ -0,0 +1,770 @@
+#version 300 es
+// -*- mode: c; -*-
+precision highp float;
+
+#ifndef LIGHT_SAMPLE_COUNT
+#define LIGHT_SAMPLE_COUNT 4
+#endif
+#ifndef ORDER_COUNT
+#define ORDER_COUNT 4
+#endif
+#ifndef SPECTRAL_TAP_COUNT
+#define SPECTRAL_TAP_COUNT 9
+#endif
+#ifndef DIFFRACTION_ORDER
+#define DIFFRACTION_ORDER 0
+#endif
+#ifndef FOIL_INTENSITY
+#define FOIL_INTENSITY 1.0
+#endif
+#ifndef LEVELS_BLACK_POINT
+#define LEVELS_BLACK_POINT 0.0
+#endif
+#ifndef LEVELS_WHITE_POINT
+#define LEVELS_WHITE_POINT 1.0
+#endif
+#ifndef LEVELS_MIDTONE
+#define LEVELS_MIDTONE 1.0
+#endif
+
+const float PI = 3.14159265359;
+const float BRDF_EPSILON = 0.00001;
+const float MAX_GRATING_TILT_RADIANS = PI / 12.0;
+const float FOIL_DISORDER = 1.0;
+const float GROOVE_SPACING_MIN_UM = 0.55;
+const float GROOVE_SPACING_MAX_UM = 3.20;
+const float VISIBLE_WAVELENGTH_MIN_UM = 0.380;
+const float VISIBLE_WAVELENGTH_MAX_UM = 0.780;
+const float SPECTRAL_JACOBIAN_SCALE = 1000.0;
+const float FOIL_GGX_ROUGHNESS = 0.16;
+const vec3 FOIL_F_0 = vec3(0.82);
+const float ZERO_ORDER_ENERGY = 0.20;
+const float BASE_TOTAL_DIFFRACTION_ENERGY = 0.12;
+const float BASE_TRANSMITTED_PRINT_ENERGY = 0.60;
+const float FOIL_INTENSITY_VALUE = float(FOIL_INTENSITY);
+const float TOTAL_DIFFRACTION_ENERGY =
+    BASE_TOTAL_DIFFRACTION_ENERGY * FOIL_INTENSITY_VALUE;
+const float SIGNED_DIFFRACTION_ENERGY =
+    0.5 * TOTAL_DIFFRACTION_ENERGY;
+const float TRANSMITTED_PRINT_ENERGY =
+    BASE_TRANSMITTED_PRINT_ENERGY
+    - BASE_TOTAL_DIFFRACTION_ENERGY * (FOIL_INTENSITY_VALUE - 1.0);
+const float ORDER_DECAY = 3.00;
+const float AMBIENT_PRINT_IRRADIANCE = 0.08;
+const float EXPOSURE = 1.0;
+const float GAMUT_KNEE_FRACTION = 0.55;
+const float GAMUT_TARGET_FRACTION = 0.85;
+const float ARTISTIC_COVERAGE_EXPONENT = 2.2;
+const float LEVELS_BLACK_POINT_VALUE = float(LEVELS_BLACK_POINT);
+const float LEVELS_WHITE_POINT_VALUE = float(LEVELS_WHITE_POINT);
+const float LEVELS_MIDTONE_VALUE = float(LEVELS_MIDTONE);
+const int MATERIAL_PHYSICAL_FOIL = 0;
+const int MATERIAL_SOLID_COLOR = 1;
+const int MATERIAL_ARTWORK = 2;
+#if LIGHT_SAMPLE_COUNT == 2
+const vec2 LIGHT_SAMPLES[LIGHT_SAMPLE_COUNT] = vec2[](
+    vec2(-0.5, -0.5),
+    vec2(0.5, 0.5)
+);
+#elif LIGHT_SAMPLE_COUNT == 4
+const vec2 LIGHT_SAMPLES[LIGHT_SAMPLE_COUNT] = vec2[](
+    vec2(-0.5, -0.5),
+    vec2(0.5, -0.5),
+    vec2(-0.5, 0.5),
+    vec2(0.5, 0.5)
+);
+#elif LIGHT_SAMPLE_COUNT == 8
+const vec2 LIGHT_SAMPLES[LIGHT_SAMPLE_COUNT] = vec2[](
+    vec2(0.5, 0.0),
+    vec2(0.0, 0.5),
+    vec2(-0.5, 0.0),
+    vec2(0.0, -0.5),
+    vec2(0.612372436, 0.612372436),
+    vec2(-0.612372436, 0.612372436),
+    vec2(-0.612372436, -0.612372436),
+    vec2(0.612372436, -0.612372436)
+);
+#elif LIGHT_SAMPLE_COUNT == 16
+// Two staggered rings have zero centroid and mean squared radius 1/2,
+// matching the first two radial moments of a uniform unit disk.
+const vec2 LIGHT_SAMPLES[LIGHT_SAMPLE_COUNT] = vec2[](
+    vec2(0.500000000, 0.000000000),
+    vec2(0.353553391, 0.353553391),
+    vec2(0.000000000, 0.500000000),
+    vec2(-0.353553391, 0.353553391),
+    vec2(-0.500000000, 0.000000000),
+    vec2(-0.353553391, -0.353553391),
+    vec2(0.000000000, -0.500000000),
+    vec2(0.353553391, -0.353553391),
+    vec2(0.800103145, 0.331413574),
+    vec2(0.331413574, 0.800103145),
+    vec2(-0.331413574, 0.800103145),
+    vec2(-0.800103145, 0.331413574),
+    vec2(-0.800103145, -0.331413574),
+    vec2(-0.331413574, -0.800103145),
+    vec2(0.331413574, -0.800103145),
+    vec2(0.800103145, -0.331413574)
+);
+#else
+#error Unsupported LIGHT_SAMPLE_COUNT
+#endif
+
+in vec2 v_texcoord;
+in vec3 v_view_position;
+in vec3 v_view_normal;
+in vec3 v_view_tangent;
+in vec3 v_view_bitangent;
+
+uniform sampler2D u_artwork;
+uniform sampler2D u_foil_control;
+uniform sampler2D u_spectral_xyz;
+uniform int u_material_kind;
+uniform vec3 u_solid_color_srgb;
+uniform vec3 u_light_position;
+uniform vec3 u_light_normal;
+uniform vec3 u_light_axis_x;
+uniform vec3 u_light_axis_y;
+uniform float u_light_radius;
+uniform float u_light_radiance;
+
+out vec4 out_color;
+
+struct FoilControl
+{
+    float groove_spacing_um;
+    vec2 grating_axis;
+    float grating_tilt;
+    float coverage;
+};
+
+// Construct finite controls that select only the ordinary printed layer.
+FoilControl noFoilControl()
+{
+    FoilControl control;
+    control.groove_spacing_um = GROOVE_SPACING_MIN_UM;
+    control.grating_axis = vec2(1.0, 0.0);
+    control.grating_tilt = 0.0;
+    control.coverage = 0.0;
+    return control;
+}
+
+// Decode display-referred artwork into the linear-light working space.
+vec3 srgbToLinear(vec3 encoded)
+{
+    vec3 lower = encoded / 12.92;
+    vec3 upper = pow((encoded + 0.055) / 1.055, vec3(2.4));
+    return mix(upper, lower, lessThanEqual(encoded, vec3(0.04045)));
+}
+
+// Encode a tone-mapped linear-light value for the default sRGB framebuffer.
+vec3 linearToSrgb(vec3 linear_color)
+{
+    vec3 lower = 12.92 * linear_color;
+    vec3 upper = 1.055 * pow(linear_color, vec3(1.0 / 2.4)) - 0.055;
+    return mix(upper, lower,
+               lessThanEqual(linear_color, vec3(0.0031308)));
+}
+
+// Apply display-referred input Levels after the physical color pipeline.
+// Midtone follows image-editor convention: values above one brighten.
+vec3 applyLevels(vec3 encoded_color)
+{
+    float level_range = max(
+        LEVELS_WHITE_POINT_VALUE - LEVELS_BLACK_POINT_VALUE,
+        BRDF_EPSILON);
+    vec3 normalized = clamp(
+        (encoded_color - LEVELS_BLACK_POINT_VALUE) / level_range,
+        0.0, 1.0);
+    return pow(normalized, vec3(1.0 / LEVELS_MIDTONE_VALUE));
+}
+
+// Compress HDR luminance while preserving chromaticity for gamut mapping.
+vec3 toneMap(vec3 hdr_color)
+{
+    float luminance = dot(hdr_color, vec3(0.2126, 0.7152, 0.0722));
+    if(luminance <= 0.0)
+    {
+        return vec3(0.0);
+    }
+    float mapped_luminance = 1.0 - exp(-EXPOSURE * luminance);
+    return hdr_color * mapped_luminance / luminance;
+}
+
+// Convert D65 CIE XYZ tristimulus values into linear sRGB.
+vec3 xyzToLinearSrgb(vec3 xyz)
+{
+    const mat3 XYZ_TO_SRGB = mat3(
+         3.24096994, -0.96924364,  0.05563008,
+        -1.53738318,  1.87596750, -0.20397696,
+        -0.49861076,  0.04155506,  1.05697151
+    );
+    return XYZ_TO_SRGB * xyz;
+}
+
+// Cube root with defined behavior for out-of-gamut negative LMS values.
+float signedCubeRoot(float value)
+{
+    return sign(value) * pow(abs(value), 1.0 / 3.0);
+}
+
+// Convert linear sRGB to the perceptually uniform OKLab space.
+vec3 linearSrgbToOklab(vec3 linear_rgb)
+{
+    vec3 lms = vec3(
+        0.4122214708 * linear_rgb.r
+            + 0.5363325363 * linear_rgb.g
+            + 0.0514459929 * linear_rgb.b,
+        0.2119034982 * linear_rgb.r
+            + 0.6806995451 * linear_rgb.g
+            + 0.1073969566 * linear_rgb.b,
+        0.0883024619 * linear_rgb.r
+            + 0.2817188376 * linear_rgb.g
+            + 0.6299787005 * linear_rgb.b
+    );
+    vec3 root_lms = vec3(
+        signedCubeRoot(lms.x),
+        signedCubeRoot(lms.y),
+        signedCubeRoot(lms.z));
+    return vec3(
+        0.2104542553 * root_lms.x
+            + 0.7936177850 * root_lms.y
+            - 0.0040720468 * root_lms.z,
+        1.9779984951 * root_lms.x
+            - 2.4285922050 * root_lms.y
+            + 0.4505937099 * root_lms.z,
+        0.0259040371 * root_lms.x
+            + 0.7827717662 * root_lms.y
+            - 0.8086757660 * root_lms.z
+    );
+}
+
+// Convert OKLab back to linear sRGB without clipping its chroma.
+vec3 oklabToLinearSrgb(vec3 lab)
+{
+    vec3 root_lms = vec3(
+        lab.x + 0.3963377774 * lab.y + 0.2158037573 * lab.z,
+        lab.x - 0.1055613458 * lab.y - 0.0638541728 * lab.z,
+        lab.x - 0.0894841775 * lab.y - 1.2914855480 * lab.z
+    );
+    vec3 lms = root_lms * root_lms * root_lms;
+    return vec3(
+        4.0767416621 * lms.x - 3.3077115913 * lms.y
+            + 0.2309699292 * lms.z,
+        -1.2684380046 * lms.x + 2.6097574011 * lms.y
+            - 0.3413193965 * lms.z,
+        -0.0041960863 * lms.x - 0.7034186147 * lms.y
+            + 1.7076147010 * lms.z
+    );
+}
+
+// Return whether a linear color lies inside the display cube.
+bool isInSrgbGamut(vec3 linear_rgb)
+{
+    return all(greaterThanEqual(linear_rgb, vec3(0.0)))
+        && all(lessThanEqual(linear_rgb, vec3(1.0)));
+}
+
+// Compress OKLab chroma at fixed perceived lightness and hue. A binary search
+// finds the sRGB cusp for this hue; the soft knee maps extreme spectral colors
+// to 85% of that cusp instead of clipping them to neon display primaries.
+vec3 perceptualGamutMap(vec3 linear_rgb)
+{
+    vec3 lab = linearSrgbToOklab(linear_rgb);
+    lab.x = clamp(lab.x, 0.0, 1.0);
+    float chroma = length(lab.yz);
+    if(chroma < BRDF_EPSILON)
+    {
+        return oklabToLinearSrgb(vec3(lab.x, 0.0, 0.0));
+    }
+
+    vec2 hue = lab.yz / chroma;
+    float lower_chroma = 0.0;
+    float upper_chroma = 0.5;
+    for(int iteration = 0; iteration < 10; ++iteration)
+    {
+        float candidate_chroma = 0.5 * (lower_chroma + upper_chroma);
+        vec3 candidate = oklabToLinearSrgb(
+            vec3(lab.x, hue * candidate_chroma));
+        if(isInSrgbGamut(candidate))
+        {
+            lower_chroma = candidate_chroma;
+        }
+        else
+        {
+            upper_chroma = candidate_chroma;
+        }
+    }
+
+    float gamut_chroma = lower_chroma;
+    float knee_chroma = GAMUT_KNEE_FRACTION * gamut_chroma;
+    float target_chroma = GAMUT_TARGET_FRACTION * gamut_chroma;
+    float mapped_chroma = chroma;
+    if(chroma > knee_chroma)
+    {
+        float compression_range = max(
+            target_chroma - knee_chroma, BRDF_EPSILON);
+        float excess = (chroma - knee_chroma) / compression_range;
+        mapped_chroma = knee_chroma
+                      + compression_range * (1.0 - exp(-excess));
+    }
+    return clamp(oklabToLinearSrgb(
+        vec3(lab.x, hue * mapped_chroma)), 0.0, 1.0);
+}
+
+// Introduce spectral gamut compression continuously with foil coverage. The
+// early return avoids the expensive OKLab search for ordinary card regions,
+// while mix() makes its limiting value equal the non-foil rendering.
+vec3 applyFoilGamutMap(vec3 linear_rgb, float coverage)
+{
+    vec3 ordinary_rgb = clamp(linear_rgb, 0.0, 1.0);
+    if(coverage <= 0.0)
+    {
+        return ordinary_rgb;
+    }
+    vec3 mapped_rgb = perceptualGamutMap(linear_rgb);
+    return mix(ordinary_rgb, mapped_rgb, clamp(coverage, 0.0, 1.0));
+}
+
+// Normalize a vector without allowing a zero-length interpolation to produce
+// NaNs. Callers choose a fallback appropriate to their geometric role.
+vec3 safeNormalize(vec3 value, vec3 fallback)
+{
+    float length_squared = dot(value, value);
+    if(length_squared < BRDF_EPSILON)
+    {
+        return fallback;
+    }
+    return value * inversesqrt(length_squared);
+}
+
+// Construct a stable tangent for the rare case where interpolation cancels a
+// vertex tangent exactly.
+vec3 orthogonalVector(vec3 normal)
+{
+    vec3 reference = abs(normal.z) < 0.999
+                   ? vec3(0.0, 0.0, 1.0)
+                   : vec3(0.0, 1.0, 0.0);
+    return safeNormalize(cross(reference, normal), vec3(1.0, 0.0, 0.0));
+}
+
+// Interpolate four scalar texels with one two-dimensional weight.
+float bilinear(float value_00, float value_10, float value_01,
+               float value_11, vec2 weight)
+{
+    float lower = mix(value_00, value_10, weight.x);
+    float upper = mix(value_01, value_11, weight.x);
+    return mix(lower, upper, weight.y);
+}
+
+// Map the unoriented green value onto its doubled-angle unit circle.
+vec2 doubledOrientation(float encoded_orientation)
+{
+    float doubled_angle = 2.0 * PI * encoded_orientation;
+    return vec2(cos(doubled_angle), sin(doubled_angle));
+}
+
+// A physical area-coverage field would mix the ordinary and foil BRDFs
+// linearly, making reflected energy proportional to the stored alpha. Tone
+// mapping and sRGB encoding make small linear foil energies look much stronger
+// than artists expect. For a representative full-coverage linear foil peak of
+// 0.84, the unmodified display pipeline produces approximately:
+//
+//     Coverage    Linear foil    sRGB display value
+//     1.00        0.840          0.78
+//     0.10        0.084          0.32
+//     0.01        0.0084         0.09
+//
+// Ten percent of the physical foil energy can therefore still appear about
+// forty percent as bright numerically. Treat alpha as an artistic control and
+// apply a display-inspired response curve so partial values fade faster. This
+// deliberately breaks energy conservation with respect to the authored
+// coverage value: the stored alpha is no longer a literal physical area
+// fraction. The endpoint BRDFs remain energy bounded because the effective
+// coverage still lies in [0, 1].
+float decodeArtisticCoverage(float encoded_coverage)
+{
+    return pow(clamp(encoded_coverage, 0.0, 1.0),
+               ARTISTIC_COVERAGE_EXPONENT);
+}
+
+// Decode the version-2 packed control texture with orientation-safe filtering.
+FoilControl sampleFoilControl(vec2 texture_coord)
+{
+    ivec2 texture_size = textureSize(u_foil_control, 0);
+    vec2 sample_position = clamp(texture_coord, 0.0, 1.0)
+                         * vec2(texture_size) - 0.5;
+    ivec2 lower_coord = ivec2(floor(sample_position));
+    ivec2 upper_bound = texture_size - ivec2(1);
+    vec2 weight = fract(sample_position);
+
+    ivec2 coord_00 = clamp(lower_coord, ivec2(0), upper_bound);
+    ivec2 coord_10 = clamp(lower_coord + ivec2(1, 0),
+                           ivec2(0), upper_bound);
+    ivec2 coord_01 = clamp(lower_coord + ivec2(0, 1),
+                           ivec2(0), upper_bound);
+    ivec2 coord_11 = clamp(lower_coord + ivec2(1, 1),
+                           ivec2(0), upper_bound);
+    vec4 value_00 = texelFetch(u_foil_control, coord_00, 0);
+    vec4 value_10 = texelFetch(u_foil_control, coord_10, 0);
+    vec4 value_01 = texelFetch(u_foil_control, coord_01, 0);
+    vec4 value_11 = texelFetch(u_foil_control, coord_11, 0);
+
+    float encoded_spacing = bilinear(
+        value_00.r, value_10.r, value_01.r, value_11.r, weight);
+    float encoded_tilt = bilinear(
+        value_00.b, value_10.b, value_01.b, value_11.b, weight);
+    float coverage = bilinear(
+        value_00.a, value_10.a, value_01.a, value_11.a, weight);
+
+    vec2 lower_axis = mix(doubledOrientation(value_00.g),
+                          doubledOrientation(value_10.g), weight.x);
+    vec2 upper_axis = mix(doubledOrientation(value_01.g),
+                          doubledOrientation(value_11.g), weight.x);
+    vec2 doubled_axis = mix(lower_axis, upper_axis, weight.y);
+    if(dot(doubled_axis, doubled_axis) < BRDF_EPSILON)
+    {
+        doubled_axis = doubledOrientation(value_00.g);
+    }
+    doubled_axis = normalize(doubled_axis);
+    float grating_angle = 0.5 * atan(doubled_axis.y, doubled_axis.x);
+
+    FoilControl control;
+    control.groove_spacing_um = GROOVE_SPACING_MAX_UM * pow(
+        GROOVE_SPACING_MIN_UM / GROOVE_SPACING_MAX_UM,
+        encoded_spacing);
+    control.grating_axis = vec2(cos(grating_angle), sin(grating_angle));
+    control.grating_tilt = MAX_GRATING_TILT_RADIANS
+                         * (2.0 * clamp(encoded_tilt, 0.0, 1.0) - 1.0);
+    control.coverage = decodeArtisticCoverage(coverage);
+    return control;
+}
+
+// Interpolate the source-weighted CIE table without float texture filtering.
+vec3 sampleSpectralXyz(float wavelength_um)
+{
+    if(wavelength_um < VISIBLE_WAVELENGTH_MIN_UM ||
+       wavelength_um > VISIBLE_WAVELENGTH_MAX_UM)
+    {
+        return vec3(0.0);
+    }
+
+    float position = (wavelength_um - VISIBLE_WAVELENGTH_MIN_UM) * 1000.0;
+    int lower_index = min(int(floor(position)), 400);
+    int upper_index = min(lower_index + 1, 400);
+    float weight = position - float(lower_index);
+    vec3 lower = texelFetch(u_spectral_xyz, ivec2(lower_index, 0), 0).rgb;
+    vec3 upper = texelFetch(u_spectral_xyz, ivec2(upper_index, 0), 0).rgb;
+    return mix(lower, upper, weight);
+}
+
+// Evaluate the isotropic GGX normal distribution.
+float distributionGgx(float normal_half)
+{
+    float alpha_squared = FOIL_GGX_ROUGHNESS * FOIL_GGX_ROUGHNESS;
+    float denominator = normal_half * normal_half
+                      * (alpha_squared - 1.0) + 1.0;
+    return alpha_squared
+         / max(PI * denominator * denominator, BRDF_EPSILON);
+}
+
+// Evaluate height-correlated Smith visibility including the BRDF denominator.
+float visibilitySmithGgx(float normal_light, float normal_view)
+{
+    float alpha_squared = FOIL_GGX_ROUGHNESS * FOIL_GGX_ROUGHNESS;
+    float view_term = normal_light * sqrt(
+        normal_view * normal_view * (1.0 - alpha_squared)
+        + alpha_squared);
+    float light_term = normal_view * sqrt(
+        normal_light * normal_light * (1.0 - alpha_squared)
+        + alpha_squared);
+    return 0.5 / max(view_term + light_term, BRDF_EPSILON);
+}
+
+// Approximate neutral coating Fresnel reflectance.
+vec3 fresnelSchlick(float view_half)
+{
+    return FOIL_F_0 + (vec3(1.0) - FOIL_F_0)
+         * pow(1.0 - view_half, 5.0);
+}
+
+// Evaluate the ordinary zeroth-order reflection BRDF.
+vec3 evaluateSpecularBrdf(vec3 light_direction, vec3 view_direction,
+                          vec3 normal)
+{
+    vec3 half_sum = light_direction + view_direction;
+    if(dot(half_sum, half_sum) < BRDF_EPSILON)
+    {
+        return vec3(0.0);
+    }
+    vec3 half_vector = normalize(half_sum);
+    float normal_light = max(dot(normal, light_direction), 0.0);
+    float normal_view = max(dot(normal, view_direction), 0.0);
+    float normal_half = max(dot(normal, half_vector), 0.0);
+    float view_half = max(dot(view_direction, half_vector), 0.0);
+    return distributionGgx(normal_half)
+         * visibilitySmithGgx(normal_light, normal_view)
+         * fresnelSchlick(view_half);
+}
+
+// Evaluate the normalized cross-groove density at the fixed maximum disorder.
+float crossGrooveLobe(float cross_coordinate, float source_sigma)
+{
+    float material_width = mix(
+        0.008, 0.16, FOIL_DISORDER * FOIL_DISORDER);
+    float width = sqrt(material_width * material_width
+                     + source_sigma * source_sigma);
+    float normalized = cross_coordinate / width;
+    return exp(-0.5 * normalized * normalized)
+         / (sqrt(2.0 * PI) * width);
+}
+
+// Convolve the spectrum with the fixed maximum period disorder.
+vec3 sampleDisorderedSpectrum(float wavelength_um, float source_width_um)
+{
+#if SPECTRAL_TAP_COUNT == 1
+    return sampleSpectralXyz(wavelength_um);
+#elif SPECTRAL_TAP_COUNT == 3
+    float relative_spread = mix(
+        0.003, 0.08, FOIL_DISORDER * FOIL_DISORDER);
+    float material_width_um = wavelength_um * relative_spread;
+    float wavelength_width = sqrt(
+        material_width_um * material_width_um
+        + source_width_um * source_width_um);
+    return 0.25 * sampleSpectralXyz(wavelength_um - wavelength_width)
+         + 0.50 * sampleSpectralXyz(wavelength_um)
+         + 0.25 * sampleSpectralXyz(wavelength_um + wavelength_width);
+#elif SPECTRAL_TAP_COUNT == 9
+    // A normalized binomial kernel approximates a Gaussian without allowing
+    // a broad source to degenerate into three isolated spectral primaries.
+    float relative_spread = mix(
+        0.003, 0.08, FOIL_DISORDER * FOIL_DISORDER);
+    float material_width_um = wavelength_um * relative_spread;
+    float wavelength_width = sqrt(
+        material_width_um * material_width_um
+        + source_width_um * source_width_um);
+    float step_width = wavelength_width / sqrt(2.0);
+    return (sampleSpectralXyz(wavelength_um - 4.0 * step_width)
+          + 8.0 * sampleSpectralXyz(wavelength_um - 3.0 * step_width)
+          + 28.0 * sampleSpectralXyz(wavelength_um - 2.0 * step_width)
+          + 56.0 * sampleSpectralXyz(wavelength_um - step_width)
+          + 70.0 * sampleSpectralXyz(wavelength_um)
+          + 56.0 * sampleSpectralXyz(wavelength_um + step_width)
+          + 28.0 * sampleSpectralXyz(wavelength_um + 2.0 * step_width)
+          + 8.0 * sampleSpectralXyz(wavelength_um + 3.0 * step_width)
+          + sampleSpectralXyz(wavelength_um + 4.0 * step_width)) / 256.0;
+#else
+#error Unsupported SPECTRAL_TAP_COUNT
+#endif
+}
+
+// Allocate half the nonzero-order energy to the visible signed order.
+float orderEfficiency(int order_index)
+{
+#if DIFFRACTION_ORDER > 0
+    return order_index + 1 == DIFFRACTION_ORDER
+         ? SIGNED_DIFFRACTION_ENERGY
+         : 0.0;
+#else
+    float weight_sum = 0.0;
+    for(int index = 0; index < ORDER_COUNT; ++index)
+    {
+        weight_sum += exp(-ORDER_DECAY * float(index));
+    }
+    float weight = exp(-ORDER_DECAY * float(order_index));
+    return SIGNED_DIFFRACTION_ENERGY * weight / weight_sum;
+#endif
+}
+
+// Evaluate all supported nonzero diffraction orders in CIE XYZ.
+vec3 evaluateDiffractionXyz(FoilControl control,
+                            vec3 light_direction, vec3 view_direction,
+                            vec3 normal, vec3 tangent, vec3 bitangent,
+                            float source_sigma)
+{
+    vec3 grating = control.grating_axis.x * tangent
+                 + control.grating_axis.y * bitangent;
+    vec3 groove = -control.grating_axis.y * tangent
+                + control.grating_axis.x * bitangent;
+    // Tilt only the microscopic grating frame around the groove axis. The
+    // macroscopic card normal still controls print lighting and visibility.
+    float tilt_cosine = cos(control.grating_tilt);
+    float tilt_sine = sin(control.grating_tilt);
+    vec3 tilted_grating = tilt_cosine * grating + tilt_sine * normal;
+    vec3 tilted_normal = tilt_cosine * normal - tilt_sine * grating;
+    vec3 direction_sum = light_direction + view_direction;
+    vec3 tangent_sum = direction_sum
+                     - tilted_normal * dot(direction_sum, tilted_normal);
+    float order_coordinate = abs(dot(tangent_sum, tilted_grating));
+    float cross_coordinate = dot(tangent_sum, groove);
+    float cross_response = crossGrooveLobe(
+        cross_coordinate, source_sigma);
+    vec3 diffraction_xyz = vec3(0.0);
+
+    for(int order_index = 0; order_index < ORDER_COUNT; ++order_index)
+    {
+#if DIFFRACTION_ORDER > 0
+        if(order_index + 1 != DIFFRACTION_ORDER)
+        {
+            continue;
+        }
+#endif
+        float order = float(order_index + 1);
+        float wavelength_um = control.groove_spacing_um
+                            * order_coordinate / order;
+        float source_width_um = control.groove_spacing_um
+                              * source_sigma / order;
+        vec3 spectral_xyz = sampleDisorderedSpectrum(
+            wavelength_um, source_width_um);
+        float jacobian = SPECTRAL_JACOBIAN_SCALE
+                       * control.groove_spacing_um / order;
+        diffraction_xyz += orderEfficiency(order_index)
+                         * cross_response * jacobian
+                         * spectral_xyz;
+    }
+    return diffraction_xyz;
+}
+
+// Evaluate nonspectral card layers without radiance or cosine factors.
+vec3 evaluateOrdinaryBrdf(FoilControl control, vec3 albedo,
+                          vec3 light_direction, vec3 view_direction,
+                          vec3 normal)
+{
+    float print_energy = mix(1.0, TRANSMITTED_PRINT_ENERGY,
+                             control.coverage);
+    vec3 print_brdf = print_energy * albedo / PI;
+    if(control.coverage > 0.0)
+    {
+        vec3 specular_brdf = ZERO_ORDER_ENERGY * evaluateSpecularBrdf(
+            light_direction, view_direction, normal);
+        print_brdf += control.coverage * specular_brdf;
+    }
+    return print_brdf;
+}
+
+// Integrate all BRDF components over the deterministic disk-light samples.
+vec3 integrateDiskLight(FoilControl control, vec3 albedo,
+                        vec3 view_direction, vec3 normal,
+                        vec3 tangent, vec3 bitangent)
+{
+    float light_area = PI * u_light_radius * u_light_radius;
+    vec3 direct_rgb = vec3(0.0);
+    vec3 diffraction_xyz = vec3(0.0);
+
+    for(int sample_index = 0;
+        sample_index < LIGHT_SAMPLE_COUNT; ++sample_index)
+    {
+        vec2 disk_coord = LIGHT_SAMPLES[sample_index];
+        vec3 sample_position = u_light_position
+                             + u_light_radius
+                             * (disk_coord.x * u_light_axis_x
+                                + disk_coord.y * u_light_axis_y);
+        vec3 to_light = sample_position - v_view_position;
+        float distance_squared = dot(to_light, to_light);
+        vec3 light_direction = to_light
+                             * inversesqrt(max(distance_squared,
+                                               BRDF_EPSILON));
+        float surface_cosine = max(dot(normal, light_direction), 0.0);
+        float emitter_cosine = max(dot(u_light_normal,
+                                       -light_direction), 0.0);
+        if(surface_cosine <= 0.0 || emitter_cosine <= 0.0)
+        {
+            continue;
+        }
+
+        float sample_weight = light_area / float(LIGHT_SAMPLE_COUNT)
+                            * u_light_radiance * surface_cosine
+                            * emitter_cosine
+                            / max(distance_squared, BRDF_EPSILON);
+        direct_rgb += sample_weight * evaluateOrdinaryBrdf(
+            control, albedo, light_direction, view_direction, normal);
+    }
+
+    // A handful of point samples visibly duplicate a narrow rainbow. For the
+    // diffraction layer, convolve one center ray with the continuous angular
+    // footprint of the disk instead. A uniform disk coordinate has standard
+    // deviation R/2; the distant-light angular approximation is therefore
+    // R/(2D). It broadens both the cross-groove lobe and selected wavelength.
+    if(control.coverage > 0.0)
+    {
+        vec3 center_vector = u_light_position - v_view_position;
+        float center_distance_squared = dot(center_vector, center_vector);
+        vec3 center_direction = center_vector * inversesqrt(
+            max(center_distance_squared, BRDF_EPSILON));
+        float surface_cosine = max(dot(normal, center_direction), 0.0);
+        float emitter_cosine = max(dot(u_light_normal,
+                                       -center_direction), 0.0);
+        if(surface_cosine > 0.0 && emitter_cosine > 0.0)
+        {
+            float center_weight = light_area * u_light_radiance
+                                * surface_cosine * emitter_cosine
+                                / max(center_distance_squared,
+                                      BRDF_EPSILON);
+            float source_sigma = 0.5 * u_light_radius * inversesqrt(
+                max(center_distance_squared, BRDF_EPSILON));
+            diffraction_xyz = center_weight * control.coverage
+                            * evaluateDiffractionXyz(
+                                control, center_direction, view_direction,
+                                normal, tangent, bitangent, source_sigma);
+        }
+    }
+
+    return direct_rgb + xyzToLinearSrgb(diffraction_xyz);
+}
+
+void main()
+{
+    vec4 artwork_sample;
+    FoilControl control;
+    if(u_material_kind == MATERIAL_SOLID_COLOR)
+    {
+        // The shell deliberately owns no texture. A uniform branch keeps its
+        // draw from sampling any of the front-only material resources.
+        artwork_sample = vec4(u_solid_color_srgb, 1.0);
+        control = noFoilControl();
+    }
+    else if(u_material_kind == MATERIAL_ARTWORK)
+    {
+        artwork_sample = texture(u_artwork, v_texcoord);
+        control = noFoilControl();
+    }
+    else if(u_material_kind == MATERIAL_PHYSICAL_FOIL)
+    {
+        artwork_sample = texture(u_artwork, v_texcoord);
+        control = sampleFoilControl(v_texcoord);
+    }
+    else
+    {
+        // Unknown modes are programming errors; magenta makes them obvious.
+        artwork_sample = vec4(1.0, 0.0, 1.0, 1.0);
+        control = noFoilControl();
+    }
+    vec3 albedo = srgbToLinear(artwork_sample.rgb);
+    vec3 view_direction = safeNormalize(
+        -v_view_position, vec3(0.0, 0.0, 1.0));
+    vec3 normal = safeNormalize(v_view_normal, vec3(0.0, 0.0, 1.0));
+    vec3 tangent = safeNormalize(
+        v_view_tangent - normal * dot(v_view_tangent, normal),
+        orthogonalVector(normal));
+    vec3 bitangent = safeNormalize(
+        v_view_bitangent - normal * dot(v_view_bitangent, normal),
+        cross(normal, tangent));
+
+    if(dot(normal, view_direction) < 0.0)
+    {
+        normal = -normal;
+        bitangent = -bitangent;
+    }
+
+    vec3 hdr_color = integrateDiskLight(
+        control, albedo, view_direction, normal, tangent, bitangent);
+    float ambient_energy = mix(1.0, TRANSMITTED_PRINT_ENERGY,
+                               control.coverage);
+    hdr_color += AMBIENT_PRINT_IRRADIANCE * ambient_energy * albedo;
+
+    vec3 tone_mapped = toneMap(hdr_color);
+    vec3 display_linear = applyFoilGamutMap(
+        tone_mapped, control.coverage);
+    vec3 encoded_color = linearToSrgb(display_linear);
+    out_color = vec4(applyLevels(encoded_color),
+                     artwork_sample.a);
+}
diff --git a/static/foil/gl-matrix-min.js b/static/foil/gl-matrix-min.js
new file mode 100644
index 0000000..7571ca0
--- /dev/null
+++ b/static/foil/gl-matrix-min.js
@@ -0,0 +1,28 @@
+/*!
+@fileoverview gl-matrix - High performance matrix and vector operations
+@author Brandon Jones
+@author Colin MacKenzie IV
+@version 3.4.1
+
+Copyright (c) 2015-2021, Brandon Jones, Colin MacKenzie IV.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+*/
+!function(t,n){"object"==typeof exports&&"undefined"!=typeof module?n(exports):"function"==typeof define&&define.amd?define(["exports"],n):n((t="undefined"!=typeof globalThis?globalThis:t||self).glMatrix={})}(this,(function(t){"use strict";var n=1e-6,a="undefined"!=typeof Float32Array?Float32Array:Array,r=Math.random;var u=Math.PI/180;Math.hypot||(Math.hypot=function(){for(var t=0,n=arguments.length;n--;)t+=arguments[n]*arguments[n];return Math.sqrt(t)});var e=Object.freeze({__proto__:null,EPSILON:n,get ARRAY_TYPE(){return a},RANDOM:r,ANGLE_ORDER:"zyx",setMatrixArrayType:function(t){a=t},toRadian:function(t){return t*u},equals:function(t,a){return Math.abs(t-a)<=n*Math.max(1,Math.abs(t),Math.abs(a))}});function o(t,n,a){var r=n[0],u=n[1],e=n[2],o=n[3],i=a[0],h=a[1],c=a[2],s=a[3];return t[0]=r*i+e*h,t[1]=u*i+o*h,t[2]=r*c+e*s,t[3]=u*c+o*s,t}function i(t,n,a){return t[0]=n[0]-a[0],t[1]=n[1]-a[1],t[2]=n[2]-a[2],t[3]=n[3]-a[3],t}var h=o,c=i,s=Object.freeze({__proto__:null,create:function(){var t=new a(4);return a!=Float32Array&&(t[1]=0,t[2]=0),t[0]=1,t[3]=1,t},clone:function(t){var n=new a(4);return n[0]=t[0],n[1]=t[1],n[2]=t[2],n[3]=t[3],n},copy:function(t,n){return t[0]=n[0],t[1]=n[1],t[2]=n[2],t[3]=n[3],t},identity:function(t){return t[0]=1,t[1]=0,t[2]=0,t[3]=1,t},fromValues:function(t,n,r,u){var e=new a(4);return e[0]=t,e[1]=n,e[2]=r,e[3]=u,e},set:function(t,n,a,r,u){return t[0]=n,t[1]=a,t[2]=r,t[3]=u,t},transpose:function(t,n){if(t===n){var a=n[1];t[1]=n[2],t[2]=a}else t[0]=n[0],t[1]=n[2],t[2]=n[1],t[3]=n[3];return t},invert:function(t,n){var a=n[0],r=n[1],u=n[2],e=n[3],o=a*e-u*r;return o?(o=1/o,t[0]=e*o,t[1]=-r*o,t[2]=-u*o,t[3]=a*o,t):null},adjoint:function(t,n){var a=n[0];return t[0]=n[3],t[1]=-n[1],t[2]=-n[2],t[3]=a,t},determinant:function(t){return t[0]*t[3]-t[2]*t[1]},multiply:o,rotate:function(t,n,a){var r=n[0],u=n[1],e=n[2],o=n[3],i=Math.sin(a),h=Math.cos(a);return t[0]=r*h+e*i,t[1]=u*h+o*i,t[2]=r*-i+e*h,t[3]=u*-i+o*h,t},scale:function(t,n,a){var r=n[0],u=n[1],e=n[2],o=n[3],i=a[0],h=a[1];return t[0]=r*i,t[1]=u*i,t[2]=e*h,t[3]=o*h,t},fromRotation:function(t,n){var a=Math.sin(n),r=Math.cos(n);return t[0]=r,t[1]=a,t[2]=-a,t[3]=r,t},fromScaling:function(t,n){return t[0]=n[0],t[1]=0,t[2]=0,t[3]=n[1],t},str:function(t){return"mat2("+t[0]+", "+t[1]+", "+t[2]+", "+t[3]+")"},frob:function(t){return Math.hypot(t[0],t[1],t[2],t[3])},LDU:function(t,n,a,r){return t[2]=r[2]/r[0],a[0]=r[0],a[1]=r[1],a[3]=r[3]-t[2]*a[1],[t,n,a]},add:function(t,n,a){return t[0]=n[0]+a[0],t[1]=n[1]+a[1],t[2]=n[2]+a[2],t[3]=n[3]+a[3],t},subtract:i,exactEquals:function(t,n){return t[0]===n[0]&&t[1]===n[1]&&t[2]===n[2]&&t[3]===n[3]},equals:function(t,a){var r=t[0],u=t[1],e=t[2],o=t[3],i=a[0],h=a[1],c=a[2],s=a[3];return Math.abs(r-i)<=n*Math.max(1,Math.abs(r),Math.abs(i))&&Math.abs(u-h)<=n*Math.max(1,Math.abs(u),Math.abs(h))&&Math.abs(e-c)<=n*Math.max(1,Math.abs(e),Math.abs(c))&&Math.abs(o-s)<=n*Math.max(1,Math.abs(o),Math.abs(s))},multiplyScalar:function(t,n,a){return t[0]=n[0]*a,t[1]=n[1]*a,t[2]=n[2]*a,t[3]=n[3]*a,t},multiplyScalarAndAdd:function(t,n,a,r){return t[0]=n[0]+a[0]*r,t[1]=n[1]+a[1]*r,t[2]=n[2]+a[2]*r,t[3]=n[3]+a[3]*r,t},mul:h,sub:c});function M(t,n,a){var r=n[0],u=n[1],e=n[2],o=n[3],i=n[4],h=n[5],c=a[0],s=a[1],M=a[2],f=a[3],l=a[4],v=a[5];return t[0]=r*c+e*s,t[1]=u*c+o*s,t[2]=r*M+e*f,t[3]=u*M+o*f,t[4]=r*l+e*v+i,t[5]=u*l+o*v+h,t}function f(t,n,a){return t[0]=n[0]-a[0],t[1]=n[1]-a[1],t[2]=n[2]-a[2],t[3]=n[3]-a[3],t[4]=n[4]-a[4],t[5]=n[5]-a[5],t}var l=M,v=f,b=Object.freeze({__proto__:null,create:function(){var t=new a(6);return a!=Float32Array&&(t[1]=0,t[2]=0,t[4]=0,t[5]=0),t[0]=1,t[3]=1,t},clone:function(t){var n=new a(6);return n[0]=t[0],n[1]=t[1],n[2]=t[2],n[3]=t[3],n[4]=t[4],n[5]=t[5],n},copy:function(t,n){return t[0]=n[0],t[1]=n[1],t[2]=n[2],t[3]=n[3],t[4]=n[4],t[5]=n[5],t},identity:function(t){return t[0]=1,t[1]=0,t[2]=0,t[3]=1,t[4]=0,t[5]=0,t},fromValues:function(t,n,r,u,e,o){var i=new a(6);return i[0]=t,i[1]=n,i[2]=r,i[3]=u,i[4]=e,i[5]=o,i},set:function(t,n,a,r,u,e,o){return t[0]=n,t[1]=a,t[2]=r,t[3]=u,t[4]=e,t[5]=o,t},invert:function(t,n){var a=n[0],r=n[1],u=n[2],e=n[3],o=n[4],i=n[5],h=a*e-r*u;return h?(h=1/h,t[0]=e*h,t[1]=-r*h,t[2]=-u*h,t[3]=a*h,t[4]=(u*i-e*o)*h,t[5]=(r*o-a*i)*h,t):null},determinant:function(t){return t[0]*t[3]-t[1]*t[2]},multiply:M,rotate:function(t,n,a){var r=n[0],u=n[1],e=n[2],o=n[3],i=n[4],h=n[5],c=Math.sin(a),s=Math.cos(a);return t[0]=r*s+e*c,t[1]=u*s+o*c,t[2]=r*-c+e*s,t[3]=u*-c+o*s,t[4]=i,t[5]=h,t},scale:function(t,n,a){var r=n[0],u=n[1],e=n[2],o=n[3],i=n[4],h=n[5],c=a[0],s=a[1];return t[0]=r*c,t[1]=u*c,t[2]=e*s,t[3]=o*s,t[4]=i,t[5]=h,t},translate:function(t,n,a){var r=n[0],u=n[1],e=n[2],o=n[3],i=n[4],h=n[5],c=a[0],s=a[1];return t[0]=r,t[1]=u,t[2]=e,t[3]=o,t[4]=r*c+e*s+i,t[5]=u*c+o*s+h,t},fromRotation:function(t,n){var a=Math.sin(n),r=Math.cos(n);return t[0]=r,t[1]=a,t[2]=-a,t[3]=r,t[4]=0,t[5]=0,t},fromScaling:function(t,n){return t[0]=n[0],t[1]=0,t[2]=0,t[3]=n[1],t[4]=0,t[5]=0,t},fromTranslation:function(t,n){return t[0]=1,t[1]=0,t[2]=0,t[3]=1,t[4]=n[0],t[5]=n[1],t},str:function(t){return"mat2d("+t[0]+", "+t[1]+", "+t[2]+", "+t[3]+", "+t[4]+", "+t[5]+")"},frob:function(t){return Math.hypot(t[0],t[1],t[2],t[3],t[4],t[5],1)},add:function(t,n,a){return t[0]=n[0]+a[0],t[1]=n[1]+a[1],t[2]=n[2]+a[2],t[3]=n[3]+a[3],t[4]=n[4]+a[4],t[5]=n[5]+a[5],t},subtract:f,multiplyScalar:function(t,n,a){return t[0]=n[0]*a,t[1]=n[1]*a,t[2]=n[2]*a,t[3]=n[3]*a,t[4]=n[4]*a,t[5]=n[5]*a,t},multiplyScalarAndAdd:function(t,n,a,r){return t[0]=n[0]+a[0]*r,t[1]=n[1]+a[1]*r,t[2]=n[2]+a[2]*r,t[3]=n[3]+a[3]*r,t[4]=n[4]+a[4]*r,t[5]=n[5]+a[5]*r,t},exactEquals:function(t,n){return t[0]===n[0]&&t[1]===n[1]&&t[2]===n[2]&&t[3]===n[3]&&t[4]===n[4]&&t[5]===n[5]},equals:function(t,a){var r=t[0],u=t[1],e=t[2],o=t[3],i=t[4],h=t[5],c=a[0],s=a[1],M=a[2],f=a[3],l=a[4],v=a[5];return Math.abs(r-c)<=n*Math.max(1,Math.abs(r),Math.abs(c))&&Math.abs(u-s)<=n*Math.max(1,Math.abs(u),Math.abs(s))&&Math.abs(e-M)<=n*Math.max(1,Math.abs(e),Math.abs(M))&&Math.abs(o-f)<=n*Math.max(1,Math.abs(o),Math.abs(f))&&Math.abs(i-l)<=n*Math.max(1,Math.abs(i),Math.abs(l))&&Math.abs(h-v)<=n*Math.max(1,Math.abs(h),Math.abs(v))},mul:l,sub:v});function m(){var t=new a(9);return a!=Float32Array&&(t[1]=0,t[2]=0,t[3]=0,t[5]=0,t[6]=0,t[7]=0),t[0]=1,t[4]=1,t[8]=1,t}function d(t,n,a){var r=n[0],u=n[1],e=n[2],o=n[3],i=n[4],h=n[5],c=n[6],s=n[7],M=n[8],f=a[0],l=a[1],v=a[2],b=a[3],m=a[4],d=a[5],p=a[6],x=a[7],y=a[8];return t[0]=f*r+l*o+v*c,t[1]=f*u+l*i+v*s,t[2]=f*e+l*h+v*M,t[3]=b*r+m*o+d*c,t[4]=b*u+m*i+d*s,t[5]=b*e+m*h+d*M,t[6]=p*r+x*o+y*c,t[7]=p*u+x*i+y*s,t[8]=p*e+x*h+y*M,t}function p(t,n,a){return t[0]=n[0]-a[0],t[1]=n[1]-a[1],t[2]=n[2]-a[2],t[3]=n[3]-a[3],t[4]=n[4]-a[4],t[5]=n[5]-a[5],t[6]=n[6]-a[6],t[7]=n[7]-a[7],t[8]=n[8]-a[8],t}var x=d,y=p,q=Object.freeze({__proto__:null,create:m,fromMat4:function(t,n){return t[0]=n[0],t[1]=n[1],t[2]=n[2],t[3]=n[4],t[4]=n[5],t[5]=n[6],t[6]=n[8],t[7]=n[9],t[8]=n[10],t},clone:function(t){var n=new a(9);return n[0]=t[0],n[1]=t[1],n[2]=t[2],n[3]=t[3],n[4]=t[4],n[5]=t[5],n[6]=t[6],n[7]=t[7],n[8]=t[8],n},copy:function(t,n){return t[0]=n[0],t[1]=n[1],t[2]=n[2],t[3]=n[3],t[4]=n[4],t[5]=n[5],t[6]=n[6],t[7]=n[7],t[8]=n[8],t},fromValues:function(t,n,r,u,e,o,i,h,c){var s=new a(9);return s[0]=t,s[1]=n,s[2]=r,s[3]=u,s[4]=e,s[5]=o,s[6]=i,s[7]=h,s[8]=c,s},set:function(t,n,a,r,u,e,o,i,h,c){return t[0]=n,t[1]=a,t[2]=r,t[3]=u,t[4]=e,t[5]=o,t[6]=i,t[7]=h,t[8]=c,t},identity:function(t){return t[0]=1,t[1]=0,t[2]=0,t[3]=0,t[4]=1,t[5]=0,t[6]=0,t[7]=0,t[8]=1,t},transpose:function(t,n){if(t===n){var a=n[1],r=n[2],u=n[5];t[1]=n[3],t[2]=n[6],t[3]=a,t[5]=n[7],t[6]=r,t[7]=u}else t[0]=n[0],t[1]=n[3],t[2]=n[6],t[3]=n[1],t[4]=n[4],t[5]=n[7],t[6]=n[2],t[7]=n[5],t[8]=n[8];return t},invert:function(t,n){var a=n[0],r=n[1],u=n[2],e=n[3],o=n[4],i=n[5],h=n[6],c=n[7],s=n[8],M=s*o-i*c,f=-s*e+i*h,l=c*e-o*h,v=a*M+r*f+u*l;return v?(v=1/v,t[0]=M*v,t[1]=(-s*r+u*c)*v,t[2]=(i*r-u*o)*v,t[3]=f*v,t[4]=(s*a-u*h)*v,t[5]=(-i*a+u*e)*v,t[6]=l*v,t[7]=(-c*a+r*h)*v,t[8]=(o*a-r*e)*v,t):null},adjoint:function(t,n){var a=n[0],r=n[1],u=n[2],e=n[3],o=n[4],i=n[5],h=n[6],c=n[7],s=n[8];return t[0]=o*s-i*c,t[1]=u*c-r*s,t[2]=r*i-u*o,t[3]=i*h-e*s,t[4]=a*s-u*h,t[5]=u*e-a*i,t[6]=e*c-o*h,t[7]=r*h-a*c,t[8]=a*o-r*e,t},determinant:function(t){var n=t[0],a=t[1],r=t[2],u=t[3],e=t[4],o=t[5],i=t[6],h=t[7],c=t[8];return n*(c*e-o*h)+a*(-c*u+o*i)+r*(h*u-e*i)},multiply:d,translate:function(t,n,a){var r=n[0],u=n[1],e=n[2],o=n[3],i=n[4],h=n[5],c=n[6],s=n[7],M=n[8],f=a[0],l=a[1];return t[0]=r,t[1]=u,t[2]=e,t[3]=o,t[4]=i,t[5]=h,t[6]=f*r+l*o+c,t[7]=f*u+l*i+s,t[8]=f*e+l*h+M,t},rotate:function(t,n,a){var r=n[0],u=n[1],e=n[2],o=n[3],i=n[4],h=n[5],c=n[6],s=n[7],M=n[8],f=Math.sin(a),l=Math.cos(a);return t[0]=l*r+f*o,t[1]=l*u+f*i,t[2]=l*e+f*h,t[3]=l*o-f*r,t[4]=l*i-f*u,t[5]=l*h-f*e,t[6]=c,t[7]=s,t[8]=M,t},scale:function(t,n,a){var r=a[0],u=a[1];return t[0]=r*n[0],t[1]=r*n[1],t[2]=r*n[2],t[3]=u*n[3],t[4]=u*n[4],t[5]=u*n[5],t[6]=n[6],t[7]=n[7],t[8]=n[8],t},fromTranslation:function(t,n){return t[0]=1,t[1]=0,t[2]=0,t[3]=0,t[4]=1,t[5]=0,t[6]=n[0],t[7]=n[1],t[8]=1,t},fromRotation:function(t,n){var a=Math.sin(n),r=Math.cos(n);return t[0]=r,t[1]=a,t[2]=0,t[3]=-a,t[4]=r,t[5]=0,t[6]=0,t[7]=0,t[8]=1,t},fromScaling:function(t,n){return t[0]=n[0],t[1]=0,t[2]=0,t[3]=0,t[4]=n[1],t[5]=0,t[6]=0,t[7]=0,t[8]=1,t},fromMat2d:function(t,n){return t[0]=n[0],t[1]=n[1],t[2]=0,t[3]=n[2],t[4]=n[3],t[5]=0,t[6]=n[4],t[7]=n[5],t[8]=1,t},fromQuat:function(t,n){var a=n[0],r=n[1],u=n[2],e=n[3],o=a+a,i=r+r,h=u+u,c=a*o,s=r*o,M=r*i,f=u*o,l=u*i,v=u*h,b=e*o,m=e*i,d=e*h;return t[0]=1-M-v,t[3]=s-d,t[6]=f+m,t[1]=s+d,t[4]=1-c-v,t[7]=l-b,t[2]=f-m,t[5]=l+b,t[8]=1-c-M,t},normalFromMat4:function(t,n){var a=n[0],r=n[1],u=n[2],e=n[3],o=n[4],i=n[5],h=n[6],c=n[7],s=n[8],M=n[9],f=n[10],l=n[11],v=n[12],b=n[13],m=n[14],d=n[15],p=a*i-r*o,x=a*h-u*o,y=a*c-e*o,q=r*h-u*i,g=r*c-e*i,_=u*c-e*h,A=s*b-M*v,w=s*m-f*v,z=s*d-l*v,R=M*m-f*b,O=M*d-l*b,j=f*d-l*m,E=p*j-x*O+y*R+q*z-g*w+_*A;return E?(E=1/E,t[0]=(i*j-h*O+c*R)*E,t[1]=(h*z-o*j-c*w)*E,t[2]=(o*O-i*z+c*A)*E,t[3]=(u*O-r*j-e*R)*E,t[4]=(a*j-u*z+e*w)*E,t[5]=(r*z-a*O-e*A)*E,t[6]=(b*_-m*g+d*q)*E,t[7]=(m*y-v*_-d*x)*E,t[8]=(v*g-b*y+d*p)*E,t):null},projection:function(t,n,a){return t[0]=2/n,t[1]=0,t[2]=0,t[3]=0,t[4]=-2/a,t[5]=0,t[6]=-1,t[7]=1,t[8]=1,t},str:function(t){return"mat3("+t[0]+", "+t[1]+", "+t[2]+", "+t[3]+", "+t[4]+", "+t[5]+", "+t[6]+", "+t[7]+", "+t[8]+")"},frob:function(t){return Math.hypot(t[0],t[1],t[2],t[3],t[4],t[5],t[6],t[7],t[8])},add:function(t,n,a){return t[0]=n[0]+a[0],t[1]=n[1]+a[1],t[2]=n[2]+a[2],t[3]=n[3]+a[3],t[4]=n[4]+a[4],t[5]=n[5]+a[5],t[6]=n[6]+a[6],t[7]=n[7]+a[7],t[8]=n[8]+a[8],t},subtract:p,multiplyScalar:function(t,n,a){return t[0]=n[0]*a,t[1]=n[1]*a,t[2]=n[2]*a,t[3]=n[3]*a,t[4]=n[4]*a,t[5]=n[5]*a,t[6]=n[6]*a,t[7]=n[7]*a,t[8]=n[8]*a,t},multiplyScalarAndAdd:function(t,n,a,r){return t[0]=n[0]+a[0]*r,t[1]=n[1]+a[1]*r,t[2]=n[2]+a[2]*r,t[3]=n[3]+a[3]*r,t[4]=n[4]+a[4]*r,t[5]=n[5]+a[5]*r,t[6]=n[6]+a[6]*r,t[7]=n[7]+a[7]*r,t[8]=n[8]+a[8]*r,t},exactEquals:function(t,n){return t[0]===n[0]&&t[1]===n[1]&&t[2]===n[2]&&t[3]===n[3]&&t[4]===n[4]&&t[5]===n[5]&&t[6]===n[6]&&t[7]===n[7]&&t[8]===n[8]},equals:function(t,a){var r=t[0],u=t[1],e=t[2],o=t[3],i=t[4],h=t[5],c=t[6],s=t[7],M=t[8],f=a[0],l=a[1],v=a[2],b=a[3],m=a[4],d=a[5],p=a[6],x=a[7],y=a[8];return Math.abs(r-f)<=n*Math.max(1,Math.abs(r),Math.abs(f))&&Math.abs(u-l)<=n*Math.max(1,Math.abs(u),Math.abs(l))&&Math.abs(e-v)<=n*Math.max(1,Math.abs(e),Math.abs(v))&&Math.abs(o-b)<=n*Math.max(1,Math.abs(o),Math.abs(b))&&Math.abs(i-m)<=n*Math.max(1,Math.abs(i),Math.abs(m))&&Math.abs(h-d)<=n*Math.max(1,Math.abs(h),Math.abs(d))&&Math.abs(c-p)<=n*Math.max(1,Math.abs(c),Math.abs(p))&&Math.abs(s-x)<=n*Math.max(1,Math.abs(s),Math.abs(x))&&Math.abs(M-y)<=n*Math.max(1,Math.abs(M),Math.abs(y))},mul:x,sub:y});function g(t){return t[0]=1,t[1]=0,t[2]=0,t[3]=0,t[4]=0,t[5]=1,t[6]=0,t[7]=0,t[8]=0,t[9]=0,t[10]=1,t[11]=0,t[12]=0,t[13]=0,t[14]=0,t[15]=1,t}function _(t,n,a){var r=n[0],u=n[1],e=n[2],o=n[3],i=n[4],h=n[5],c=n[6],s=n[7],M=n[8],f=n[9],l=n[10],v=n[11],b=n[12],m=n[13],d=n[14],p=n[15],x=a[0],y=a[1],q=a[2],g=a[3];return t[0]=x*r+y*i+q*M+g*b,t[1]=x*u+y*h+q*f+g*m,t[2]=x*e+y*c+q*l+g*d,t[3]=x*o+y*s+q*v+g*p,x=a[4],y=a[5],q=a[6],g=a[7],t[4]=x*r+y*i+q*M+g*b,t[5]=x*u+y*h+q*f+g*m,t[6]=x*e+y*c+q*l+g*d,t[7]=x*o+y*s+q*v+g*p,x=a[8],y=a[9],q=a[10],g=a[11],t[8]=x*r+y*i+q*M+g*b,t[9]=x*u+y*h+q*f+g*m,t[10]=x*e+y*c+q*l+g*d,t[11]=x*o+y*s+q*v+g*p,x=a[12],y=a[13],q=a[14],g=a[15],t[12]=x*r+y*i+q*M+g*b,t[13]=x*u+y*h+q*f+g*m,t[14]=x*e+y*c+q*l+g*d,t[15]=x*o+y*s+q*v+g*p,t}function A(t,n,a){var r=n[0],u=n[1],e=n[2],o=n[3],i=r+r,h=u+u,c=e+e,s=r*i,M=r*h,f=r*c,l=u*h,v=u*c,b=e*c,m=o*i,d=o*h,p=o*c;return t[0]=1-(l+b),t[1]=M+p,t[2]=f-d,t[3]=0,t[4]=M-p,t[5]=1-(s+b),t[6]=v+m,t[7]=0,t[8]=f+d,t[9]=v-m,t[10]=1-(s+l),t[11]=0,t[12]=a[0],t[13]=a[1],t[14]=a[2],t[15]=1,t}function w(t,n){return t[0]=n[12],t[1]=n[13],t[2]=n[14],t}function z(t,n){var a=n[0],r=n[1],u=n[2],e=n[4],o=n[5],i=n[6],h=n[8],c=n[9],s=n[10];return t[0]=Math.hypot(a,r,u),t[1]=Math.hypot(e,o,i),t[2]=Math.hypot(h,c,s),t}function R(t,n){var r=new a(3);z(r,n);var u=1/r[0],e=1/r[1],o=1/r[2],i=n[0]*u,h=n[1]*e,c=n[2]*o,s=n[4]*u,M=n[5]*e,f=n[6]*o,l=n[8]*u,v=n[9]*e,b=n[10]*o,m=i+M+b,d=0;return m>0?(d=2*Math.sqrt(m+1),t[3]=.25*d,t[0]=(f-v)/d,t[1]=(l-c)/d,t[2]=(h-s)/d):i>M&&i>b?(d=2*Math.sqrt(1+i-M-b),t[3]=(f-v)/d,t[0]=.25*d,t[1]=(h+s)/d,t[2]=(l+c)/d):M>b?(d=2*Math.sqrt(1+M-i-b),t[3]=(l-c)/d,t[0]=(h+s)/d,t[1]=.25*d,t[2]=(f+v)/d):(d=2*Math.sqrt(1+b-i-M),t[3]=(h-s)/d,t[0]=(l+c)/d,t[1]=(f+v)/d,t[2]=.25*d),t}function O(t,n,a,r,u){var e=1/Math.tan(n/2);if(t[0]=e/a,t[1]=0,t[2]=0,t[3]=0,t[4]=0,t[5]=e,t[6]=0,t[7]=0,t[8]=0,t[9]=0,t[11]=-1,t[12]=0,t[13]=0,t[15]=0,null!=u&&u!==1/0){var o=1/(r-u);t[10]=(u+r)*o,t[14]=2*u*r*o}else t[10]=-1,t[14]=-2*r;return t}var j=O;function E(t,n,a,r,u,e,o){var i=1/(n-a),h=1/(r-u),c=1/(e-o);return t[0]=-2*i,t[1]=0,t[2]=0,t[3]=0,t[4]=0,t[5]=-2*h,t[6]=0,t[7]=0,t[8]=0,t[9]=0,t[10]=2*c,t[11]=0,t[12]=(n+a)*i,t[13]=(u+r)*h,t[14]=(o+e)*c,t[15]=1,t}var P=E;function T(t,n,a){return t[0]=n[0]-a[0],t[1]=n[1]-a[1],t[2]=n[2]-a[2],t[3]=n[3]-a[3],t[4]=n[4]-a[4],t[5]=n[5]-a[5],t[6]=n[6]-a[6],t[7]=n[7]-a[7],t[8]=n[8]-a[8],t[9]=n[9]-a[9],t[10]=n[10]-a[10],t[11]=n[11]-a[11],t[12]=n[12]-a[12],t[13]=n[13]-a[13],t[14]=n[14]-a[14],t[15]=n[15]-a[15],t}var S=_,D=T,F=Object.freeze({__proto__:null,create:function(){var t=new a(16);return a!=Float32Array&&(t[1]=0,t[2]=0,t[3]=0,t[4]=0,t[6]=0,t[7]=0,t[8]=0,t[9]=0,t[11]=0,t[12]=0,t[13]=0,t[14]=0),t[0]=1,t[5]=1,t[10]=1,t[15]=1,t},clone:function(t){var n=new a(16);return n[0]=t[0],n[1]=t[1],n[2]=t[2],n[3]=t[3],n[4]=t[4],n[5]=t[5],n[6]=t[6],n[7]=t[7],n[8]=t[8],n[9]=t[9],n[10]=t[10],n[11]=t[11],n[12]=t[12],n[13]=t[13],n[14]=t[14],n[15]=t[15],n},copy:function(t,n){return t[0]=n[0],t[1]=n[1],t[2]=n[2],t[3]=n[3],t[4]=n[4],t[5]=n[5],t[6]=n[6],t[7]=n[7],t[8]=n[8],t[9]=n[9],t[10]=n[10],t[11]=n[11],t[12]=n[12],t[13]=n[13],t[14]=n[14],t[15]=n[15],t},fromValues:function(t,n,r,u,e,o,i,h,c,s,M,f,l,v,b,m){var d=new a(16);return d[0]=t,d[1]=n,d[2]=r,d[3]=u,d[4]=e,d[5]=o,d[6]=i,d[7]=h,d[8]=c,d[9]=s,d[10]=M,d[11]=f,d[12]=l,d[13]=v,d[14]=b,d[15]=m,d},set:function(t,n,a,r,u,e,o,i,h,c,s,M,f,l,v,b,m){return t[0]=n,t[1]=a,t[2]=r,t[3]=u,t[4]=e,t[5]=o,t[6]=i,t[7]=h,t[8]=c,t[9]=s,t[10]=M,t[11]=f,t[12]=l,t[13]=v,t[14]=b,t[15]=m,t},identity:g,transpose:function(t,n){if(t===n){var a=n[1],r=n[2],u=n[3],e=n[6],o=n[7],i=n[11];t[1]=n[4],t[2]=n[8],t[3]=n[12],t[4]=a,t[6]=n[9],t[7]=n[13],t[8]=r,t[9]=e,t[11]=n[14],t[12]=u,t[13]=o,t[14]=i}else t[0]=n[0],t[1]=n[4],t[2]=n[8],t[3]=n[12],t[4]=n[1],t[5]=n[5],t[6]=n[9],t[7]=n[13],t[8]=n[2],t[9]=n[6],t[10]=n[10],t[11]=n[14],t[12]=n[3],t[13]=n[7],t[14]=n[11],t[15]=n[15];return t},invert:function(t,n){var a=n[0],r=n[1],u=n[2],e=n[3],o=n[4],i=n[5],h=n[6],c=n[7],s=n[8],M=n[9],f=n[10],l=n[11],v=n[12],b=n[13],m=n[14],d=n[15],p=a*i-r*o,x=a*h-u*o,y=a*c-e*o,q=r*h-u*i,g=r*c-e*i,_=u*c-e*h,A=s*b-M*v,w=s*m-f*v,z=s*d-l*v,R=M*m-f*b,O=M*d-l*b,j=f*d-l*m,E=p*j-x*O+y*R+q*z-g*w+_*A;return E?(E=1/E,t[0]=(i*j-h*O+c*R)*E,t[1]=(u*O-r*j-e*R)*E,t[2]=(b*_-m*g+d*q)*E,t[3]=(f*g-M*_-l*q)*E,t[4]=(h*z-o*j-c*w)*E,t[5]=(a*j-u*z+e*w)*E,t[6]=(m*y-v*_-d*x)*E,t[7]=(s*_-f*y+l*x)*E,t[8]=(o*O-i*z+c*A)*E,t[9]=(r*z-a*O-e*A)*E,t[10]=(v*g-b*y+d*p)*E,t[11]=(M*y-s*g-l*p)*E,t[12]=(i*w-o*R-h*A)*E,t[13]=(a*R-r*w+u*A)*E,t[14]=(b*x-v*q-m*p)*E,t[15]=(s*q-M*x+f*p)*E,t):null},adjoint:function(t,n){var a=n[0],r=n[1],u=n[2],e=n[3],o=n[4],i=n[5],h=n[6],c=n[7],s=n[8],M=n[9],f=n[10],l=n[11],v=n[12],b=n[13],m=n[14],d=n[15],p=a*i-r*o,x=a*h-u*o,y=a*c-e*o,q=r*h-u*i,g=r*c-e*i,_=u*c-e*h,A=s*b-M*v,w=s*m-f*v,z=s*d-l*v,R=M*m-f*b,O=M*d-l*b,j=f*d-l*m;return t[0]=i*j-h*O+c*R,t[1]=u*O-r*j-e*R,t[2]=b*_-m*g+d*q,t[3]=f*g-M*_-l*q,t[4]=h*z-o*j-c*w,t[5]=a*j-u*z+e*w,t[6]=m*y-v*_-d*x,t[7]=s*_-f*y+l*x,t[8]=o*O-i*z+c*A,t[9]=r*z-a*O-e*A,t[10]=v*g-b*y+d*p,t[11]=M*y-s*g-l*p,t[12]=i*w-o*R-h*A,t[13]=a*R-r*w+u*A,t[14]=b*x-v*q-m*p,t[15]=s*q-M*x+f*p,t},determinant:function(t){var n=t[0],a=t[1],r=t[2],u=t[3],e=t[4],o=t[5],i=t[6],h=t[7],c=t[8],s=t[9],M=t[10],f=t[11],l=t[12],v=t[13],b=t[14],m=n*o-a*e,d=n*i-r*e,p=a*i-r*o,x=c*v-s*l,y=c*b-M*l,q=s*b-M*v;return h*(n*q-a*y+r*x)-u*(e*q-o*y+i*x)+t[15]*(c*p-s*d+M*m)-f*(l*p-v*d+b*m)},multiply:_,translate:function(t,n,a){var r,u,e,o,i,h,c,s,M,f,l,v,b=a[0],m=a[1],d=a[2];return n===t?(t[12]=n[0]*b+n[4]*m+n[8]*d+n[12],t[13]=n[1]*b+n[5]*m+n[9]*d+n[13],t[14]=n[2]*b+n[6]*m+n[10]*d+n[14],t[15]=n[3]*b+n[7]*m+n[11]*d+n[15]):(r=n[0],u=n[1],e=n[2],o=n[3],i=n[4],h=n[5],c=n[6],s=n[7],M=n[8],f=n[9],l=n[10],v=n[11],t[0]=r,t[1]=u,t[2]=e,t[3]=o,t[4]=i,t[5]=h,t[6]=c,t[7]=s,t[8]=M,t[9]=f,t[10]=l,t[11]=v,t[12]=r*b+i*m+M*d+n[12],t[13]=u*b+h*m+f*d+n[13],t[14]=e*b+c*m+l*d+n[14],t[15]=o*b+s*m+v*d+n[15]),t},scale:function(t,n,a){var r=a[0],u=a[1],e=a[2];return t[0]=n[0]*r,t[1]=n[1]*r,t[2]=n[2]*r,t[3]=n[3]*r,t[4]=n[4]*u,t[5]=n[5]*u,t[6]=n[6]*u,t[7]=n[7]*u,t[8]=n[8]*e,t[9]=n[9]*e,t[10]=n[10]*e,t[11]=n[11]*e,t[12]=n[12],t[13]=n[13],t[14]=n[14],t[15]=n[15],t},rotate:function(t,a,r,u){var e,o,i,h,c,s,M,f,l,v,b,m,d,p,x,y,q,g,_,A,w,z,R,O,j=u[0],E=u[1],P=u[2],T=Math.hypot(j,E,P);return T<n?null:(j*=T=1/T,E*=T,P*=T,e=Math.sin(r),i=1-(o=Math.cos(r)),h=a[0],c=a[1],s=a[2],M=a[3],f=a[4],l=a[5],v=a[6],b=a[7],m=a[8],d=a[9],p=a[10],x=a[11],y=j*j*i+o,q=E*j*i+P*e,g=P*j*i-E*e,_=j*E*i-P*e,A=E*E*i+o,w=P*E*i+j*e,z=j*P*i+E*e,R=E*P*i-j*e,O=P*P*i+o,t[0]=h*y+f*q+m*g,t[1]=c*y+l*q+d*g,t[2]=s*y+v*q+p*g,t[3]=M*y+b*q+x*g,t[4]=h*_+f*A+m*w,t[5]=c*_+l*A+d*w,t[6]=s*_+v*A+p*w,t[7]=M*_+b*A+x*w,t[8]=h*z+f*R+m*O,t[9]=c*z+l*R+d*O,t[10]=s*z+v*R+p*O,t[11]=M*z+b*R+x*O,a!==t&&(t[12]=a[12],t[13]=a[13],t[14]=a[14],t[15]=a[15]),t)},rotateX:function(t,n,a){var r=Math.sin(a),u=Math.cos(a),e=n[4],o=n[5],i=n[6],h=n[7],c=n[8],s=n[9],M=n[10],f=n[11];return n!==t&&(t[0]=n[0],t[1]=n[1],t[2]=n[2],t[3]=n[3],t[12]=n[12],t[13]=n[13],t[14]=n[14],t[15]=n[15]),t[4]=e*u+c*r,t[5]=o*u+s*r,t[6]=i*u+M*r,t[7]=h*u+f*r,t[8]=c*u-e*r,t[9]=s*u-o*r,t[10]=M*u-i*r,t[11]=f*u-h*r,t},rotateY:function(t,n,a){var r=Math.sin(a),u=Math.cos(a),e=n[0],o=n[1],i=n[2],h=n[3],c=n[8],s=n[9],M=n[10],f=n[11];return n!==t&&(t[4]=n[4],t[5]=n[5],t[6]=n[6],t[7]=n[7],t[12]=n[12],t[13]=n[13],t[14]=n[14],t[15]=n[15]),t[0]=e*u-c*r,t[1]=o*u-s*r,t[2]=i*u-M*r,t[3]=h*u-f*r,t[8]=e*r+c*u,t[9]=o*r+s*u,t[10]=i*r+M*u,t[11]=h*r+f*u,t},rotateZ:function(t,n,a){var r=Math.sin(a),u=Math.cos(a),e=n[0],o=n[1],i=n[2],h=n[3],c=n[4],s=n[5],M=n[6],f=n[7];return n!==t&&(t[8]=n[8],t[9]=n[9],t[10]=n[10],t[11]=n[11],t[12]=n[12],t[13]=n[13],t[14]=n[14],t[15]=n[15]),t[0]=e*u+c*r,t[1]=o*u+s*r,t[2]=i*u+M*r,t[3]=h*u+f*r,t[4]=c*u-e*r,t[5]=s*u-o*r,t[6]=M*u-i*r,t[7]=f*u-h*r,t},fromTranslation:function(t,n){return t[0]=1,t[1]=0,t[2]=0,t[3]=0,t[4]=0,t[5]=1,t[6]=0,t[7]=0,t[8]=0,t[9]=0,t[10]=1,t[11]=0,t[12]=n[0],t[13]=n[1],t[14]=n[2],t[15]=1,t},fromScaling:function(t,n){return t[0]=n[0],t[1]=0,t[2]=0,t[3]=0,t[4]=0,t[5]=n[1],t[6]=0,t[7]=0,t[8]=0,t[9]=0,t[10]=n[2],t[11]=0,t[12]=0,t[13]=0,t[14]=0,t[15]=1,t},fromRotation:function(t,a,r){var u,e,o,i=r[0],h=r[1],c=r[2],s=Math.hypot(i,h,c);return s<n?null:(i*=s=1/s,h*=s,c*=s,u=Math.sin(a),o=1-(e=Math.cos(a)),t[0]=i*i*o+e,t[1]=h*i*o+c*u,t[2]=c*i*o-h*u,t[3]=0,t[4]=i*h*o-c*u,t[5]=h*h*o+e,t[6]=c*h*o+i*u,t[7]=0,t[8]=i*c*o+h*u,t[9]=h*c*o-i*u,t[10]=c*c*o+e,t[11]=0,t[12]=0,t[13]=0,t[14]=0,t[15]=1,t)},fromXRotation:function(t,n){var a=Math.sin(n),r=Math.cos(n);return t[0]=1,t[1]=0,t[2]=0,t[3]=0,t[4]=0,t[5]=r,t[6]=a,t[7]=0,t[8]=0,t[9]=-a,t[10]=r,t[11]=0,t[12]=0,t[13]=0,t[14]=0,t[15]=1,t},fromYRotation:function(t,n){var a=Math.sin(n),r=Math.cos(n);return t[0]=r,t[1]=0,t[2]=-a,t[3]=0,t[4]=0,t[5]=1,t[6]=0,t[7]=0,t[8]=a,t[9]=0,t[10]=r,t[11]=0,t[12]=0,t[13]=0,t[14]=0,t[15]=1,t},fromZRotation:function(t,n){var a=Math.sin(n),r=Math.cos(n);return t[0]=r,t[1]=a,t[2]=0,t[3]=0,t[4]=-a,t[5]=r,t[6]=0,t[7]=0,t[8]=0,t[9]=0,t[10]=1,t[11]=0,t[12]=0,t[13]=0,t[14]=0,t[15]=1,t},fromRotationTranslation:A,fromQuat2:function(t,n){var r=new a(3),u=-n[0],e=-n[1],o=-n[2],i=n[3],h=n[4],c=n[5],s=n[6],M=n[7],f=u*u+e*e+o*o+i*i;return f>0?(r[0]=2*(h*i+M*u+c*o-s*e)/f,r[1]=2*(c*i+M*e+s*u-h*o)/f,r[2]=2*(s*i+M*o+h*e-c*u)/f):(r[0]=2*(h*i+M*u+c*o-s*e),r[1]=2*(c*i+M*e+s*u-h*o),r[2]=2*(s*i+M*o+h*e-c*u)),A(t,n,r),t},getTranslation:w,getScaling:z,getRotation:R,decompose:function(t,n,a,r){n[0]=r[12],n[1]=r[13],n[2]=r[14];var u=r[0],e=r[1],o=r[2],i=r[4],h=r[5],c=r[6],s=r[8],M=r[9],f=r[10];a[0]=Math.hypot(u,e,o),a[1]=Math.hypot(i,h,c),a[2]=Math.hypot(s,M,f);var l=1/a[0],v=1/a[1],b=1/a[2],m=u*l,d=e*v,p=o*b,x=i*l,y=h*v,q=c*b,g=s*l,_=M*v,A=f*b,w=m+y+A,z=0;return w>0?(z=2*Math.sqrt(w+1),t[3]=.25*z,t[0]=(q-_)/z,t[1]=(g-p)/z,t[2]=(d-x)/z):m>y&&m>A?(z=2*Math.sqrt(1+m-y-A),t[3]=(q-_)/z,t[0]=.25*z,t[1]=(d+x)/z,t[2]=(g+p)/z):y>A?(z=2*Math.sqrt(1+y-m-A),t[3]=(g-p)/z,t[0]=(d+x)/z,t[1]=.25*z,t[2]=(q+_)/z):(z=2*Math.sqrt(1+A-m-y),t[3]=(d-x)/z,t[0]=(g+p)/z,t[1]=(q+_)/z,t[2]=.25*z),t},fromRotationTranslationScale:function(t,n,a,r){var u=n[0],e=n[1],o=n[2],i=n[3],h=u+u,c=e+e,s=o+o,M=u*h,f=u*c,l=u*s,v=e*c,b=e*s,m=o*s,d=i*h,p=i*c,x=i*s,y=r[0],q=r[1],g=r[2];return t[0]=(1-(v+m))*y,t[1]=(f+x)*y,t[2]=(l-p)*y,t[3]=0,t[4]=(f-x)*q,t[5]=(1-(M+m))*q,t[6]=(b+d)*q,t[7]=0,t[8]=(l+p)*g,t[9]=(b-d)*g,t[10]=(1-(M+v))*g,t[11]=0,t[12]=a[0],t[13]=a[1],t[14]=a[2],t[15]=1,t},fromRotationTranslationScaleOrigin:function(t,n,a,r,u){var e=n[0],o=n[1],i=n[2],h=n[3],c=e+e,s=o+o,M=i+i,f=e*c,l=e*s,v=e*M,b=o*s,m=o*M,d=i*M,p=h*c,x=h*s,y=h*M,q=r[0],g=r[1],_=r[2],A=u[0],w=u[1],z=u[2],R=(1-(b+d))*q,O=(l+y)*q,j=(v-x)*q,E=(l-y)*g,P=(1-(f+d))*g,T=(m+p)*g,S=(v+x)*_,D=(m-p)*_,F=(1-(f+b))*_;return t[0]=R,t[1]=O,t[2]=j,t[3]=0,t[4]=E,t[5]=P,t[6]=T,t[7]=0,t[8]=S,t[9]=D,t[10]=F,t[11]=0,t[12]=a[0]+A-(R*A+E*w+S*z),t[13]=a[1]+w-(O*A+P*w+D*z),t[14]=a[2]+z-(j*A+T*w+F*z),t[15]=1,t},fromQuat:function(t,n){var a=n[0],r=n[1],u=n[2],e=n[3],o=a+a,i=r+r,h=u+u,c=a*o,s=r*o,M=r*i,f=u*o,l=u*i,v=u*h,b=e*o,m=e*i,d=e*h;return t[0]=1-M-v,t[1]=s+d,t[2]=f-m,t[3]=0,t[4]=s-d,t[5]=1-c-v,t[6]=l+b,t[7]=0,t[8]=f+m,t[9]=l-b,t[10]=1-c-M,t[11]=0,t[12]=0,t[13]=0,t[14]=0,t[15]=1,t},frustum:function(t,n,a,r,u,e,o){var i=1/(a-n),h=1/(u-r),c=1/(e-o);return t[0]=2*e*i,t[1]=0,t[2]=0,t[3]=0,t[4]=0,t[5]=2*e*h,t[6]=0,t[7]=0,t[8]=(a+n)*i,t[9]=(u+r)*h,t[10]=(o+e)*c,t[11]=-1,t[12]=0,t[13]=0,t[14]=o*e*2*c,t[15]=0,t},perspectiveNO:O,perspective:j,perspectiveZO:function(t,n,a,r,u){var e=1/Math.tan(n/2);if(t[0]=e/a,t[1]=0,t[2]=0,t[3]=0,t[4]=0,t[5]=e,t[6]=0,t[7]=0,t[8]=0,t[9]=0,t[11]=-1,t[12]=0,t[13]=0,t[15]=0,null!=u&&u!==1/0){var o=1/(r-u);t[10]=u*o,t[14]=u*r*o}else t[10]=-1,t[14]=-r;return t},perspectiveFromFieldOfView:function(t,n,a,r){var u=Math.tan(n.upDegrees*Math.PI/180),e=Math.tan(n.downDegrees*Math.PI/180),o=Math.tan(n.leftDegrees*Math.PI/180),i=Math.tan(n.rightDegrees*Math.PI/180),h=2/(o+i),c=2/(u+e);return t[0]=h,t[1]=0,t[2]=0,t[3]=0,t[4]=0,t[5]=c,t[6]=0,t[7]=0,t[8]=-(o-i)*h*.5,t[9]=(u-e)*c*.5,t[10]=r/(a-r),t[11]=-1,t[12]=0,t[13]=0,t[14]=r*a/(a-r),t[15]=0,t},orthoNO:E,ortho:P,orthoZO:function(t,n,a,r,u,e,o){var i=1/(n-a),h=1/(r-u),c=1/(e-o);return t[0]=-2*i,t[1]=0,t[2]=0,t[3]=0,t[4]=0,t[5]=-2*h,t[6]=0,t[7]=0,t[8]=0,t[9]=0,t[10]=c,t[11]=0,t[12]=(n+a)*i,t[13]=(u+r)*h,t[14]=e*c,t[15]=1,t},lookAt:function(t,a,r,u){var e,o,i,h,c,s,M,f,l,v,b=a[0],m=a[1],d=a[2],p=u[0],x=u[1],y=u[2],q=r[0],_=r[1],A=r[2];return Math.abs(b-q)<n&&Math.abs(m-_)<n&&Math.abs(d-A)<n?g(t):(M=b-q,f=m-_,l=d-A,e=x*(l*=v=1/Math.hypot(M,f,l))-y*(f*=v),o=y*(M*=v)-p*l,i=p*f-x*M,(v=Math.hypot(e,o,i))?(e*=v=1/v,o*=v,i*=v):(e=0,o=0,i=0),h=f*i-l*o,c=l*e-M*i,s=M*o-f*e,(v=Math.hypot(h,c,s))?(h*=v=1/v,c*=v,s*=v):(h=0,c=0,s=0),t[0]=e,t[1]=h,t[2]=M,t[3]=0,t[4]=o,t[5]=c,t[6]=f,t[7]=0,t[8]=i,t[9]=s,t[10]=l,t[11]=0,t[12]=-(e*b+o*m+i*d),t[13]=-(h*b+c*m+s*d),t[14]=-(M*b+f*m+l*d),t[15]=1,t)},targetTo:function(t,n,a,r){var u=n[0],e=n[1],o=n[2],i=r[0],h=r[1],c=r[2],s=u-a[0],M=e-a[1],f=o-a[2],l=s*s+M*M+f*f;l>0&&(s*=l=1/Math.sqrt(l),M*=l,f*=l);var v=h*f-c*M,b=c*s-i*f,m=i*M-h*s;return(l=v*v+b*b+m*m)>0&&(v*=l=1/Math.sqrt(l),b*=l,m*=l),t[0]=v,t[1]=b,t[2]=m,t[3]=0,t[4]=M*m-f*b,t[5]=f*v-s*m,t[6]=s*b-M*v,t[7]=0,t[8]=s,t[9]=M,t[10]=f,t[11]=0,t[12]=u,t[13]=e,t[14]=o,t[15]=1,t},str:function(t){return"mat4("+t[0]+", "+t[1]+", "+t[2]+", "+t[3]+", "+t[4]+", "+t[5]+", "+t[6]+", "+t[7]+", "+t[8]+", "+t[9]+", "+t[10]+", "+t[11]+", "+t[12]+", "+t[13]+", "+t[14]+", "+t[15]+")"},frob:function(t){return Math.hypot(t[0],t[1],t[2],t[3],t[4],t[5],t[6],t[7],t[8],t[9],t[10],t[11],t[12],t[13],t[14],t[15])},add:function(t,n,a){return t[0]=n[0]+a[0],t[1]=n[1]+a[1],t[2]=n[2]+a[2],t[3]=n[3]+a[3],t[4]=n[4]+a[4],t[5]=n[5]+a[5],t[6]=n[6]+a[6],t[7]=n[7]+a[7],t[8]=n[8]+a[8],t[9]=n[9]+a[9],t[10]=n[10]+a[10],t[11]=n[11]+a[11],t[12]=n[12]+a[12],t[13]=n[13]+a[13],t[14]=n[14]+a[14],t[15]=n[15]+a[15],t},subtract:T,multiplyScalar:function(t,n,a){return t[0]=n[0]*a,t[1]=n[1]*a,t[2]=n[2]*a,t[3]=n[3]*a,t[4]=n[4]*a,t[5]=n[5]*a,t[6]=n[6]*a,t[7]=n[7]*a,t[8]=n[8]*a,t[9]=n[9]*a,t[10]=n[10]*a,t[11]=n[11]*a,t[12]=n[12]*a,t[13]=n[13]*a,t[14]=n[14]*a,t[15]=n[15]*a,t},multiplyScalarAndAdd:function(t,n,a,r){return t[0]=n[0]+a[0]*r,t[1]=n[1]+a[1]*r,t[2]=n[2]+a[2]*r,t[3]=n[3]+a[3]*r,t[4]=n[4]+a[4]*r,t[5]=n[5]+a[5]*r,t[6]=n[6]+a[6]*r,t[7]=n[7]+a[7]*r,t[8]=n[8]+a[8]*r,t[9]=n[9]+a[9]*r,t[10]=n[10]+a[10]*r,t[11]=n[11]+a[11]*r,t[12]=n[12]+a[12]*r,t[13]=n[13]+a[13]*r,t[14]=n[14]+a[14]*r,t[15]=n[15]+a[15]*r,t},exactEquals:function(t,n){return t[0]===n[0]&&t[1]===n[1]&&t[2]===n[2]&&t[3]===n[3]&&t[4]===n[4]&&t[5]===n[5]&&t[6]===n[6]&&t[7]===n[7]&&t[8]===n[8]&&t[9]===n[9]&&t[10]===n[10]&&t[11]===n[11]&&t[12]===n[12]&&t[13]===n[13]&&t[14]===n[14]&&t[15]===n[15]},equals:function(t,a){var r=t[0],u=t[1],e=t[2],o=t[3],i=t[4],h=t[5],c=t[6],s=t[7],M=t[8],f=t[9],l=t[10],v=t[11],b=t[12],m=t[13],d=t[14],p=t[15],x=a[0],y=a[1],q=a[2],g=a[3],_=a[4],A=a[5],w=a[6],z=a[7],R=a[8],O=a[9],j=a[10],E=a[11],P=a[12],T=a[13],S=a[14],D=a[15];return Math.abs(r-x)<=n*Math.max(1,Math.abs(r),Math.abs(x))&&Math.abs(u-y)<=n*Math.max(1,Math.abs(u),Math.abs(y))&&Math.abs(e-q)<=n*Math.max(1,Math.abs(e),Math.abs(q))&&Math.abs(o-g)<=n*Math.max(1,Math.abs(o),Math.abs(g))&&Math.abs(i-_)<=n*Math.max(1,Math.abs(i),Math.abs(_))&&Math.abs(h-A)<=n*Math.max(1,Math.abs(h),Math.abs(A))&&Math.abs(c-w)<=n*Math.max(1,Math.abs(c),Math.abs(w))&&Math.abs(s-z)<=n*Math.max(1,Math.abs(s),Math.abs(z))&&Math.abs(M-R)<=n*Math.max(1,Math.abs(M),Math.abs(R))&&Math.abs(f-O)<=n*Math.max(1,Math.abs(f),Math.abs(O))&&Math.abs(l-j)<=n*Math.max(1,Math.abs(l),Math.abs(j))&&Math.abs(v-E)<=n*Math.max(1,Math.abs(v),Math.abs(E))&&Math.abs(b-P)<=n*Math.max(1,Math.abs(b),Math.abs(P))&&Math.abs(m-T)<=n*Math.max(1,Math.abs(m),Math.abs(T))&&Math.abs(d-S)<=n*Math.max(1,Math.abs(d),Math.abs(S))&&Math.abs(p-D)<=n*Math.max(1,Math.abs(p),Math.abs(D))},mul:S,sub:D});function I(){var t=new a(3);return a!=Float32Array&&(t[0]=0,t[1]=0,t[2]=0),t}function L(t){var n=t[0],a=t[1],r=t[2];return Math.hypot(n,a,r)}function V(t,n,r){var u=new a(3);return u[0]=t,u[1]=n,u[2]=r,u}function k(t,n,a){return t[0]=n[0]-a[0],t[1]=n[1]-a[1],t[2]=n[2]-a[2],t}function Q(t,n,a){return t[0]=n[0]*a[0],t[1]=n[1]*a[1],t[2]=n[2]*a[2],t}function Y(t,n,a){return t[0]=n[0]/a[0],t[1]=n[1]/a[1],t[2]=n[2]/a[2],t}function Z(t,n){var a=n[0]-t[0],r=n[1]-t[1],u=n[2]-t[2];return Math.hypot(a,r,u)}function N(t,n){var a=n[0]-t[0],r=n[1]-t[1],u=n[2]-t[2];return a*a+r*r+u*u}function X(t){var n=t[0],a=t[1],r=t[2];return n*n+a*a+r*r}function B(t,n){var a=n[0],r=n[1],u=n[2],e=a*a+r*r+u*u;return e>0&&(e=1/Math.sqrt(e)),t[0]=n[0]*e,t[1]=n[1]*e,t[2]=n[2]*e,t}function U(t,n){return t[0]*n[0]+t[1]*n[1]+t[2]*n[2]}function G(t,n,a){var r=n[0],u=n[1],e=n[2],o=a[0],i=a[1],h=a[2];return t[0]=u*h-e*i,t[1]=e*o-r*h,t[2]=r*i-u*o,t}var W,C=k,H=Q,J=Y,K=Z,$=N,tt=L,nt=X,at=(W=I(),function(t,n,a,r,u,e){var o,i;for(n||(n=3),a||(a=0),i=r?Math.min(r*n+a,t.length):t.length,o=a;o<i;o+=n)W[0]=t[o],W[1]=t[o+1],W[2]=t[o+2],u(W,W,e),t[o]=W[0],t[o+1]=W[1],t[o+2]=W[2];return t}),rt=Object.freeze({__proto__:null,create:I,clone:function(t){var n=new a(3);return n[0]=t[0],n[1]=t[1],n[2]=t[2],n},length:L,fromValues:V,copy:function(t,n){return t[0]=n[0],t[1]=n[1],t[2]=n[2],t},set:function(t,n,a,r){return t[0]=n,t[1]=a,t[2]=r,t},add:function(t,n,a){return t[0]=n[0]+a[0],t[1]=n[1]+a[1],t[2]=n[2]+a[2],t},subtract:k,multiply:Q,divide:Y,ceil:function(t,n){return t[0]=Math.ceil(n[0]),t[1]=Math.ceil(n[1]),t[2]=Math.ceil(n[2]),t},floor:function(t,n){return t[0]=Math.floor(n[0]),t[1]=Math.floor(n[1]),t[2]=Math.floor(n[2]),t},min:function(t,n,a){return t[0]=Math.min(n[0],a[0]),t[1]=Math.min(n[1],a[1]),t[2]=Math.min(n[2],a[2]),t},max:function(t,n,a){return t[0]=Math.max(n[0],a[0]),t[1]=Math.max(n[1],a[1]),t[2]=Math.max(n[2],a[2]),t},round:function(t,n){return t[0]=Math.round(n[0]),t[1]=Math.round(n[1]),t[2]=Math.round(n[2]),t},scale:function(t,n,a){return t[0]=n[0]*a,t[1]=n[1]*a,t[2]=n[2]*a,t},scaleAndAdd:function(t,n,a,r){return t[0]=n[0]+a[0]*r,t[1]=n[1]+a[1]*r,t[2]=n[2]+a[2]*r,t},distance:Z,squaredDistance:N,squaredLength:X,negate:function(t,n){return t[0]=-n[0],t[1]=-n[1],t[2]=-n[2],t},inverse:function(t,n){return t[0]=1/n[0],t[1]=1/n[1],t[2]=1/n[2],t},normalize:B,dot:U,cross:G,lerp:function(t,n,a,r){var u=n[0],e=n[1],o=n[2];return t[0]=u+r*(a[0]-u),t[1]=e+r*(a[1]-e),t[2]=o+r*(a[2]-o),t},slerp:function(t,n,a,r){var u=Math.acos(Math.min(Math.max(U(n,a),-1),1)),e=Math.sin(u),o=Math.sin((1-r)*u)/e,i=Math.sin(r*u)/e;return t[0]=o*n[0]+i*a[0],t[1]=o*n[1]+i*a[1],t[2]=o*n[2]+i*a[2],t},hermite:function(t,n,a,r,u,e){var o=e*e,i=o*(2*e-3)+1,h=o*(e-2)+e,c=o*(e-1),s=o*(3-2*e);return t[0]=n[0]*i+a[0]*h+r[0]*c+u[0]*s,t[1]=n[1]*i+a[1]*h+r[1]*c+u[1]*s,t[2]=n[2]*i+a[2]*h+r[2]*c+u[2]*s,t},bezier:function(t,n,a,r,u,e){var o=1-e,i=o*o,h=e*e,c=i*o,s=3*e*i,M=3*h*o,f=h*e;return t[0]=n[0]*c+a[0]*s+r[0]*M+u[0]*f,t[1]=n[1]*c+a[1]*s+r[1]*M+u[1]*f,t[2]=n[2]*c+a[2]*s+r[2]*M+u[2]*f,t},random:function(t,n){n=n||1;var a=2*r()*Math.PI,u=2*r()-1,e=Math.sqrt(1-u*u)*n;return t[0]=Math.cos(a)*e,t[1]=Math.sin(a)*e,t[2]=u*n,t},transformMat4:function(t,n,a){var r=n[0],u=n[1],e=n[2],o=a[3]*r+a[7]*u+a[11]*e+a[15];return o=o||1,t[0]=(a[0]*r+a[4]*u+a[8]*e+a[12])/o,t[1]=(a[1]*r+a[5]*u+a[9]*e+a[13])/o,t[2]=(a[2]*r+a[6]*u+a[10]*e+a[14])/o,t},transformMat3:function(t,n,a){var r=n[0],u=n[1],e=n[2];return t[0]=r*a[0]+u*a[3]+e*a[6],t[1]=r*a[1]+u*a[4]+e*a[7],t[2]=r*a[2]+u*a[5]+e*a[8],t},transformQuat:function(t,n,a){var r=a[0],u=a[1],e=a[2],o=a[3],i=n[0],h=n[1],c=n[2],s=u*c-e*h,M=e*i-r*c,f=r*h-u*i,l=u*f-e*M,v=e*s-r*f,b=r*M-u*s,m=2*o;return s*=m,M*=m,f*=m,l*=2,v*=2,b*=2,t[0]=i+s+l,t[1]=h+M+v,t[2]=c+f+b,t},rotateX:function(t,n,a,r){var u=[],e=[];return u[0]=n[0]-a[0],u[1]=n[1]-a[1],u[2]=n[2]-a[2],e[0]=u[0],e[1]=u[1]*Math.cos(r)-u[2]*Math.sin(r),e[2]=u[1]*Math.sin(r)+u[2]*Math.cos(r),t[0]=e[0]+a[0],t[1]=e[1]+a[1],t[2]=e[2]+a[2],t},rotateY:function(t,n,a,r){var u=[],e=[];return u[0]=n[0]-a[0],u[1]=n[1]-a[1],u[2]=n[2]-a[2],e[0]=u[2]*Math.sin(r)+u[0]*Math.cos(r),e[1]=u[1],e[2]=u[2]*Math.cos(r)-u[0]*Math.sin(r),t[0]=e[0]+a[0],t[1]=e[1]+a[1],t[2]=e[2]+a[2],t},rotateZ:function(t,n,a,r){var u=[],e=[];return u[0]=n[0]-a[0],u[1]=n[1]-a[1],u[2]=n[2]-a[2],e[0]=u[0]*Math.cos(r)-u[1]*Math.sin(r),e[1]=u[0]*Math.sin(r)+u[1]*Math.cos(r),e[2]=u[2],t[0]=e[0]+a[0],t[1]=e[1]+a[1],t[2]=e[2]+a[2],t},angle:function(t,n){var a=t[0],r=t[1],u=t[2],e=n[0],o=n[1],i=n[2],h=Math.sqrt((a*a+r*r+u*u)*(e*e+o*o+i*i)),c=h&&U(t,n)/h;return Math.acos(Math.min(Math.max(c,-1),1))},zero:function(t){return t[0]=0,t[1]=0,t[2]=0,t},str:function(t){return"vec3("+t[0]+", "+t[1]+", "+t[2]+")"},exactEquals:function(t,n){return t[0]===n[0]&&t[1]===n[1]&&t[2]===n[2]},equals:function(t,a){var r=t[0],u=t[1],e=t[2],o=a[0],i=a[1],h=a[2];return Math.abs(r-o)<=n*Math.max(1,Math.abs(r),Math.abs(o))&&Math.abs(u-i)<=n*Math.max(1,Math.abs(u),Math.abs(i))&&Math.abs(e-h)<=n*Math.max(1,Math.abs(e),Math.abs(h))},sub:C,mul:H,div:J,dist:K,sqrDist:$,len:tt,sqrLen:nt,forEach:at});function ut(){var t=new a(4);return a!=Float32Array&&(t[0]=0,t[1]=0,t[2]=0,t[3]=0),t}function et(t){var n=new a(4);return n[0]=t[0],n[1]=t[1],n[2]=t[2],n[3]=t[3],n}function ot(t,n,r,u){var e=new a(4);return e[0]=t,e[1]=n,e[2]=r,e[3]=u,e}function it(t,n){return t[0]=n[0],t[1]=n[1],t[2]=n[2],t[3]=n[3],t}function ht(t,n,a,r,u){return t[0]=n,t[1]=a,t[2]=r,t[3]=u,t}function ct(t,n,a){return t[0]=n[0]+a[0],t[1]=n[1]+a[1],t[2]=n[2]+a[2],t[3]=n[3]+a[3],t}function st(t,n,a){return t[0]=n[0]-a[0],t[1]=n[1]-a[1],t[2]=n[2]-a[2],t[3]=n[3]-a[3],t}function Mt(t,n,a){return t[0]=n[0]*a[0],t[1]=n[1]*a[1],t[2]=n[2]*a[2],t[3]=n[3]*a[3],t}function ft(t,n,a){return t[0]=n[0]/a[0],t[1]=n[1]/a[1],t[2]=n[2]/a[2],t[3]=n[3]/a[3],t}function lt(t,n,a){return t[0]=n[0]*a,t[1]=n[1]*a,t[2]=n[2]*a,t[3]=n[3]*a,t}function vt(t,n){var a=n[0]-t[0],r=n[1]-t[1],u=n[2]-t[2],e=n[3]-t[3];return Math.hypot(a,r,u,e)}function bt(t,n){var a=n[0]-t[0],r=n[1]-t[1],u=n[2]-t[2],e=n[3]-t[3];return a*a+r*r+u*u+e*e}function mt(t){var n=t[0],a=t[1],r=t[2],u=t[3];return Math.hypot(n,a,r,u)}function dt(t){var n=t[0],a=t[1],r=t[2],u=t[3];return n*n+a*a+r*r+u*u}function pt(t,n){var a=n[0],r=n[1],u=n[2],e=n[3],o=a*a+r*r+u*u+e*e;return o>0&&(o=1/Math.sqrt(o)),t[0]=a*o,t[1]=r*o,t[2]=u*o,t[3]=e*o,t}function xt(t,n){return t[0]*n[0]+t[1]*n[1]+t[2]*n[2]+t[3]*n[3]}function yt(t,n,a,r){var u=n[0],e=n[1],o=n[2],i=n[3];return t[0]=u+r*(a[0]-u),t[1]=e+r*(a[1]-e),t[2]=o+r*(a[2]-o),t[3]=i+r*(a[3]-i),t}function qt(t,n){return t[0]===n[0]&&t[1]===n[1]&&t[2]===n[2]&&t[3]===n[3]}var gt=st,_t=Mt,At=ft,wt=vt,zt=bt,Rt=mt,Ot=dt,jt=function(){var t=ut();return function(n,a,r,u,e,o){var i,h;for(a||(a=4),r||(r=0),h=u?Math.min(u*a+r,n.length):n.length,i=r;i<h;i+=a)t[0]=n[i],t[1]=n[i+1],t[2]=n[i+2],t[3]=n[i+3],e(t,t,o),n[i]=t[0],n[i+1]=t[1],n[i+2]=t[2],n[i+3]=t[3];return n}}(),Et=Object.freeze({__proto__:null,create:ut,clone:et,fromValues:ot,copy:it,set:ht,add:ct,subtract:st,multiply:Mt,divide:ft,ceil:function(t,n){return t[0]=Math.ceil(n[0]),t[1]=Math.ceil(n[1]),t[2]=Math.ceil(n[2]),t[3]=Math.ceil(n[3]),t},floor:function(t,n){return t[0]=Math.floor(n[0]),t[1]=Math.floor(n[1]),t[2]=Math.floor(n[2]),t[3]=Math.floor(n[3]),t},min:function(t,n,a){return t[0]=Math.min(n[0],a[0]),t[1]=Math.min(n[1],a[1]),t[2]=Math.min(n[2],a[2]),t[3]=Math.min(n[3],a[3]),t},max:function(t,n,a){return t[0]=Math.max(n[0],a[0]),t[1]=Math.max(n[1],a[1]),t[2]=Math.max(n[2],a[2]),t[3]=Math.max(n[3],a[3]),t},round:function(t,n){return t[0]=Math.round(n[0]),t[1]=Math.round(n[1]),t[2]=Math.round(n[2]),t[3]=Math.round(n[3]),t},scale:lt,scaleAndAdd:function(t,n,a,r){return t[0]=n[0]+a[0]*r,t[1]=n[1]+a[1]*r,t[2]=n[2]+a[2]*r,t[3]=n[3]+a[3]*r,t},distance:vt,squaredDistance:bt,length:mt,squaredLength:dt,negate:function(t,n){return t[0]=-n[0],t[1]=-n[1],t[2]=-n[2],t[3]=-n[3],t},inverse:function(t,n){return t[0]=1/n[0],t[1]=1/n[1],t[2]=1/n[2],t[3]=1/n[3],t},normalize:pt,dot:xt,cross:function(t,n,a,r){var u=a[0]*r[1]-a[1]*r[0],e=a[0]*r[2]-a[2]*r[0],o=a[0]*r[3]-a[3]*r[0],i=a[1]*r[2]-a[2]*r[1],h=a[1]*r[3]-a[3]*r[1],c=a[2]*r[3]-a[3]*r[2],s=n[0],M=n[1],f=n[2],l=n[3];return t[0]=M*c-f*h+l*i,t[1]=-s*c+f*o-l*e,t[2]=s*h-M*o+l*u,t[3]=-s*i+M*e-f*u,t},lerp:yt,random:function(t,n){var a,u,e,o,i,h;n=n||1;do{i=(a=2*r()-1)*a+(u=2*r()-1)*u}while(i>=1);do{h=(e=2*r()-1)*e+(o=2*r()-1)*o}while(h>=1);var c=Math.sqrt((1-i)/h);return t[0]=n*a,t[1]=n*u,t[2]=n*e*c,t[3]=n*o*c,t},transformMat4:function(t,n,a){var r=n[0],u=n[1],e=n[2],o=n[3];return t[0]=a[0]*r+a[4]*u+a[8]*e+a[12]*o,t[1]=a[1]*r+a[5]*u+a[9]*e+a[13]*o,t[2]=a[2]*r+a[6]*u+a[10]*e+a[14]*o,t[3]=a[3]*r+a[7]*u+a[11]*e+a[15]*o,t},transformQuat:function(t,n,a){var r=n[0],u=n[1],e=n[2],o=a[0],i=a[1],h=a[2],c=a[3],s=c*r+i*e-h*u,M=c*u+h*r-o*e,f=c*e+o*u-i*r,l=-o*r-i*u-h*e;return t[0]=s*c+l*-o+M*-h-f*-i,t[1]=M*c+l*-i+f*-o-s*-h,t[2]=f*c+l*-h+s*-i-M*-o,t[3]=n[3],t},zero:function(t){return t[0]=0,t[1]=0,t[2]=0,t[3]=0,t},str:function(t){return"vec4("+t[0]+", "+t[1]+", "+t[2]+", "+t[3]+")"},exactEquals:qt,equals:function(t,a){var r=t[0],u=t[1],e=t[2],o=t[3],i=a[0],h=a[1],c=a[2],s=a[3];return Math.abs(r-i)<=n*Math.max(1,Math.abs(r),Math.abs(i))&&Math.abs(u-h)<=n*Math.max(1,Math.abs(u),Math.abs(h))&&Math.abs(e-c)<=n*Math.max(1,Math.abs(e),Math.abs(c))&&Math.abs(o-s)<=n*Math.max(1,Math.abs(o),Math.abs(s))},sub:gt,mul:_t,div:At,dist:wt,sqrDist:zt,len:Rt,sqrLen:Ot,forEach:jt});function Pt(){var t=new a(4);return a!=Float32Array&&(t[0]=0,t[1]=0,t[2]=0),t[3]=1,t}function Tt(t,n,a){a*=.5;var r=Math.sin(a);return t[0]=r*n[0],t[1]=r*n[1],t[2]=r*n[2],t[3]=Math.cos(a),t}function St(t,n,a){var r=n[0],u=n[1],e=n[2],o=n[3],i=a[0],h=a[1],c=a[2],s=a[3];return t[0]=r*s+o*i+u*c-e*h,t[1]=u*s+o*h+e*i-r*c,t[2]=e*s+o*c+r*h-u*i,t[3]=o*s-r*i-u*h-e*c,t}function Dt(t,n,a){a*=.5;var r=n[0],u=n[1],e=n[2],o=n[3],i=Math.sin(a),h=Math.cos(a);return t[0]=r*h+o*i,t[1]=u*h+e*i,t[2]=e*h-u*i,t[3]=o*h-r*i,t}function Ft(t,n,a){a*=.5;var r=n[0],u=n[1],e=n[2],o=n[3],i=Math.sin(a),h=Math.cos(a);return t[0]=r*h-e*i,t[1]=u*h+o*i,t[2]=e*h+r*i,t[3]=o*h-u*i,t}function It(t,n,a){a*=.5;var r=n[0],u=n[1],e=n[2],o=n[3],i=Math.sin(a),h=Math.cos(a);return t[0]=r*h+u*i,t[1]=u*h-r*i,t[2]=e*h+o*i,t[3]=o*h-e*i,t}function Lt(t,n){var a=n[0],r=n[1],u=n[2],e=n[3],o=Math.sqrt(a*a+r*r+u*u),i=Math.exp(e),h=o>0?i*Math.sin(o)/o:0;return t[0]=a*h,t[1]=r*h,t[2]=u*h,t[3]=i*Math.cos(o),t}function Vt(t,n){var a=n[0],r=n[1],u=n[2],e=n[3],o=Math.sqrt(a*a+r*r+u*u),i=o>0?Math.atan2(o,e)/o:0;return t[0]=a*i,t[1]=r*i,t[2]=u*i,t[3]=.5*Math.log(a*a+r*r+u*u+e*e),t}function kt(t,a,r,u){var e,o,i,h,c,s=a[0],M=a[1],f=a[2],l=a[3],v=r[0],b=r[1],m=r[2],d=r[3];return(o=s*v+M*b+f*m+l*d)<0&&(o=-o,v=-v,b=-b,m=-m,d=-d),1-o>n?(e=Math.acos(o),i=Math.sin(e),h=Math.sin((1-u)*e)/i,c=Math.sin(u*e)/i):(h=1-u,c=u),t[0]=h*s+c*v,t[1]=h*M+c*b,t[2]=h*f+c*m,t[3]=h*l+c*d,t}function Qt(t,n){var a,r=n[0]+n[4]+n[8];if(r>0)a=Math.sqrt(r+1),t[3]=.5*a,a=.5/a,t[0]=(n[5]-n[7])*a,t[1]=(n[6]-n[2])*a,t[2]=(n[1]-n[3])*a;else{var u=0;n[4]>n[0]&&(u=1),n[8]>n[3*u+u]&&(u=2);var e=(u+1)%3,o=(u+2)%3;a=Math.sqrt(n[3*u+u]-n[3*e+e]-n[3*o+o]+1),t[u]=.5*a,a=.5/a,t[3]=(n[3*e+o]-n[3*o+e])*a,t[e]=(n[3*e+u]+n[3*u+e])*a,t[o]=(n[3*o+u]+n[3*u+o])*a}return t}var Yt=et,Zt=ot,Nt=it,Xt=ht,Bt=ct,Ut=St,Gt=lt,Wt=xt,Ct=yt,Ht=mt,Jt=Ht,Kt=dt,$t=Kt,tn=pt,nn=qt;var an,rn,un,en,on,hn,cn=(an=I(),rn=V(1,0,0),un=V(0,1,0),function(t,n,a){var r=U(n,a);return r<-.999999?(G(an,rn,n),tt(an)<1e-6&&G(an,un,n),B(an,an),Tt(t,an,Math.PI),t):r>.999999?(t[0]=0,t[1]=0,t[2]=0,t[3]=1,t):(G(an,n,a),t[0]=an[0],t[1]=an[1],t[2]=an[2],t[3]=1+r,tn(t,t))}),sn=(en=Pt(),on=Pt(),function(t,n,a,r,u,e){return kt(en,n,u,e),kt(on,a,r,e),kt(t,en,on,2*e*(1-e)),t}),Mn=(hn=m(),function(t,n,a,r){return hn[0]=a[0],hn[3]=a[1],hn[6]=a[2],hn[1]=r[0],hn[4]=r[1],hn[7]=r[2],hn[2]=-n[0],hn[5]=-n[1],hn[8]=-n[2],tn(t,Qt(t,hn))}),fn=Object.freeze({__proto__:null,create:Pt,identity:function(t){return t[0]=0,t[1]=0,t[2]=0,t[3]=1,t},setAxisAngle:Tt,getAxisAngle:function(t,a){var r=2*Math.acos(a[3]),u=Math.sin(r/2);return u>n?(t[0]=a[0]/u,t[1]=a[1]/u,t[2]=a[2]/u):(t[0]=1,t[1]=0,t[2]=0),r},getAngle:function(t,n){var a=Wt(t,n);return Math.acos(2*a*a-1)},multiply:St,rotateX:Dt,rotateY:Ft,rotateZ:It,calculateW:function(t,n){var a=n[0],r=n[1],u=n[2];return t[0]=a,t[1]=r,t[2]=u,t[3]=Math.sqrt(Math.abs(1-a*a-r*r-u*u)),t},exp:Lt,ln:Vt,pow:function(t,n,a){return Vt(t,n),Gt(t,t,a),Lt(t,t),t},slerp:kt,random:function(t){var n=r(),a=r(),u=r(),e=Math.sqrt(1-n),o=Math.sqrt(n);return t[0]=e*Math.sin(2*Math.PI*a),t[1]=e*Math.cos(2*Math.PI*a),t[2]=o*Math.sin(2*Math.PI*u),t[3]=o*Math.cos(2*Math.PI*u),t},invert:function(t,n){var a=n[0],r=n[1],u=n[2],e=n[3],o=a*a+r*r+u*u+e*e,i=o?1/o:0;return t[0]=-a*i,t[1]=-r*i,t[2]=-u*i,t[3]=e*i,t},conjugate:function(t,n){return t[0]=-n[0],t[1]=-n[1],t[2]=-n[2],t[3]=n[3],t},fromMat3:Qt,fromEuler:function(t,n,a,r){var u=arguments.length>4&&void 0!==arguments[4]?arguments[4]:"zyx",e=Math.PI/360;n*=e,r*=e,a*=e;var o=Math.sin(n),i=Math.cos(n),h=Math.sin(a),c=Math.cos(a),s=Math.sin(r),M=Math.cos(r);switch(u){case"xyz":t[0]=o*c*M+i*h*s,t[1]=i*h*M-o*c*s,t[2]=i*c*s+o*h*M,t[3]=i*c*M-o*h*s;break;case"xzy":t[0]=o*c*M-i*h*s,t[1]=i*h*M-o*c*s,t[2]=i*c*s+o*h*M,t[3]=i*c*M+o*h*s;break;case"yxz":t[0]=o*c*M+i*h*s,t[1]=i*h*M-o*c*s,t[2]=i*c*s-o*h*M,t[3]=i*c*M+o*h*s;break;case"yzx":t[0]=o*c*M+i*h*s,t[1]=i*h*M+o*c*s,t[2]=i*c*s-o*h*M,t[3]=i*c*M-o*h*s;break;case"zxy":t[0]=o*c*M-i*h*s,t[1]=i*h*M+o*c*s,t[2]=i*c*s+o*h*M,t[3]=i*c*M-o*h*s;break;case"zyx":t[0]=o*c*M-i*h*s,t[1]=i*h*M+o*c*s,t[2]=i*c*s-o*h*M,t[3]=i*c*M+o*h*s;break;default:throw new Error("Unknown angle order "+u)}return t},str:function(t){return"quat("+t[0]+", "+t[1]+", "+t[2]+", "+t[3]+")"},clone:Yt,fromValues:Zt,copy:Nt,set:Xt,add:Bt,mul:Ut,scale:Gt,dot:Wt,lerp:Ct,length:Ht,len:Jt,squaredLength:Kt,sqrLen:$t,normalize:tn,exactEquals:nn,equals:function(t,a){return Math.abs(xt(t,a))>=1-n},rotationTo:cn,sqlerp:sn,setAxes:Mn});function ln(t,n,a){var r=.5*a[0],u=.5*a[1],e=.5*a[2],o=n[0],i=n[1],h=n[2],c=n[3];return t[0]=o,t[1]=i,t[2]=h,t[3]=c,t[4]=r*c+u*h-e*i,t[5]=u*c+e*o-r*h,t[6]=e*c+r*i-u*o,t[7]=-r*o-u*i-e*h,t}function vn(t,n){return t[0]=n[0],t[1]=n[1],t[2]=n[2],t[3]=n[3],t[4]=n[4],t[5]=n[5],t[6]=n[6],t[7]=n[7],t}var bn=Nt;var mn=Nt;function dn(t,n,a){var r=n[0],u=n[1],e=n[2],o=n[3],i=a[4],h=a[5],c=a[6],s=a[7],M=n[4],f=n[5],l=n[6],v=n[7],b=a[0],m=a[1],d=a[2],p=a[3];return t[0]=r*p+o*b+u*d-e*m,t[1]=u*p+o*m+e*b-r*d,t[2]=e*p+o*d+r*m-u*b,t[3]=o*p-r*b-u*m-e*d,t[4]=r*s+o*i+u*c-e*h+M*p+v*b+f*d-l*m,t[5]=u*s+o*h+e*i-r*c+f*p+v*m+l*b-M*d,t[6]=e*s+o*c+r*h-u*i+l*p+v*d+M*m-f*b,t[7]=o*s-r*i-u*h-e*c+v*p-M*b-f*m-l*d,t}var pn=dn;var xn=Wt;var yn=Ht,qn=yn,gn=Kt,_n=gn;var An=Object.freeze({__proto__:null,create:function(){var t=new a(8);return a!=Float32Array&&(t[0]=0,t[1]=0,t[2]=0,t[4]=0,t[5]=0,t[6]=0,t[7]=0),t[3]=1,t},clone:function(t){var n=new a(8);return n[0]=t[0],n[1]=t[1],n[2]=t[2],n[3]=t[3],n[4]=t[4],n[5]=t[5],n[6]=t[6],n[7]=t[7],n},fromValues:function(t,n,r,u,e,o,i,h){var c=new a(8);return c[0]=t,c[1]=n,c[2]=r,c[3]=u,c[4]=e,c[5]=o,c[6]=i,c[7]=h,c},fromRotationTranslationValues:function(t,n,r,u,e,o,i){var h=new a(8);h[0]=t,h[1]=n,h[2]=r,h[3]=u;var c=.5*e,s=.5*o,M=.5*i;return h[4]=c*u+s*r-M*n,h[5]=s*u+M*t-c*r,h[6]=M*u+c*n-s*t,h[7]=-c*t-s*n-M*r,h},fromRotationTranslation:ln,fromTranslation:function(t,n){return t[0]=0,t[1]=0,t[2]=0,t[3]=1,t[4]=.5*n[0],t[5]=.5*n[1],t[6]=.5*n[2],t[7]=0,t},fromRotation:function(t,n){return t[0]=n[0],t[1]=n[1],t[2]=n[2],t[3]=n[3],t[4]=0,t[5]=0,t[6]=0,t[7]=0,t},fromMat4:function(t,n){var r=Pt();R(r,n);var u=new a(3);return w(u,n),ln(t,r,u),t},copy:vn,identity:function(t){return t[0]=0,t[1]=0,t[2]=0,t[3]=1,t[4]=0,t[5]=0,t[6]=0,t[7]=0,t},set:function(t,n,a,r,u,e,o,i,h){return t[0]=n,t[1]=a,t[2]=r,t[3]=u,t[4]=e,t[5]=o,t[6]=i,t[7]=h,t},getReal:bn,getDual:function(t,n){return t[0]=n[4],t[1]=n[5],t[2]=n[6],t[3]=n[7],t},setReal:mn,setDual:function(t,n){return t[4]=n[0],t[5]=n[1],t[6]=n[2],t[7]=n[3],t},getTranslation:function(t,n){var a=n[4],r=n[5],u=n[6],e=n[7],o=-n[0],i=-n[1],h=-n[2],c=n[3];return t[0]=2*(a*c+e*o+r*h-u*i),t[1]=2*(r*c+e*i+u*o-a*h),t[2]=2*(u*c+e*h+a*i-r*o),t},translate:function(t,n,a){var r=n[0],u=n[1],e=n[2],o=n[3],i=.5*a[0],h=.5*a[1],c=.5*a[2],s=n[4],M=n[5],f=n[6],l=n[7];return t[0]=r,t[1]=u,t[2]=e,t[3]=o,t[4]=o*i+u*c-e*h+s,t[5]=o*h+e*i-r*c+M,t[6]=o*c+r*h-u*i+f,t[7]=-r*i-u*h-e*c+l,t},rotateX:function(t,n,a){var r=-n[0],u=-n[1],e=-n[2],o=n[3],i=n[4],h=n[5],c=n[6],s=n[7],M=i*o+s*r+h*e-c*u,f=h*o+s*u+c*r-i*e,l=c*o+s*e+i*u-h*r,v=s*o-i*r-h*u-c*e;return Dt(t,n,a),r=t[0],u=t[1],e=t[2],o=t[3],t[4]=M*o+v*r+f*e-l*u,t[5]=f*o+v*u+l*r-M*e,t[6]=l*o+v*e+M*u-f*r,t[7]=v*o-M*r-f*u-l*e,t},rotateY:function(t,n,a){var r=-n[0],u=-n[1],e=-n[2],o=n[3],i=n[4],h=n[5],c=n[6],s=n[7],M=i*o+s*r+h*e-c*u,f=h*o+s*u+c*r-i*e,l=c*o+s*e+i*u-h*r,v=s*o-i*r-h*u-c*e;return Ft(t,n,a),r=t[0],u=t[1],e=t[2],o=t[3],t[4]=M*o+v*r+f*e-l*u,t[5]=f*o+v*u+l*r-M*e,t[6]=l*o+v*e+M*u-f*r,t[7]=v*o-M*r-f*u-l*e,t},rotateZ:function(t,n,a){var r=-n[0],u=-n[1],e=-n[2],o=n[3],i=n[4],h=n[5],c=n[6],s=n[7],M=i*o+s*r+h*e-c*u,f=h*o+s*u+c*r-i*e,l=c*o+s*e+i*u-h*r,v=s*o-i*r-h*u-c*e;return It(t,n,a),r=t[0],u=t[1],e=t[2],o=t[3],t[4]=M*o+v*r+f*e-l*u,t[5]=f*o+v*u+l*r-M*e,t[6]=l*o+v*e+M*u-f*r,t[7]=v*o-M*r-f*u-l*e,t},rotateByQuatAppend:function(t,n,a){var r=a[0],u=a[1],e=a[2],o=a[3],i=n[0],h=n[1],c=n[2],s=n[3];return t[0]=i*o+s*r+h*e-c*u,t[1]=h*o+s*u+c*r-i*e,t[2]=c*o+s*e+i*u-h*r,t[3]=s*o-i*r-h*u-c*e,i=n[4],h=n[5],c=n[6],s=n[7],t[4]=i*o+s*r+h*e-c*u,t[5]=h*o+s*u+c*r-i*e,t[6]=c*o+s*e+i*u-h*r,t[7]=s*o-i*r-h*u-c*e,t},rotateByQuatPrepend:function(t,n,a){var r=n[0],u=n[1],e=n[2],o=n[3],i=a[0],h=a[1],c=a[2],s=a[3];return t[0]=r*s+o*i+u*c-e*h,t[1]=u*s+o*h+e*i-r*c,t[2]=e*s+o*c+r*h-u*i,t[3]=o*s-r*i-u*h-e*c,i=a[4],h=a[5],c=a[6],s=a[7],t[4]=r*s+o*i+u*c-e*h,t[5]=u*s+o*h+e*i-r*c,t[6]=e*s+o*c+r*h-u*i,t[7]=o*s-r*i-u*h-e*c,t},rotateAroundAxis:function(t,a,r,u){if(Math.abs(u)<n)return vn(t,a);var e=Math.hypot(r[0],r[1],r[2]);u*=.5;var o=Math.sin(u),i=o*r[0]/e,h=o*r[1]/e,c=o*r[2]/e,s=Math.cos(u),M=a[0],f=a[1],l=a[2],v=a[3];t[0]=M*s+v*i+f*c-l*h,t[1]=f*s+v*h+l*i-M*c,t[2]=l*s+v*c+M*h-f*i,t[3]=v*s-M*i-f*h-l*c;var b=a[4],m=a[5],d=a[6],p=a[7];return t[4]=b*s+p*i+m*c-d*h,t[5]=m*s+p*h+d*i-b*c,t[6]=d*s+p*c+b*h-m*i,t[7]=p*s-b*i-m*h-d*c,t},add:function(t,n,a){return t[0]=n[0]+a[0],t[1]=n[1]+a[1],t[2]=n[2]+a[2],t[3]=n[3]+a[3],t[4]=n[4]+a[4],t[5]=n[5]+a[5],t[6]=n[6]+a[6],t[7]=n[7]+a[7],t},multiply:dn,mul:pn,scale:function(t,n,a){return t[0]=n[0]*a,t[1]=n[1]*a,t[2]=n[2]*a,t[3]=n[3]*a,t[4]=n[4]*a,t[5]=n[5]*a,t[6]=n[6]*a,t[7]=n[7]*a,t},dot:xn,lerp:function(t,n,a,r){var u=1-r;return xn(n,a)<0&&(r=-r),t[0]=n[0]*u+a[0]*r,t[1]=n[1]*u+a[1]*r,t[2]=n[2]*u+a[2]*r,t[3]=n[3]*u+a[3]*r,t[4]=n[4]*u+a[4]*r,t[5]=n[5]*u+a[5]*r,t[6]=n[6]*u+a[6]*r,t[7]=n[7]*u+a[7]*r,t},invert:function(t,n){var a=gn(n);return t[0]=-n[0]/a,t[1]=-n[1]/a,t[2]=-n[2]/a,t[3]=n[3]/a,t[4]=-n[4]/a,t[5]=-n[5]/a,t[6]=-n[6]/a,t[7]=n[7]/a,t},conjugate:function(t,n){return t[0]=-n[0],t[1]=-n[1],t[2]=-n[2],t[3]=n[3],t[4]=-n[4],t[5]=-n[5],t[6]=-n[6],t[7]=n[7],t},length:yn,len:qn,squaredLength:gn,sqrLen:_n,normalize:function(t,n){var a=gn(n);if(a>0){a=Math.sqrt(a);var r=n[0]/a,u=n[1]/a,e=n[2]/a,o=n[3]/a,i=n[4],h=n[5],c=n[6],s=n[7],M=r*i+u*h+e*c+o*s;t[0]=r,t[1]=u,t[2]=e,t[3]=o,t[4]=(i-r*M)/a,t[5]=(h-u*M)/a,t[6]=(c-e*M)/a,t[7]=(s-o*M)/a}return t},str:function(t){return"quat2("+t[0]+", "+t[1]+", "+t[2]+", "+t[3]+", "+t[4]+", "+t[5]+", "+t[6]+", "+t[7]+")"},exactEquals:function(t,n){return t[0]===n[0]&&t[1]===n[1]&&t[2]===n[2]&&t[3]===n[3]&&t[4]===n[4]&&t[5]===n[5]&&t[6]===n[6]&&t[7]===n[7]},equals:function(t,a){var r=t[0],u=t[1],e=t[2],o=t[3],i=t[4],h=t[5],c=t[6],s=t[7],M=a[0],f=a[1],l=a[2],v=a[3],b=a[4],m=a[5],d=a[6],p=a[7];return Math.abs(r-M)<=n*Math.max(1,Math.abs(r),Math.abs(M))&&Math.abs(u-f)<=n*Math.max(1,Math.abs(u),Math.abs(f))&&Math.abs(e-l)<=n*Math.max(1,Math.abs(e),Math.abs(l))&&Math.abs(o-v)<=n*Math.max(1,Math.abs(o),Math.abs(v))&&Math.abs(i-b)<=n*Math.max(1,Math.abs(i),Math.abs(b))&&Math.abs(h-m)<=n*Math.max(1,Math.abs(h),Math.abs(m))&&Math.abs(c-d)<=n*Math.max(1,Math.abs(c),Math.abs(d))&&Math.abs(s-p)<=n*Math.max(1,Math.abs(s),Math.abs(p))}});function wn(){var t=new a(2);return a!=Float32Array&&(t[0]=0,t[1]=0),t}function zn(t,n,a){return t[0]=n[0]-a[0],t[1]=n[1]-a[1],t}function Rn(t,n,a){return t[0]=n[0]*a[0],t[1]=n[1]*a[1],t}function On(t,n,a){return t[0]=n[0]/a[0],t[1]=n[1]/a[1],t}function jn(t,n){var a=n[0]-t[0],r=n[1]-t[1];return Math.hypot(a,r)}function En(t,n){var a=n[0]-t[0],r=n[1]-t[1];return a*a+r*r}function Pn(t){var n=t[0],a=t[1];return Math.hypot(n,a)}function Tn(t){var n=t[0],a=t[1];return n*n+a*a}var Sn=Pn,Dn=zn,Fn=Rn,In=On,Ln=jn,Vn=En,kn=Tn,Qn=function(){var t=wn();return function(n,a,r,u,e,o){var i,h;for(a||(a=2),r||(r=0),h=u?Math.min(u*a+r,n.length):n.length,i=r;i<h;i+=a)t[0]=n[i],t[1]=n[i+1],e(t,t,o),n[i]=t[0],n[i+1]=t[1];return n}}(),Yn=Object.freeze({__proto__:null,create:wn,clone:function(t){var n=new a(2);return n[0]=t[0],n[1]=t[1],n},fromValues:function(t,n){var r=new a(2);return r[0]=t,r[1]=n,r},copy:function(t,n){return t[0]=n[0],t[1]=n[1],t},set:function(t,n,a){return t[0]=n,t[1]=a,t},add:function(t,n,a){return t[0]=n[0]+a[0],t[1]=n[1]+a[1],t},subtract:zn,multiply:Rn,divide:On,ceil:function(t,n){return t[0]=Math.ceil(n[0]),t[1]=Math.ceil(n[1]),t},floor:function(t,n){return t[0]=Math.floor(n[0]),t[1]=Math.floor(n[1]),t},min:function(t,n,a){return t[0]=Math.min(n[0],a[0]),t[1]=Math.min(n[1],a[1]),t},max:function(t,n,a){return t[0]=Math.max(n[0],a[0]),t[1]=Math.max(n[1],a[1]),t},round:function(t,n){return t[0]=Math.round(n[0]),t[1]=Math.round(n[1]),t},scale:function(t,n,a){return t[0]=n[0]*a,t[1]=n[1]*a,t},scaleAndAdd:function(t,n,a,r){return t[0]=n[0]+a[0]*r,t[1]=n[1]+a[1]*r,t},distance:jn,squaredDistance:En,length:Pn,squaredLength:Tn,negate:function(t,n){return t[0]=-n[0],t[1]=-n[1],t},inverse:function(t,n){return t[0]=1/n[0],t[1]=1/n[1],t},normalize:function(t,n){var a=n[0],r=n[1],u=a*a+r*r;return u>0&&(u=1/Math.sqrt(u)),t[0]=n[0]*u,t[1]=n[1]*u,t},dot:function(t,n){return t[0]*n[0]+t[1]*n[1]},cross:function(t,n,a){var r=n[0]*a[1]-n[1]*a[0];return t[0]=t[1]=0,t[2]=r,t},lerp:function(t,n,a,r){var u=n[0],e=n[1];return t[0]=u+r*(a[0]-u),t[1]=e+r*(a[1]-e),t},random:function(t,n){n=n||1;var a=2*r()*Math.PI;return t[0]=Math.cos(a)*n,t[1]=Math.sin(a)*n,t},transformMat2:function(t,n,a){var r=n[0],u=n[1];return t[0]=a[0]*r+a[2]*u,t[1]=a[1]*r+a[3]*u,t},transformMat2d:function(t,n,a){var r=n[0],u=n[1];return t[0]=a[0]*r+a[2]*u+a[4],t[1]=a[1]*r+a[3]*u+a[5],t},transformMat3:function(t,n,a){var r=n[0],u=n[1];return t[0]=a[0]*r+a[3]*u+a[6],t[1]=a[1]*r+a[4]*u+a[7],t},transformMat4:function(t,n,a){var r=n[0],u=n[1];return t[0]=a[0]*r+a[4]*u+a[12],t[1]=a[1]*r+a[5]*u+a[13],t},rotate:function(t,n,a,r){var u=n[0]-a[0],e=n[1]-a[1],o=Math.sin(r),i=Math.cos(r);return t[0]=u*i-e*o+a[0],t[1]=u*o+e*i+a[1],t},angle:function(t,n){var a=t[0],r=t[1],u=n[0],e=n[1],o=Math.sqrt((a*a+r*r)*(u*u+e*e)),i=o&&(a*u+r*e)/o;return Math.acos(Math.min(Math.max(i,-1),1))},zero:function(t){return t[0]=0,t[1]=0,t},str:function(t){return"vec2("+t[0]+", "+t[1]+")"},exactEquals:function(t,n){return t[0]===n[0]&&t[1]===n[1]},equals:function(t,a){var r=t[0],u=t[1],e=a[0],o=a[1];return Math.abs(r-e)<=n*Math.max(1,Math.abs(r),Math.abs(e))&&Math.abs(u-o)<=n*Math.max(1,Math.abs(u),Math.abs(o))},len:Sn,sub:Dn,mul:Fn,div:In,dist:Ln,sqrDist:Vn,sqrLen:kn,forEach:Qn});t.glMatrix=e,t.mat2=s,t.mat2d=b,t.mat3=q,t.mat4=F,t.quat=fn,t.quat2=An,t.vec2=Yn,t.vec3=rt,t.vec4=Et,Object.defineProperty(t,"__esModule",{value:!0})}));
diff --git a/static/foil/libwebgl.js b/static/foil/libwebgl.js
new file mode 100644
index 0000000..9163324
--- /dev/null
+++ b/static/foil/libwebgl.js
@@ -0,0 +1,1064 @@
+/** Fetch one text resource synchronously during demo initialization. */
+function loadTextResource(url)
+{
+    const request = new XMLHttpRequest();
+    request.open("GET", url, false);
+    request.send(null);
+    if(request.status != 200 && request.status != 0)
+    {
+        throw(new Error(`Failed to load ${url}: HTTP ${request.status}`));
+    }
+    return request.responseText;
+}
+
+/** Insert validated compile-time numeric definitions after GLSL #version. */
+function defineShaderConstants(source, definitions)
+{
+    const version_end = source.indexOf("\n");
+    if(!source.startsWith("#version ") || version_end < 0)
+    {
+        throw(new Error("Shader source does not begin with #version."));
+    }
+
+    let definition_source = "";
+    for(const [name, value] of Object.entries(definitions))
+    {
+        if(!/^[A-Z][A-Z0-9_]*$/.test(name) ||
+           !Number.isFinite(value) || value < 0.0)
+        {
+            throw(new Error(`Invalid shader definition: ${name}=${value}`));
+        }
+        definition_source += `#define ${name} ${value}\n`;
+    }
+    return source.slice(0, version_end + 1) + definition_source
+         + source.slice(version_end + 1);
+}
+
+/** Compile one shader and include its URL in any diagnostic. */
+function compileShader(gl, source, shader_type, url)
+{
+    const shader = gl.createShader(shader_type);
+    gl.shaderSource(shader, source);
+    gl.compileShader(shader);
+    if(!gl.getShaderParameter(shader, gl.COMPILE_STATUS))
+    {
+        const message = gl.getShaderInfoLog(shader);
+        gl.deleteShader(shader);
+        throw(new Error(`Failed to compile ${url}: ${message}`));
+    }
+    return shader;
+}
+
+/** Link two compiled shaders into one WebGL program. */
+function linkProgram(gl, vertex_shader, fragment_shader,
+                     vertex_url, fragment_url)
+{
+    const program = gl.createProgram();
+    gl.attachShader(program, vertex_shader);
+    gl.attachShader(program, fragment_shader);
+    gl.linkProgram(program);
+    if(!gl.getProgramParameter(program, gl.LINK_STATUS))
+    {
+        const message = gl.getProgramInfoLog(program);
+        gl.deleteProgram(program);
+        throw(new Error(
+            `Failed to link ${vertex_url} with ${fragment_url}: ${message}`));
+    }
+    return program;
+}
+
+/** Own a linked shader program and cache required variable locations. */
+class ShaderProgram
+{
+    /** Compile and link a program from vertex and fragment source URLs. */
+    constructor(gl, vertex_url, fragment_url, fragment_definitions = {})
+    {
+        const vertex_shader = compileShader(
+            gl, loadTextResource(vertex_url), gl.VERTEX_SHADER, vertex_url);
+        const fragment_source = defineShaderConstants(
+            loadTextResource(fragment_url), fragment_definitions);
+        const fragment_shader = compileShader(
+            gl, fragment_source, gl.FRAGMENT_SHADER, fragment_url);
+        this.program = linkProgram(
+            gl, vertex_shader, fragment_shader, vertex_url, fragment_url);
+        gl.deleteShader(vertex_shader);
+        gl.deleteShader(fragment_shader);
+
+        this.attribute_locations = new Map();
+        this.uniform_locations = new Map();
+        this.gl = gl;
+    }
+
+    /** Bind this program for subsequent drawing. */
+    use()
+    {
+        this.gl.useProgram(this.program);
+    }
+
+    /** Return a required cached uniform location or throw. */
+    uniform(name)
+    {
+        if(!this.uniform_locations.has(name))
+        {
+            const location = this.gl.getUniformLocation(this.program, name);
+            this.uniform_locations.set(name, location);
+        }
+        const location = this.uniform_locations.get(name);
+        if(location == null)
+        {
+            throw(new Error(`Required uniform not found: ${name}`));
+        }
+        return location;
+    }
+
+    /** Return a cached uniform location, or null when it is not active. */
+    optionalUniform(name)
+    {
+        if(!this.uniform_locations.has(name))
+        {
+            const location = this.gl.getUniformLocation(this.program, name);
+            this.uniform_locations.set(name, location);
+        }
+        return this.uniform_locations.get(name);
+    }
+
+    /** Return a required cached attribute location or throw. */
+    attribute(name)
+    {
+        if(!this.attribute_locations.has(name))
+        {
+            const location = this.gl.getAttribLocation(this.program, name);
+            if(location < 0)
+            {
+                throw(new Error(`Required attribute not found: ${name}`));
+            }
+            this.attribute_locations.set(name, location);
+        }
+        return this.attribute_locations.get(name);
+    }
+}
+
+/** Resize the drawing buffer and viewport to the displayed canvas size. */
+function resizeCanvas(canvas, gl)
+{
+    const width = canvas.clientWidth;
+    const height = canvas.clientHeight;
+    if(canvas.width != width || canvas.height != height)
+    {
+        canvas.width = width;
+        canvas.height = height;
+    }
+    gl.viewport(0, 0, width, height);
+}
+
+/** Own a complete set of buffered vertex inputs for a drawable. */
+class VertexArray
+{
+    /** Create an initially empty vertex array. */
+    constructor(gl)
+    {
+        this.vertex_array = gl.createVertexArray();
+        this.buffers = [];
+        this.gl = gl;
+    }
+
+    /** Add one buffered attribute to this vertex array. */
+    addAttribute(program, attribute_name, data, {
+        component_count = 3,
+        num_type = this.gl.FLOAT,
+        normalize = false,
+        stride = 0,
+        offset = 0,
+    })
+    {
+        const buffer = this.gl.createBuffer();
+        const attribute_ref = program.attribute(attribute_name);
+
+        this.gl.bindVertexArray(this.vertex_array);
+        this.gl.bindBuffer(this.gl.ARRAY_BUFFER, buffer);
+        this.gl.bufferData(this.gl.ARRAY_BUFFER, data, this.gl.STATIC_DRAW);
+        this.gl.enableVertexAttribArray(attribute_ref);
+        this.gl.vertexAttribPointer(attribute_ref, component_count, num_type,
+                                    normalize, stride, offset);
+        this.buffers.push(buffer);
+    }
+
+    /** Bind this vertex array for drawing. */
+    use()
+    {
+        this.gl.bindVertexArray(this.vertex_array);
+    }
+}
+
+/** Color-space behavior for image texture resources. */
+const TEXTURE_ROLE = Object.freeze({
+    COLOR: "color",
+    DATA: "data",
+});
+
+/** Return decode transforms for an uploaded browser image. */
+function bitmapDecodeOptions(flip_y, role)
+{
+    return {
+        imageOrientation: flip_y ? "flipY" : "none",
+        premultiplyAlpha: "none",
+        colorSpaceConversion:
+            role == TEXTURE_ROLE.DATA ? "none" : "default",
+    };
+}
+
+/** Shader material modes shared by every declarative material. */
+const MATERIAL_KIND = Object.freeze({
+    PHYSICAL_FOIL: 0,
+    SOLID_COLOR: 1,
+});
+
+/** Maximum decoded pixels accepted for one browser image texture. */
+const MAX_CARD_TEXTURE_PIXELS = 24 * 1024 * 1024;
+
+/** Own one asynchronously loaded image texture. */
+class Texture
+{
+    /** Create a texture and start loading its image. */
+    constructor(gl, url, {
+        cross_origin = null,
+        flip_y = false,
+        placeholder_color = null,
+        role = TEXTURE_ROLE.COLOR,
+    } = {})
+    {
+        if(!Object.values(TEXTURE_ROLE).includes(role))
+        {
+            throw(new Error(`Unknown texture role: ${role}`));
+        }
+        if(placeholder_color == null)
+        {
+            placeholder_color = role == TEXTURE_ROLE.DATA
+                ? [0, 0, 0, 0]
+                : [0, 0, 255, 255];
+        }
+
+        this.texture = gl.createTexture();
+        this.load_listeners = [];
+        this.width = 0;
+        this.height = 0;
+        this.flip_y = flip_y;
+        this.role = role;
+        this.url = url;
+        this.cross_origin = cross_origin;
+        this.gl = gl;
+        this.disposed = false;
+        this.is_constant = false;
+        this.ready = new Promise(function storeTextureCompletion(
+            resolve, reject)
+        {
+            this.resolve_load = resolve;
+            this.reject_load = reject;
+        }.bind(this));
+
+        gl.activeTexture(gl.TEXTURE0);
+        gl.bindTexture(gl.TEXTURE_2D, this.texture);
+        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
+        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
+        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
+        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
+        gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, 1, 1, 0, gl.RGBA,
+                      gl.UNSIGNED_BYTE,
+                      new Uint8Array(placeholder_color));
+
+        this.image = null;
+        if(url != null)
+        {
+            this.image = new Image();
+            this.handle_load = this.handleLoad.bind(this);
+            this.handle_error = this.handleError.bind(this);
+            this.image.addEventListener("load", this.handle_load);
+            this.image.addEventListener("error", this.handle_error);
+            if(cross_origin != null)
+            {
+                this.image.crossOrigin = cross_origin;
+            }
+            this.image.src = url;
+        }
+    }
+
+    /** Decode and upload one ephemeral browser file without creating a URL. */
+    static async fromFile(gl, file, options = {})
+    {
+        // WebGL ignores UNPACK_FLIP_Y_WEBGL for ImageBitmap sources. Apply
+        // the requested orientation while creating the bitmap, then prevent
+        // Texture.upload() from attempting the same transformation again.
+        const upload_options = Object.assign({}, options, {flip_y: false});
+        const texture = new Texture(gl, null, upload_options);
+        let bitmap = null;
+        try
+        {
+            bitmap = await createImageBitmap(
+                file, bitmapDecodeOptions(options.flip_y, options.role));
+            texture.upload(bitmap, bitmap.width, bitmap.height);
+            return texture;
+        }
+        catch(error)
+        {
+            const wrapped_error = new Error(
+                `Failed to process ${file.name}: ${error.message}`);
+            texture.fail(wrapped_error);
+            texture.ready.catch(function consumeFailedUpload() {});
+            texture.dispose();
+            throw(wrapped_error);
+        }
+        finally
+        {
+            if(bitmap != null)
+            {
+                bitmap.close();
+            }
+        }
+    }
+
+    /** Create a ready one-pixel texture from an explicit RGBA value. */
+    static constant(gl, color, options = {})
+    {
+        const is_valid = Array.isArray(color) && color.length == 4
+            && color.every(function validateTextureByte(value)
+            {
+                return Number.isInteger(value) && value >= 0 && value <= 255;
+            });
+        if(!is_valid)
+        {
+            throw(new Error(
+                "Constant texture color must contain four bytes."));
+        }
+        const texture_options = Object.assign(
+            {}, options, {placeholder_color: color});
+        const texture = new Texture(gl, null, texture_options);
+        texture.is_constant = true;
+        texture.width = 1;
+        texture.height = 1;
+        texture.resolve_load(texture);
+        return texture;
+    }
+
+    /** Replace a ready constant texture texel without reallocating it. */
+    setConstantColor(color)
+    {
+        const is_valid = Array.isArray(color) && color.length == 4
+            && color.every(function validateTextureByte(value)
+            {
+                return Number.isInteger(value)
+                    && value >= 0 && value <= 255;
+            });
+        if(!this.is_constant || !is_valid || this.disposed)
+        {
+            throw(new Error(
+                "Only a live constant texture accepts four color bytes."));
+        }
+
+        const gl = this.gl;
+        const previous_active_texture = gl.getParameter(gl.ACTIVE_TEXTURE);
+        gl.activeTexture(gl.TEXTURE0);
+        const previous_texture = gl.getParameter(gl.TEXTURE_BINDING_2D);
+        try
+        {
+            gl.bindTexture(gl.TEXTURE_2D, this.texture);
+            gl.texSubImage2D(
+                gl.TEXTURE_2D, 0, 0, 0, 1, 1, gl.RGBA,
+                gl.UNSIGNED_BYTE, new Uint8Array(color));
+        }
+        finally
+        {
+            gl.bindTexture(gl.TEXTURE_2D, previous_texture);
+            gl.activeTexture(previous_active_texture);
+        }
+    }
+
+    /** Upload one decoded source while preserving global pixel-unpack state. */
+    upload(source, width, height)
+    {
+        const gl = this.gl;
+        const max_texture_size = gl.getParameter(gl.MAX_TEXTURE_SIZE);
+        if(!Number.isInteger(width) || !Number.isInteger(height)
+           || width <= 0 || height <= 0
+           || width > max_texture_size || height > max_texture_size
+           || width * height > MAX_CARD_TEXTURE_PIXELS)
+        {
+            throw(new Error(
+                `Texture ${this.url || "upload"} has invalid dimensions `
+                + `${width}x`
+                + `${height}; maximum dimension is ${max_texture_size} and `
+                + `maximum area is ${MAX_CARD_TEXTURE_PIXELS} pixels.`));
+        }
+        const previous_flip = gl.getParameter(gl.UNPACK_FLIP_Y_WEBGL);
+        const previous_premultiply = gl.getParameter(
+            gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL);
+        const previous_color_space = gl.getParameter(
+            gl.UNPACK_COLORSPACE_CONVERSION_WEBGL);
+        const previous_active_texture = gl.getParameter(gl.ACTIVE_TEXTURE);
+        gl.activeTexture(gl.TEXTURE0);
+        const previous_texture = gl.getParameter(gl.TEXTURE_BINDING_2D);
+
+        try
+        {
+            gl.bindTexture(gl.TEXTURE_2D, this.texture);
+            gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, this.flip_y);
+            gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
+            if(this.role == TEXTURE_ROLE.DATA)
+            {
+                gl.pixelStorei(
+                    gl.UNPACK_COLORSPACE_CONVERSION_WEBGL, gl.NONE);
+            }
+            gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, gl.RGBA,
+                          gl.UNSIGNED_BYTE, source);
+
+            if(this.role == TEXTURE_ROLE.COLOR)
+            {
+                gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER,
+                                 gl.LINEAR_MIPMAP_LINEAR);
+                gl.generateMipmap(gl.TEXTURE_2D);
+            }
+        }
+        finally
+        {
+            gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, previous_flip);
+            gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL,
+                           previous_premultiply);
+            gl.pixelStorei(gl.UNPACK_COLORSPACE_CONVERSION_WEBGL,
+                           previous_color_space);
+            gl.bindTexture(gl.TEXTURE_2D, previous_texture);
+            gl.activeTexture(previous_active_texture);
+        }
+
+        this.width = width;
+        this.height = height;
+        this.resolve_load(this);
+        for(const listener of this.load_listeners)
+        {
+            listener(this);
+        }
+    }
+
+    /** Upload a successfully loaded URL image or reject its ready promise. */
+    handleLoad(event)
+    {
+        try
+        {
+            this.upload(event.target, event.target.naturalWidth,
+                        event.target.naturalHeight);
+        }
+        catch(error)
+        {
+            this.fail(error);
+        }
+    }
+
+    /** Report an image-load failure with its complete URL. */
+    handleError()
+    {
+        const cors_hint = this.cross_origin == null
+            ? ""
+            : " The image server must allow cross-origin access (CORS).";
+        this.fail(new Error(
+            `Failed to load texture: ${this.url}.${cors_hint}`));
+    }
+
+    /** Reject this texture's completion promise with one load error. */
+    fail(error)
+    {
+        this.reject_load(error);
+    }
+
+    /** Run a callback after this texture has loaded. */
+    onLoad(listener)
+    {
+        this.load_listeners.push(listener);
+        if(this.width > 0 && this.height > 0)
+        {
+            listener(this);
+        }
+    }
+
+    /** Bind this texture to a required sampler uniform. */
+    use(program, uniform_name, unit)
+    {
+        this.gl.activeTexture(this.gl.TEXTURE0 + unit);
+        this.gl.bindTexture(this.gl.TEXTURE_2D, this.texture);
+        this.gl.uniform1i(program.uniform(uniform_name), unit);
+    }
+
+    /** Release this texture's GPU allocation exactly once. */
+    dispose()
+    {
+        if(this.image != null)
+        {
+            this.image.removeEventListener("load", this.handle_load);
+            this.image.removeEventListener("error", this.handle_error);
+        }
+        if(!this.disposed)
+        {
+            this.gl.deleteTexture(this.texture);
+            this.disposed = true;
+        }
+        this.image = null;
+    }
+}
+
+/** Own the D65-weighted CIE XYZ floating-point lookup texture. */
+class SpectralLut
+{
+    /** Load, validate, and upload the binary spectral table. */
+    constructor(gl, url)
+    {
+        this.texture = gl.createTexture();
+        this.load_listeners = [];
+        this.loaded = false;
+        this.failed = false;
+        this.pending_reported = false;
+        this.url = url;
+        this.gl = gl;
+
+        gl.activeTexture(gl.TEXTURE0);
+        gl.bindTexture(gl.TEXTURE_2D, this.texture);
+        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
+        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
+        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
+        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
+        gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA32F, 1, 1, 0, gl.RGBA,
+                      gl.FLOAT, new Float32Array([0.0, 0.0, 0.0, 0.0]));
+
+        const request = new XMLHttpRequest();
+        request.open("GET", url, true);
+        request.responseType = "arraybuffer";
+        request.addEventListener("load", this.handleLoad.bind(this));
+        request.addEventListener("error", this.handleError.bind(this));
+        request.send(null);
+        this.request = request;
+    }
+
+    /** Validate and upload a completed binary request. */
+    handleLoad(event)
+    {
+        const request = event.target;
+        if(request.status != 200 && request.status != 0)
+        {
+            this.failed = true;
+            throw(new Error(
+                `Failed to load ${this.url}: HTTP ${request.status}`));
+        }
+        if(request.response == null || request.response.byteLength != 6416)
+        {
+            this.failed = true;
+            const actual_size = request.response == null
+                ? 0
+                : request.response.byteLength;
+            throw(new Error(
+                `Malformed ${this.url}: expected 6416 bytes, got `
+                + actual_size));
+        }
+
+        const gl = this.gl;
+        gl.bindTexture(gl.TEXTURE_2D, this.texture);
+        gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA32F, 401, 1, 0,
+                      gl.RGBA, gl.FLOAT,
+                      new Float32Array(request.response));
+        this.loaded = true;
+        for(const listener of this.load_listeners)
+        {
+            listener(this);
+        }
+    }
+
+    /** Report a binary-resource request failure. */
+    handleError()
+    {
+        this.failed = true;
+        throw(new Error(`Failed to load spectral table: ${this.url}`));
+    }
+
+    /** Run a callback after successful validation and upload. */
+    onLoad(listener)
+    {
+        this.load_listeners.push(listener);
+        if(this.loaded)
+        {
+            listener(this);
+        }
+    }
+
+    /** Bind the spectral table to a required sampler uniform. */
+    use(program, texture_unit)
+    {
+        if(!this.loaded && !this.failed && !this.pending_reported)
+        {
+            console.info(`Spectral table pending: ${this.url}`);
+            this.pending_reported = true;
+        }
+        this.gl.activeTexture(this.gl.TEXTURE0 + texture_unit);
+        this.gl.bindTexture(this.gl.TEXTURE_2D, this.texture);
+        this.gl.uniform1i(program.uniform("u_spectral_xyz"), texture_unit);
+    }
+
+    /** Release the lookup texture and any pending resource request. */
+    dispose()
+    {
+        if(this.request != null && !this.loaded && !this.failed)
+        {
+            this.request.abort();
+        }
+        this.gl.deleteTexture(this.texture);
+        this.request = null;
+    }
+}
+
+/** Own artwork, physical foil controls, and the spectral lookup. */
+class PhysicalFoilMaterial
+{
+    /** Load or adopt every resource required by a physical foil card. */
+    constructor(gl, artwork, foil_control, spectral_lut)
+    {
+        this.artwork = artwork instanceof Texture
+            ? artwork
+            : new Texture(gl, artwork, {flip_y: true});
+        this.foil_control = foil_control instanceof Texture
+            ? foil_control
+            : new Texture(gl, foil_control, {
+                flip_y: true,
+                role: TEXTURE_ROLE.DATA,
+            });
+        this.owns_spectral_lut = !(spectral_lut instanceof SpectralLut);
+        this.spectral_lut = this.owns_spectral_lut
+            ? new SpectralLut(gl, spectral_lut)
+            : spectral_lut;
+        this.gl = gl;
+        this.ready = Promise.all([
+            this.artwork.ready,
+            this.foil_control.ready,
+        ]).then(function finishLoadedMaterial()
+        {
+            return this;
+        }.bind(this));
+    }
+
+    /** Create a file material, using uniform controls when no map exists. */
+    static async fromFiles(gl, artwork_file, control_file, control_color,
+                           spectral_lut)
+    {
+        let artwork = null;
+        let foil_control = null;
+        try
+        {
+            artwork = await Texture.fromFile(gl, artwork_file, {
+                flip_y: true,
+            });
+            foil_control = control_file == null
+                ? Texture.constant(gl, control_color, {
+                    role: TEXTURE_ROLE.DATA,
+                })
+                : await Texture.fromFile(gl, control_file, {
+                    flip_y: true,
+                    role: TEXTURE_ROLE.DATA,
+                });
+            const material = new PhysicalFoilMaterial(
+                gl, artwork, foil_control, spectral_lut);
+            await material.ready;
+            return material;
+        }
+        catch(error)
+        {
+            if(artwork != null)
+            {
+                artwork.dispose();
+            }
+            if(foil_control != null)
+            {
+                foil_control.dispose();
+            }
+            throw(error);
+        }
+    }
+
+    /** Create a URL material, using uniform controls when no map exists. */
+    static async fromUrls(gl, artwork_url, control_url, control_color,
+                          spectral_lut)
+    {
+        const artwork = new Texture(gl, artwork_url, {
+            cross_origin: "anonymous",
+            flip_y: true,
+        });
+        const foil_control = control_url == null
+            ? Texture.constant(gl, control_color, {
+                role: TEXTURE_ROLE.DATA,
+            })
+            : new Texture(gl, control_url, {
+                cross_origin: "anonymous",
+                flip_y: true,
+                role: TEXTURE_ROLE.DATA,
+            });
+        const material = new PhysicalFoilMaterial(
+            gl, artwork, foil_control, spectral_lut);
+        try
+        {
+            await material.ready;
+            return material;
+        }
+        catch(error)
+        {
+            material.dispose();
+            throw(error);
+        }
+    }
+
+    /** Bind all physical card material resources. */
+    use(program)
+    {
+        this.gl.uniform1i(
+            program.uniform("u_material_kind"), MATERIAL_KIND.PHYSICAL_FOIL);
+        this.artwork.use(program, "u_artwork", 0);
+        this.foil_control.use(program, "u_foil_control", 1);
+        this.spectral_lut.use(program, 2);
+    }
+
+    /** Update uniform controls, returning false for image-backed controls. */
+    setUniformControl(control_color)
+    {
+        if(!this.foil_control.is_constant)
+        {
+            return false;
+        }
+        this.foil_control.setConstantColor(control_color);
+        return true;
+    }
+
+    /** Release owned artwork, controls, and any unshared spectral table. */
+    dispose()
+    {
+        this.artwork.dispose();
+        this.foil_control.dispose();
+        if(this.owns_spectral_lut)
+        {
+            this.spectral_lut.dispose();
+        }
+    }
+}
+
+/** Shade geometry with one opaque, non-foil sRGB color. */
+class SolidColorMaterial
+{
+    /** Create a validated solid-color material. */
+    constructor(gl, color_srgb)
+    {
+        const is_valid = Array.isArray(color_srgb)
+            && color_srgb.length == 3
+            && Number.isFinite(color_srgb[0])
+            && Number.isFinite(color_srgb[1])
+            && Number.isFinite(color_srgb[2])
+            && color_srgb[0] >= 0.0 && color_srgb[0] <= 1.0
+            && color_srgb[1] >= 0.0 && color_srgb[1] <= 1.0
+            && color_srgb[2] >= 0.0 && color_srgb[2] <= 1.0;
+        if(!is_valid)
+        {
+            throw(new Error("Invalid solid material color."));
+        }
+        this.color_srgb = color_srgb.slice();
+        this.gl = gl;
+    }
+
+    /** Select solid shading and upload the encoded sRGB color. */
+    use(program)
+    {
+        this.gl.uniform1i(
+            program.uniform("u_material_kind"), MATERIAL_KIND.SOLID_COLOR);
+        this.gl.uniform3fv(
+            program.uniform("u_solid_color_srgb"), this.color_srgb);
+    }
+}
+
+/** Return the cross product of two three-dimensional arrays. */
+function crossVector(left, right)
+{
+    return [
+        left[1] * right[2] - left[2] * right[1],
+        left[2] * right[0] - left[0] * right[2],
+        left[0] * right[1] - left[1] * right[0],
+    ];
+}
+
+/** Return a normalized vector or a caller-provided fallback. */
+function normalizeVector(value, fallback)
+{
+    const length = Math.hypot(value[0], value[1], value[2]);
+    if(length < 1e-8)
+    {
+        return fallback.slice();
+    }
+    return value.map(function divideVectorComponent(component)
+    {
+        return component / length;
+    });
+}
+
+/** Return whether a value is a finite three-dimensional numeric array. */
+function isFiniteVector3(value)
+{
+    return (Array.isArray(value) || ArrayBuffer.isView(value))
+        && value.length == 3
+        && value.every(Number.isFinite);
+}
+
+/** Calculate UV-aligned normals and tangent vectors for triangle data. */
+function calculateTangentFrames(positions, texcoords, source_normals,
+                                geometry_name)
+{
+    const normals = [];
+    const tangents = [];
+    let warned_degenerate_uv = false;
+
+    for(let vertex = 0; vertex < positions.length / 3; vertex += 3)
+    {
+        const position = [];
+        const uv = [];
+        const normal = [];
+        for(let local_vertex = 0; local_vertex < 3; ++local_vertex)
+        {
+            const position_offset = 3 * (vertex + local_vertex);
+            const uv_offset = 2 * (vertex + local_vertex);
+            position.push(positions.slice(position_offset,
+                                          position_offset + 3));
+            uv.push(texcoords.slice(uv_offset, uv_offset + 2));
+            if(source_normals != null)
+            {
+                normal.push(source_normals.slice(position_offset,
+                                                 position_offset + 3));
+            }
+        }
+
+        const edge_1 = position[1].map(
+            function subtractFirstPosition(value, index)
+            {
+                return value - position[0][index];
+            });
+        const edge_2 = position[2].map(
+            function subtractSecondPosition(value, index)
+            {
+                return value - position[0][index];
+            });
+        const face_normal = normalizeVector(crossVector(edge_1, edge_2),
+                                            [0.0, 1.0, 0.0]);
+        const uv_1 = [uv[1][0] - uv[0][0], uv[1][1] - uv[0][1]];
+        const uv_2 = [uv[2][0] - uv[0][0], uv[2][1] - uv[0][1]];
+        const determinant = uv_1[0] * uv_2[1] - uv_1[1] * uv_2[0];
+
+        let raw_tangent;
+        let raw_bitangent;
+        if(Math.abs(determinant) < 1e-8)
+        {
+            if(!warned_degenerate_uv)
+            {
+                console.warn(
+                    `Degenerate UV triangle in geometry ${geometry_name}`);
+                warned_degenerate_uv = true;
+            }
+            const reference = Math.abs(face_normal[2]) < 0.999
+                ? [0.0, 0.0, 1.0]
+                : [0.0, 1.0, 0.0];
+            raw_tangent = normalizeVector(
+                crossVector(reference, face_normal), [1.0, 0.0, 0.0]);
+            raw_bitangent = crossVector(face_normal, raw_tangent);
+        }
+        else
+        {
+            const inverse = 1.0 / determinant;
+            raw_tangent = edge_1.map(
+                function calculateTangent(value, index)
+                {
+                    return inverse
+                         * (uv_2[1] * value - uv_1[1] * edge_2[index]);
+                });
+            raw_bitangent = edge_1.map(
+                function calculateBitangent(value, index)
+                {
+                    return inverse
+                         * (-uv_2[0] * value + uv_1[0] * edge_2[index]);
+                });
+        }
+
+        for(let local_vertex = 0; local_vertex < 3; ++local_vertex)
+        {
+            const vertex_normal = normal.length > 0
+                ? normalizeVector(normal[local_vertex], face_normal)
+                : face_normal;
+            const normal_tangent = vertex_normal[0] * raw_tangent[0]
+                                 + vertex_normal[1] * raw_tangent[1]
+                                 + vertex_normal[2] * raw_tangent[2];
+            const orthogonal_tangent = raw_tangent.map(
+                function removeNormal(value, index)
+                {
+                    return value - normal_tangent * vertex_normal[index];
+                });
+            const tangent = normalizeVector(orthogonal_tangent,
+                                            [1.0, 0.0, 0.0]);
+            const handedness = crossVector(vertex_normal, tangent)
+                .reduce(function dotBitangent(total, value, index)
+                {
+                    return total + value * raw_bitangent[index];
+                }, 0.0) < 0.0 ? -1.0 : 1.0;
+            normals.push(...vertex_normal);
+            tangents.push(...tangent, handedness);
+        }
+    }
+    return {normals, tangents};
+}
+
+/** Own one drawable geometry and its material. */
+class Model
+{
+    /** Create a tangent-aware textured model from parsed OBJ geometry. */
+    constructor(gl, program, geometry, material)
+    {
+        const data = geometry.data;
+        const frame = calculateTangentFrames(
+            data.position, data.texcoord, data.normal, geometry.object);
+
+        this.vertex_array = new VertexArray(gl);
+        this.vertex_array.addAttribute(
+            program, "a_position", new Float32Array(data.position), {});
+        this.vertex_array.addAttribute(
+            program, "a_texcoord", new Float32Array(data.texcoord),
+            {component_count: 2});
+        this.vertex_array.addAttribute(
+            program, "a_normal", new Float32Array(frame.normals), {});
+        this.vertex_array.addAttribute(
+            program, "a_tangent", new Float32Array(frame.tangents),
+            {component_count: 4});
+        this.vertex_count = data.position.length / 3;
+        this.material = material;
+        this.gl = gl;
+    }
+}
+
+/** Store the drawable models in one scene. */
+class Scene
+{
+    /** Create a scene from an ordered model list. */
+    constructor(models)
+    {
+        this.models = models;
+    }
+}
+
+/** Represent a one-sided disk emitter with D65 spectral radiance. */
+class DiskLight
+{
+    /** Create a validated disk emitter in world space. */
+    constructor(position, normal, radius, radiance)
+    {
+        const normal_length = isFiniteVector3(normal)
+            ? Math.hypot(...normal)
+            : 0.0;
+        if(!isFiniteVector3(position) || normal_length < 1e-8
+           || !Number.isFinite(radius) || !Number.isFinite(radiance)
+           || radius <= 0.0 || radiance < 0.0)
+        {
+            throw(new Error("Disk-light parameters are invalid."));
+        }
+        this.position = position.slice();
+        this.normal = normalizeVector(normal, [0.0, 0.0, -1.0]);
+        const reference = Math.abs(this.normal[2]) < 0.999
+            ? [0.0, 0.0, 1.0]
+            : [0.0, 1.0, 0.0];
+        this.axis_x = normalizeVector(crossVector(reference, this.normal),
+                                      [1.0, 0.0, 0.0]);
+        this.axis_y = normalizeVector(crossVector(this.normal, this.axis_x),
+                                      [0.0, 1.0, 0.0]);
+        this.radius = radius;
+        this.radiance = radiance;
+    }
+
+    /** Transform and upload this disk light for the current view. */
+    use(program, view_matrix)
+    {
+        const vec3 = glMatrix.vec3;
+        const view_position = vec3.transformMat4(
+            vec3.create(), this.position, view_matrix);
+        const view_normal = vec3.transformMat3(
+            vec3.create(), this.normal, glMatrix.mat3.fromMat4(
+                glMatrix.mat3.create(), view_matrix));
+        const view_axis_x = vec3.transformMat3(
+            vec3.create(), this.axis_x, glMatrix.mat3.fromMat4(
+                glMatrix.mat3.create(), view_matrix));
+        const view_axis_y = vec3.transformMat3(
+            vec3.create(), this.axis_y, glMatrix.mat3.fromMat4(
+                glMatrix.mat3.create(), view_matrix));
+        const gl = program.gl;
+        gl.uniform3fv(program.uniform("u_light_position"), view_position);
+        gl.uniform3fv(program.uniform("u_light_normal"), view_normal);
+        gl.uniform3fv(program.uniform("u_light_axis_x"), view_axis_x);
+        gl.uniform3fv(program.uniform("u_light_axis_y"), view_axis_y);
+        gl.uniform1f(program.uniform("u_light_radius"), this.radius);
+        gl.uniform1f(program.uniform("u_light_radiance"), this.radiance);
+    }
+}
+
+/** Own frame-level WebGL state and draw scenes declaratively. */
+class Renderer
+{
+    /** Create a renderer for one WebGL context and shader program. */
+    constructor(gl, program)
+    {
+        this.canvas = gl.canvas;
+        this.program = program;
+        this.gl = gl;
+
+        gl.enable(gl.DEPTH_TEST);
+        gl.depthFunc(gl.LEQUAL);
+        gl.enable(gl.CULL_FACE);
+        gl.clearColor(0.0, 0.0, 0.0, 0.0);
+    }
+
+    /** Draw a scene under one disk light from supplied camera matrices. */
+    draw(scene, camera, light)
+    {
+        const mat3 = glMatrix.mat3;
+        const mat4 = glMatrix.mat4;
+        const model_view_matrix = mat4.multiply(
+            mat4.create(), camera.view_matrix, camera.model_matrix);
+        const normal_matrix = mat3.normalFromMat4(
+            mat3.create(), model_view_matrix);
+
+        resizeCanvas(this.canvas, this.gl);
+        this.gl.clear(this.gl.COLOR_BUFFER_BIT | this.gl.DEPTH_BUFFER_BIT);
+        this.program.use();
+        this.gl.uniformMatrix4fv(
+            this.program.uniform("u_projection"), false,
+            camera.projection_matrix);
+        this.gl.uniformMatrix4fv(
+            this.program.uniform("u_model_view"), false,
+            model_view_matrix);
+        this.gl.uniformMatrix3fv(
+            this.program.uniform("u_normal_matrix"), false, normal_matrix);
+        light.use(this.program, camera.view_matrix);
+
+        for(const model of scene.models)
+        {
+            model.vertex_array.use();
+            model.material.use(this.program);
+            this.gl.drawArrays(this.gl.TRIANGLES, 0, model.vertex_count);
+        }
+    }
+}
+
+if(typeof module != "undefined")
+{
+    module.exports = {
+        DiskLight,
+        MAX_CARD_TEXTURE_PIXELS,
+        MATERIAL_KIND,
+        PhysicalFoilMaterial,
+        ShaderProgram,
+        SolidColorMaterial,
+        TEXTURE_ROLE,
+        Texture,
+        bitmapDecodeOptions,
+        calculateTangentFrames,
+        defineShaderConstants,
+    };
+}
diff --git a/static/foil/obj.js b/static/foil/obj.js
new file mode 100644
index 0000000..0497b1d
--- /dev/null
+++ b/static/foil/obj.js
@@ -0,0 +1,386 @@
+// Copied from https://webgl2fundamentals.org/webgl/lessons/webgl-load-obj.html
+/** Parse OBJ text into nonindexed triangle geometry arrays. */
+function parseOBJ(text)
+{
+    // because indices are base 1 let's just fill in the 0th data
+    const objPositions = [[0, 0, 0]];
+    const objTexcoords = [[0, 0]];
+    const objNormals = [[0, 0, 0]];
+    const objColors = [[0, 0, 0]];
+
+    // same order as `f` indices
+    const objVertexData = [
+        objPositions,
+        objTexcoords,
+        objNormals,
+        objColors,
+    ];
+
+    // same order as `f` indices
+    let webglVertexData = [
+        [],   // positions
+        [],   // texcoords
+        [],   // normals
+        [],   // colors
+    ];
+
+    const materialLibs = [];
+    const geometries = [];
+    let geometry;
+    let groups = ['default'];
+    let material = 'default';
+    let object = 'default';
+
+    const noop = () => {};
+
+    function newGeometry() {
+        // If there is an existing geometry and it's
+        // not empty then start a new one.
+        if (geometry && geometry.data.position.length) {
+            geometry = undefined;
+        }
+    }
+
+    function setGeometry() {
+        if (!geometry) {
+            const position = [];
+            const texcoord = [];
+            const normal = [];
+            const color = [];
+            webglVertexData = [
+                position,
+                texcoord,
+                normal,
+                color,
+            ];
+            geometry = {
+                object,
+                groups,
+                material,
+                data: {
+                    position,
+                    texcoord,
+                    normal,
+                    color,
+                },
+            };
+            geometries.push(geometry);
+        }
+    }
+
+    function addVertex(vert) {
+        const ptn = vert.split('/');
+        ptn.forEach((objIndexStr, i) => {
+            if (!objIndexStr) {
+                return;
+            }
+            const objIndex = parseInt(objIndexStr);
+            const index = objIndex + (objIndex >= 0 ? 0 : objVertexData[i].length);
+            webglVertexData[i].push(...objVertexData[i][index]);
+            // if this is the position index (index 0) and we parsed
+            // vertex colors then copy the vertex colors to the webgl vertex color data
+            if (i === 0 && objColors.length > 1) {
+                geometry.data.color.push(...objColors[index]);
+            }
+        });
+    }
+
+    const keywords = {
+        v(parts) {
+            // if there are more than 3 values here they are vertex colors
+            if (parts.length > 3) {
+                objPositions.push(parts.slice(0, 3).map(parseFloat));
+                objColors.push(parts.slice(3).map(parseFloat));
+            } else {
+                objPositions.push(parts.map(parseFloat));
+            }
+        },
+        vn(parts) {
+            objNormals.push(parts.map(parseFloat));
+        },
+        vt(parts) {
+            // should check for missing v and extra w?
+            objTexcoords.push(parts.map(parseFloat));
+        },
+        f(parts) {
+            setGeometry();
+            const numTriangles = parts.length - 2;
+            for (let tri = 0; tri < numTriangles; ++tri) {
+                addVertex(parts[0]);
+                addVertex(parts[tri + 1]);
+                addVertex(parts[tri + 2]);
+            }
+        },
+        s: noop,    // smoothing group
+        mtllib(parts, unparsedArgs) {
+            // the spec says there can be multiple filenames here
+            // but many exist with spaces in a single filename
+            materialLibs.push(unparsedArgs);
+        },
+        usemtl(parts, unparsedArgs) {
+            material = unparsedArgs;
+            newGeometry();
+        },
+        g(parts) {
+            groups = parts;
+            newGeometry();
+        },
+        o(parts, unparsedArgs) {
+            object = unparsedArgs;
+            newGeometry();
+        },
+    };
+
+    const keywordRE = /(\w*)(?: )*(.*)/;
+    const lines = text.split('\n');
+    for (let lineNo = 0; lineNo < lines.length; ++lineNo) {
+        const line = lines[lineNo].trim();
+        if (line === '' || line.startsWith('#')) {
+            continue;
+        }
+        const m = keywordRE.exec(line);
+        if (!m) {
+            continue;
+        }
+        const [, keyword, unparsedArgs] = m;
+        const parts = line.split(/\s+/).slice(1);
+        const handler = keywords[keyword];
+        if (!handler) {
+            console.warn('unhandled keyword:', keyword);  // eslint-disable-line no-console
+            continue;
+        }
+        handler(parts, unparsedArgs);
+    }
+
+    // remove any arrays that have no entries.
+    for (const geometry of geometries) {
+        geometry.data = Object.fromEntries(
+            Object.entries(geometry.data).filter(([, array]) => array.length > 0));
+    }
+
+    return {
+        geometries,
+        materialLibs,
+    };
+}
+
+/** Model-space rules for extracting the front from the atlas-mapped OBJ. */
+const CARD_MESH_LAYOUT = Object.freeze({
+    front_normal: Object.freeze([0.0, 1.0, 0.0]),
+    front_normal_threshold: 0.999,
+    front_uv_min: Object.freeze([0.5, 0.3]),
+    front_uv_max: Object.freeze([1.0, 1.0]),
+    uv_tolerance: 0.001,
+});
+
+/** Return a validated normalized three-component vector. */
+function normalizedLayoutVector(value, name)
+{
+    if(!Array.isArray(value) || value.length != 3
+       || !value.every(Number.isFinite))
+    {
+        throw(new Error(`Invalid ${name}.`));
+    }
+    const length = Math.hypot(...value);
+    if(length < 1e-12)
+    {
+        throw(new Error(`Invalid ${name}: vector has zero length.`));
+    }
+    return [value[0] / length, value[1] / length, value[2] / length];
+}
+
+/** Return a geometry with empty arrays matching the source attributes. */
+function emptyPartitionGeometry(geometry, suffix, material)
+{
+    const data = {};
+    for(const name of Object.keys(geometry.data))
+    {
+        data[name] = [];
+    }
+    return {
+        object: `${geometry.object}/${suffix}`,
+        groups: geometry.groups,
+        material,
+        data,
+    };
+}
+
+/** Validate geometry arrays and return their components per vertex. */
+function geometryComponents(geometry)
+{
+    if(geometry == null || geometry.data == null
+       || !Array.isArray(geometry.data.position)
+       || !Array.isArray(geometry.data.texcoord))
+    {
+        throw(new Error("Card geometry requires positions and texcoords."));
+    }
+    const vertex_count = geometry.data.position.length / 3;
+    if(vertex_count == 0 || !Number.isInteger(vertex_count)
+       || vertex_count % 3 != 0)
+    {
+        throw(new Error(
+            `Card geometry ${geometry.object} has incomplete triangles.`));
+    }
+
+    const components = {};
+    for(const [name, data] of Object.entries(geometry.data))
+    {
+        if(!Array.isArray(data) || data.length == 0
+           || !data.every(Number.isFinite)
+           || data.length % vertex_count != 0)
+        {
+            throw(new Error(
+                `Card geometry ${geometry.object} has invalid ${name} data.`));
+        }
+        components[name] = data.length / vertex_count;
+    }
+    if(components.position != 3 || components.texcoord != 2
+       || (components.normal != null && components.normal != 3))
+    {
+        throw(new Error(
+            `Card geometry ${geometry.object} has invalid attribute sizes.`));
+    }
+    return components;
+}
+
+/** Remap one validated card-front coordinate into the unit square. */
+function remapCardFrontUv(u, v, layout, geometry_name, triangle)
+{
+    const values = [u, v];
+    const remapped = [];
+    for(let axis = 0; axis < 2; ++axis)
+    {
+        const minimum = layout.front_uv_min[axis];
+        const maximum = layout.front_uv_max[axis];
+        if(values[axis] < minimum - layout.uv_tolerance
+           || values[axis] > maximum + layout.uv_tolerance)
+        {
+            const coordinate = axis == 0 ? "u" : "v";
+            throw(new Error(
+                `Front UV outside atlas region in ${geometry_name} triangle `
+                + `${triangle}: ${coordinate}=${values[axis]}, expected `
+                + `${minimum}..${maximum} (tolerance `
+                + `${layout.uv_tolerance}).`));
+        }
+        const value = (values[axis] - minimum) / (maximum - minimum);
+        remapped.push(Math.min(Math.max(value, 0.0), 1.0));
+    }
+    return remapped;
+}
+
+/** Append one source triangle, optionally remapping its texture coordinates. */
+function appendPartitionTriangle(source, destination, components,
+                                 triangle, layout)
+{
+    const first_vertex = triangle * 3;
+    for(const [name, component_count] of Object.entries(components))
+    {
+        const first = first_vertex * component_count;
+        const last = first + 3 * component_count;
+        if(name != "texcoord" || layout == null)
+        {
+            destination.data[name].push(
+                ...source.data[name].slice(first, last));
+            continue;
+        }
+
+        for(let vertex = 0; vertex < 3; ++vertex)
+        {
+            const offset = first + 2 * vertex;
+            destination.data.texcoord.push(...remapCardFrontUv(
+                source.data.texcoord[offset],
+                source.data.texcoord[offset + 1], layout,
+                source.object, triangle));
+        }
+    }
+}
+
+/** Partition and remap an atlas-mapped card into front and shell geometry. */
+function partitionCardGeometry(geometry, layout)
+{
+    const components = geometryComponents(geometry);
+    if(layout == null || !Number.isFinite(layout.front_normal_threshold)
+       || layout.front_normal_threshold < -1.0
+       || layout.front_normal_threshold > 1.0
+       || !Number.isFinite(layout.uv_tolerance)
+       || layout.uv_tolerance < 0.0)
+    {
+        throw(new Error("Invalid card mesh layout."));
+    }
+    const front_normal = normalizedLayoutVector(
+        layout.front_normal, "card front normal");
+    const uv_min = layout.front_uv_min;
+    const uv_max = layout.front_uv_max;
+    if(!Array.isArray(uv_min) || !Array.isArray(uv_max)
+       || uv_min.length != 2 || uv_max.length != 2
+       || !uv_min.every(Number.isFinite) || !uv_max.every(Number.isFinite)
+       || uv_min[0] >= uv_max[0] || uv_min[1] >= uv_max[1])
+    {
+        throw(new Error("Invalid card front UV rectangle."));
+    }
+
+    const front = emptyPartitionGeometry(geometry, "front", "front");
+    const shell = emptyPartitionGeometry(geometry, "shell", "shell");
+    const positions = geometry.data.position;
+    const texcoords = geometry.data.texcoord;
+    const triangle_count = positions.length / 9;
+
+    for(let triangle = 0; triangle < triangle_count; ++triangle)
+    {
+        const offset = triangle * 9;
+        const edge_1 = [
+            positions[offset + 3] - positions[offset],
+            positions[offset + 4] - positions[offset + 1],
+            positions[offset + 5] - positions[offset + 2],
+        ];
+        const edge_2 = [
+            positions[offset + 6] - positions[offset],
+            positions[offset + 7] - positions[offset + 1],
+            positions[offset + 8] - positions[offset + 2],
+        ];
+        const face_normal = [
+            edge_1[1] * edge_2[2] - edge_1[2] * edge_2[1],
+            edge_1[2] * edge_2[0] - edge_1[0] * edge_2[2],
+            edge_1[0] * edge_2[1] - edge_1[1] * edge_2[0],
+        ];
+        const normal_length = Math.hypot(...face_normal);
+        if(normal_length < 1e-12)
+        {
+            throw(new Error(
+                `Degenerate triangle ${triangle} in ${geometry.object}.`));
+        }
+        const facing = (face_normal[0] * front_normal[0]
+                      + face_normal[1] * front_normal[1]
+                      + face_normal[2] * front_normal[2]) / normal_length;
+        if(facing < layout.front_normal_threshold)
+        {
+            appendPartitionTriangle(
+                geometry, shell, components, triangle, null);
+            continue;
+        }
+
+        appendPartitionTriangle(
+            geometry, front, components, triangle, layout);
+    }
+
+    for(const partition of [front, shell])
+    {
+        for(const [name, data] of Object.entries(partition.data))
+        {
+            if(data.length == 0)
+            {
+                delete partition.data[name];
+            }
+        }
+    }
+    return {front, shell};
+}
+
+if(typeof module != "undefined")
+{
+    module.exports = {
+        CARD_MESH_LAYOUT,
+        parseOBJ,
+        partitionCardGeometry,
+    };
+}
diff --git a/static/foil/spectral_xyz.bin b/static/foil/spectral_xyz.bin
new file mode 100644
index 0000000..18ef0bf
Binary files /dev/null and b/static/foil/spectral_xyz.bin differ
diff --git a/static/foil/vert-shader.glsl b/static/foil/vert-shader.glsl
new file mode 100644
index 0000000..44ac983
--- /dev/null
+++ b/static/foil/vert-shader.glsl
@@ -0,0 +1,57 @@
+#version 300 es
+// -*- mode: c; -*-
+
+in vec4 a_position;
+in vec3 a_normal;
+in vec4 a_tangent;
+in vec2 a_texcoord;
+
+uniform mat4 u_projection;
+uniform mat4 u_model_view;
+uniform mat3 u_normal_matrix;
+
+out vec2 v_texcoord;
+out vec3 v_view_position;
+out vec3 v_view_normal;
+out vec3 v_view_tangent;
+out vec3 v_view_bitangent;
+
+// Normalize interpolant inputs without allowing malformed geometry to inject
+// NaNs into every fragment covered by a primitive.
+vec3 safeNormalize(vec3 value, vec3 fallback)
+{
+    float length_squared = dot(value, value);
+    return length_squared < 0.00001
+         ? fallback
+         : value * inversesqrt(length_squared);
+}
+
+// Construct a fallback tangent perpendicular to the supplied normal.
+vec3 orthogonalVector(vec3 normal)
+{
+    vec3 reference = abs(normal.z) < 0.999
+                   ? vec3(0.0, 0.0, 1.0)
+                   : vec3(0.0, 1.0, 0.0);
+    return safeNormalize(cross(reference, normal), vec3(1.0, 0.0, 0.0));
+}
+
+void main()
+{
+    vec4 view_position = u_model_view * a_position;
+    vec3 normal = safeNormalize(
+        u_normal_matrix * a_normal, vec3(0.0, 0.0, 1.0));
+    vec3 tangent = mat3(u_model_view) * a_tangent.xyz;
+
+    // Re-orthogonalization suppresses interpolation and transform error before
+    // constructing the UV-aligned bitangent used by the grating orientation.
+    tangent = safeNormalize(
+        tangent - normal * dot(normal, tangent), orthogonalVector(normal));
+
+    gl_Position = u_projection * view_position;
+    v_texcoord = a_texcoord;
+    v_view_position = view_position.xyz;
+    v_view_normal = normal;
+    v_view_tangent = tangent;
+    v_view_bitangent = a_tangent.w * safeNormalize(
+        cross(normal, tangent), orthogonalVector(normal));
+}
diff --git a/styling.md b/styling.md
new file mode 100644
index 0000000..7ac03b8
--- /dev/null
+++ b/styling.md
@@ -0,0 +1,434 @@
+<design-system>
+# High-Fidelity Claymorphism Design System
+
+## Design Philosophy
+
+Note that the design should work for content. This design system here
+should not be applied blindly without assessing whether it works for
+the content or not.
+
+**Spatial Concept: Fullscreen Content with Floating Clay Chrome**
+The application should feel like one continuous, viewport-sized workspace,
+not a document placed inside a centered website container. Primary content
+uses the full available width and, for immersive views, the full viewport.
+Navigation, inspectors, and similar application chrome float above that
+workspace as separate clay objects.
+
+This is especially important to the app-like character of the design:
+
+*   The navigation bar remains visible above scrolling content. Page titles,
+    sorting tools, grids, and other content scroll underneath its translucent
+    surface instead of stopping at a permanent header boundary.
+*   An initial top inset keeps the first content from being obscured, but the
+    navbar does not occupy normal document flow.
+*   Detail-page sidebars are overlays, not layout columns. The preview or
+    primary surface continues behind them and fills the viewport.
+*   Floating chrome uses translucency, backdrop blur, a complete clay shadow
+    stack, and generous outer gutters so it remains visually separate from
+    the content below.
+*   Fullscreen does not mean cramped or edge-to-edge text. Content grids keep
+    responsive page gutters, and readable prose retains an appropriate line
+    length. It means removing arbitrary centered-page width caps.
+
+**Core Concept: Digital Clay**
+This design system is not merely a "soft UI"—it is a high-fidelity simulation of a tangible, physical world constructed from **premium digital clay**. Every element on the screen should evoke the sensation of holding a high-end, matte-finish vinyl toy or a soft, air-filled silicone object. It rejects the flatness of modern minimalism in favor of volume, weight, and tactility.
+
+**The "High-Fidelity" Difference**:
+Unlike early 2020s "Neumorphism" (which felt like extruded plastic) or basic "Claymorphism" (which often feels like flat vector art), **High-Fidelity Claymorphism** relies on complex, multi-layered lighting simulation using 4-layer shadow stacks. It renders objects that feel dense, substantial, and interactive—not hollow decorations.
+
+*   **Materiality**: Think of soft-touch matte silicone, marshmallow-like foam, or high-quality injection-molded plastic with a premium finish. Surfaces absorb light rather than reflecting it sharply, creating a warm, inviting aesthetic.
+*   **Lighting**: The "world" is lit by a soft, diffused overhead light source positioned top-left, creating deep ambient occlusion shadows below objects and gentle specular highlights on their upper ridges. This creates the illusion of physical depth.
+*   **Shadow Architecture**: Every element uses carefully crafted multi-layer shadows:
+    - **Outer shadows**: Soft, colored drop shadows that define distance from the surface
+    - **Highlight shadows**: Top-left highlights that simulate light reflection
+    - **Inner shadows**: Subtle colored reflections and rim lights that add dimensionality
+    - **Active states**: Pressed elements use inset shadows to simulate physical depression
+
+**The Sensory Vibe**:
+*   **Playful & Optimistic**: The interface radiates joy through "candy store" colors (vivid violets, hot pinks, sky blues, emerald greens, amber oranges) and bouncy, organic motion. It feels safe, welcoming, and unpretentious—like a premium toy store display.
+*   **Tactile & Responsive**: Elements don't just change color when interacted with—they physically react with exaggerated realism. Buttons actively "squish" (scale-[0.92] + shadow-clayPressed) and compress under the cursor. Cards lift and float towards the user (-translate-y-2 with enhanced shadows). Every interaction provides satisfying visual feedback.
+*   **Friendly & Safe**: There are **zero sharp corners** in this universe. Every edge is aggressively rounded (`rounded-[20px]` minimum, up to `rounded-[60px]` for large containers), subconsciously signaling safety and approachability to the user. The design language speaks "friendly" and "accessible" without words.
+*   **Premium Craft**: Despite the playfulness, this aesthetic maintains a sense of quality through careful attention to detail: consistent border radii, precise shadow layering, harmonious color relationships, and smooth micro-interactions.
+
+**The "Clay" Physics Engine**:
+1.  **Convexity (The Bulge)**: Primary interactive elements (Buttons, Stat orbs, Feature cards) bulge OUT towards the user with `shadow-clayButton` or `shadow-clayCard`. They capture light on their top-left edge and cast soft colored shadows below, creating the illusion of floating above the surface.
+2.  **Concavity (The Press)**: Secondary surfaces (Input fields, Active button states, FAQ panels when open) are pressed INTO the clay surface with `shadow-clayPressed`. They cast internal shadows on their top edge and catch light on their bottom lip, making them feel recessed.
+3.  **Buoyancy (The Float)**: The interface exists in a zero-gravity environment with high air resistance. Background blobs drift slowly (8-12s animations with translateY and rotate). Cards hover effortlessly with hover states that amplify the float effect. Nothing feels statically "stuck" to the grid—everything breathes and moves subtly.
+4.  **Micro-Physics**: Hover states consistently lift elements upward (`hover:-translate-y-1` to `-translate-y-2`) while enhancing their shadows, simulating the element floating closer to the viewer. Active/pressed states do the opposite—compressing downward with reduced shadows.
+
+---
+
+## Design Token System
+
+### Colors (The "Candy Shop" Palette)
+
+**Background**:
+*   **Canvas**: `#F4F1FA` (Very pale, cool lavender-white). This provides a cleaner, more modern base than warm beige. Never use pure white—the slight tint creates warmth.
+
+**Foreground**:
+*   **Text (Primary)**: `#332F3A` (Soft Charcoal). High contrast (passing WCAG AA) but softer than black for a friendlier feel.
+*   **Muted (Secondary)**: `#635F69` (Dark Lavender-Gray). Crucial for readability against light backgrounds. Use for body text, labels, and secondary information. Never go lighter than this value.
+
+**Accents (Vibrant & Saturated)**:
+*   **Primary Accent**: `#7C3AED` (Vivid Violet). The hero color used for primary CTAs, links, and brand emphasis.
+*   **Secondary Accent**: `#DB2777` (Hot Pink). Used in gradients and for secondary emphasis.
+*   **Tertiary**: `#0EA5E9` (Sky Blue). For informational elements and background blobs.
+*   **Success/Benefits**: `#10B981` (Emerald Green). For checkmarks and positive indicators.
+*   **Warning**: `#F59E0B` (Amber). For alerts and star ratings.
+
+**Gradient Strategy**:
+*   **Primary Buttons**: `bg-gradient-to-br from-[#A78BFA] to-[#7C3AED]` (lighter violet to primary violet). This creates depth and prevents overly dark buttons.
+*   **Icon Orbs**: `bg-gradient-to-br` from light pastel (400) to saturated hue (600) with varied colors for visual interest (e.g., `from-blue-400 to-blue-600`, `from-purple-400 to-purple-600`, `from-pink-400 to-pink-600`).
+*   **Text Highlights**: Use multi-stop gradients for hero text (`clay-text-gradient`): `from-clay-foreground 20%, to-clay-accent 60%, to-clay-accent-alt`. Keep gradient text large (text-5xl+) for readability.
+*   **Background Blobs**: Semi-transparent accent colors with 10% opacity and blur-3xl for soft ambient lighting.
+
+### Typography
+
+**Font Selection**:
+*   **Headings**: **Nunito** (Google Fonts, Weights: 700/800/900). The rounded terminals perfectly complement the soft clay aesthetic. Apply via inline styles: `style={{ fontFamily: "Nunito, sans-serif" }}` for all headings, stat numbers, and emphasis text.
+*   **Body**: **DM Sans** (Google Fonts, Weights: 400/500/700). Geometric, clean, and highly readable. Applied globally via body font-family.
+
+**Hierarchy (Mobile-First with Progressive Enhancement)**:
+*   **Hero Headline**: `text-5xl sm:text-6xl md:text-7xl lg:text-8xl`, Black weight (font-black), tight tracking (tracking-tight), line-height 1.1. Always use Nunito.
+*   **Section Titles**: `text-3xl sm:text-4xl md:text-5xl`, Extrabold (font-extrabold) or Black. Always use Nunito.
+*   **Card Titles**: `text-xl` to `text-2xl` (larger for hero cards: `text-3xl`), Bold (font-bold) to Extrabold. Use Nunito.
+*   **Body Text**: `text-base` to `text-lg`, Medium weight (font-medium), relaxed leading (leading-relaxed). Use DM Sans.
+*   **Small Text**: `text-sm` to `text-xs`, Medium to Bold weight. Use for labels, metadata, uppercase tracking-wide treatments.
+
+**Typography Best Practices**:
+*   Always pair Nunito headings with DM Sans body for optimal hierarchy.
+*   Use `font-black` (900 weight) for maximum impact on large headings and numbers.
+*   Ensure line-height is generous: `leading-relaxed` (1.625) for body, `leading-[1.1]` for tight display headings.
+*   Limit line length to 60-75 characters with max-w-2xl to max-w-3xl containers for optimal readability.
+*   Use `tracking-tight` on large headings to maintain visual density, `tracking-wide` or `tracking-widest` on small caps/labels.
+
+### Shapes & Radii
+
+**The "Super-Rounded" Rule** (Absolute Values Only):
+*   **Large Containers/Hero Sections**: `rounded-[48px]` to `rounded-[60px]`
+*   **Standard Cards**: `rounded-[32px]` (the default for most cards)
+*   **Medium Elements** (Benefits pills, Blog cards): `rounded-[24px]`
+*   **Buttons & Inputs**: `rounded-[20px]` or `rounded-2xl`
+*   **Icon Containers**: `rounded-2xl` (16px) for square icons, `rounded-full` for circular
+*   **Small Badges**: `rounded-lg` (8px) minimum, `rounded-full` preferred
+*   **Stat Orbs**: `rounded-full` (perfect circles)
+
+**Critical Rules**:
+*   Never use `rounded-md` (4px) or `rounded-sm`. They appear too sharp and generic for this aesthetic.
+*   Maintain consistency: if a card uses `rounded-[32px]`, its nested image should use `rounded-[24px]` (8px less) to create visual hierarchy.
+*   On mobile, you may reduce radii slightly (e.g., `rounded-[32px] sm:rounded-[40px]`) to maximize screen real estate while maintaining the soft aesthetic.
+
+### Shadows (The Engine of Clay)
+
+This system uses a **High-Fidelity Shadow Stack** to simulate complex lighting.
+
+**1. Deep Clay (Surface)**:
+For the main background elements or large containers.
+```css
+box-shadow:
+  30px 30px 60px #cdc6d9,           /* Deep, soft ambient occlusion */
+  -30px -30px 60px #ffffff,         /* Top-left ambient light */
+  inset 10px 10px 20px rgba(139, 92, 246, 0.05), /* Subtle color reflection */
+  inset -10px -10px 20px rgba(255, 255, 255, 0.8); /* Surface specularity */
+```
+
+**2. Clay Card (Floating)**:
+For standard content cards.
+```css
+box-shadow:
+  16px 16px 32px rgba(160, 150, 180, 0.2), /* Soft purple-gray drop shadow */
+  -10px -10px 24px rgba(255, 255, 255, 0.9), /* Strong top-left highlight */
+  inset 6px 6px 12px rgba(139, 92, 246, 0.03), /* Inner colored bounce light */
+  inset -6px -6px 12px rgba(255, 255, 255, 1); /* Inner rim light */
+```
+
+**3. Clay Button (High Convexity)**:
+For clickable elements.
+```css
+box-shadow:
+  12px 12px 24px rgba(139, 92, 246, 0.3), /* Strong colored drop shadow */
+  -8px -8px 16px rgba(255, 255, 255, 0.4), /* Top-left highlight */
+  inset 4px 4px 8px rgba(255, 255, 255, 0.4), /* Inner rim */
+  inset -4px -4px 8px rgba(0, 0, 0, 0.1); /* Bottom-right shading */
+```
+
+**4. Clay Pressed (Recessed)**:
+For inputs and active states.
+```css
+box-shadow:
+  inset 10px 10px 20px #d9d4e3, /* Deep inner shadow top-left */
+  inset -10px -10px 20px #ffffff; /* Inner highlight bottom-right */
+```
+
+---
+
+## Component Architecture
+
+### 1. The Universal Card (`Card`)
+*   **Base Styles**: `relative overflow-hidden rounded-[32px] bg-clay-cardBg p-8 text-clay-foreground shadow-clayCard backdrop-blur-xl`
+*   **Interactive States**:
+    *   Default: `shadow-clayCard` (4-layer shadow with soft depth)
+    *   Hover: `hover:-translate-y-2 hover:shadow-[enhanced]` (lifted with stronger shadow)
+    *   Transition: `transition-all duration-500` (smooth, premium feel)
+*   **Structure**:
+    *   Outer wrapper handles positioning, overflow, shadows
+    *   **Inner Content Wrapper**: `<div className="relative z-10 flex h-full flex-col">{children}</div>` to support absolute positioned decorative elements
+*   **Decorations**: Use absolute positioned panels with negative margins (`-bottom-8 -left-8 -right-8`) to create "peeking" UI elements that emerge from card bottoms
+*   **Variants**:
+    *   Glass effect: `bg-white/60` to `bg-white/80`
+    *   Solid: `bg-white`
+    *   Feature hero card: `md:col-span-2 md:row-span-2` with larger internal padding
+
+### 2. The Clay Button (`Button`)
+*   **Base Shape**: `rounded-[20px]` with chunky height (`h-14` default, `h-16` for lg)
+*   **Base Styles**: `inline-flex items-center justify-center font-bold tracking-wide transition-all duration-200`
+*   **Variants**:
+    *   **Primary/Default**: `bg-gradient-to-br from-[#A78BFA] to-[#7C3AED] text-white shadow-clayButton hover:shadow-clayButtonHover`
+    *   **Secondary**: `bg-white text-clay-foreground shadow-clayButton`
+    *   **Outline**: `border-2 border-clay-accent/20 bg-transparent text-clay-accent hover:border-clay-accent hover:bg-clay-accent/5`
+    *   **Ghost**: `text-clay-foreground hover:bg-clay-accent/10 hover:text-clay-accent`
+*   **Interactive States**:
+    *   Hover: `hover:-translate-y-1` (lift up 4px) + Enhanced shadow
+    *   Active: `active:scale-[0.92] active:shadow-clayPressed` (pronounced squish effect)
+    *   Focus: `focus-visible:ring-4 focus-visible:ring-clay-accent/30 focus-visible:ring-offset-2`
+*   **Sizing**: Use `size` prop: `sm` (h-11), `default` (h-14), `lg` (h-16)
+
+### 3. The Recessed Input (`Input`)
+*   **Base Shape**: `rounded-2xl` with generous height `h-16`
+*   **Base Styles**: `flex w-full border-0 bg-[#EFEBF5] px-6 py-4 text-clay-foreground text-lg shadow-clayPressed`
+*   **States**:
+    *   Default: Recessed with `shadow-clayPressed` (inset shadows)
+    *   Focus: `focus:bg-white focus:ring-4 focus:ring-clay-accent/20` (transforms to raised white surface)
+    *   Placeholder: `placeholder:text-clay-muted`
+*   **Accessibility**: `transition-all duration-200` for smooth state changes
+
+### 4. Floating 3D Blobs (Background)
+**Never use a flat background.** Always include 3-4 large, animated blobs.
+*   **Container**: `<div className="pointer-events-none fixed inset-0 overflow-hidden -z-10">`
+*   **Individual Blobs**:
+    *   Classes: `absolute h-[60vh] w-[60vh] rounded-full blur-3xl`
+    *   Colors: Accent colors with `/10` opacity (e.g., `bg-[#8B5CF6]/10`, `bg-[#EC4899]/10`, `bg-[#0EA5E9]/10`)
+    *   Positioning: Negative margins to bleed off edges (`-top-[10%] -left-[10%]`, `-right-[10%] top-[20%]`)
+    *   Animation: `clay-blob` or `clay-blob-alt` with staggered `animation-delay-2000` or `animation-delay-4000`
+*   **Purpose**: Creates ambient colored lighting that shows through glass-morphic cards
+
+---
+
+## Animation System
+
+**1. Clay Float (`clay-float`)**:
+Simulates zero-gravity drift for background blobs. 8 second duration.
+```css
+@keyframes clay-float {
+  0%, 100% { transform: translateY(0) rotate(0deg); }
+  50% { transform: translateY(-20px) rotate(2deg); }
+}
+```
+
+**2. Clay Float Delayed (`clay-float-delayed`)**:
+Alternative animation with opposite rotation. 10 second duration.
+```css
+@keyframes clay-float-delayed {
+  0%, 100% { transform: translateY(0) rotate(0deg); }
+  50% { transform: translateY(-15px) rotate(-2deg); }
+}
+```
+
+**3. Clay Float Slow (`clay-float-slow`)**:
+For hero decorative elements that orbit the headline. 12 second duration with more pronounced movement.
+```css
+@keyframes clay-float-slow {
+  0%, 100% { transform: translateY(0) rotate(0deg); }
+  50% { transform: translateY(-30px) rotate(5deg); }
+}
+```
+
+**4. Clay Breathe (`clay-breathe`)**:
+Simulates an object inflating/deflating slightly. 6 second duration. Used on stat orbs.
+```css
+@keyframes clay-breathe {
+  0%, 100% { transform: scale(1); }
+  50% { transform: scale(1.02); }
+}
+```
+
+**5. Hover Lift**:
+Standard interactive elements should lift upward on hover:
+*   Cards: `hover:-translate-y-2` (8px) with enhanced shadow
+*   Benefits pills: `hover:-translate-y-1` (4px)
+*   Testimonials: `hover:-translate-y-2` (8px)
+*   Blog posts: `hover:-translate-y-3` (12px) for dramatic effect
+*   Buttons: `hover:-translate-y-1` (4px) with shadow enhancement
+
+**6. Active Press**:
+Buttons use `active:scale-[0.92]` combined with `active:shadow-clayPressed` to simulate a physical squish when clicked. Duration should be fast (200ms) for immediate feedback.
+
+**7. Scale Transforms**:
+*   Stat orbs: `hover:scale-110` (10% growth)
+*   How It Works circles: `group-hover:scale-110` with 300ms duration
+*   Pricing cards (non-highlighted): `hover:scale-105` (5% subtle growth)
+*   Featured card in Bento grid: `hover:scale-[1.02]` (minimal growth due to large size)
+
+**8. Animation Delays**:
+Use staggered animations for visual rhythm:
+*   `.animation-delay-2000` (2s delay)
+*   `.animation-delay-4000` (4s delay)
+
+**9. Reduced Motion**:
+Always include `@media (prefers-reduced-motion: reduce)` to disable all animations for accessibility.
+
+---
+
+## Layout Patterns
+
+**1. Fullscreen Application Canvas**:
+*   The main landmark spans the viewport and has a minimum height of `100svh`.
+*   Collection grids, headings, and page-level tools share responsive outer
+    gutters but do not sit inside a fixed-width centered shell.
+*   Immersive media and preview surfaces may extend underneath all floating
+    chrome and reach every viewport edge.
+*   Use `svh` or `dvh` with a `vh` fallback so browser controls do not make
+    fullscreen layouts jump or crop unexpectedly.
+
+**2. Floating Navigation**:
+*   Position the primary navbar above the page with `position: fixed` and a
+    deliberate high layer. Give it outer gutters instead of attaching it to
+    the viewport edges.
+*   Use a translucent Glass-Clay surface and `backdrop-filter` so scrolling
+    content remains perceptible beneath it without reducing legibility.
+*   Give the beginning of normal pages a top content inset approximately
+    equal to the navbar's occupied area. Once scrolling begins, content is
+    allowed to pass underneath the navbar.
+*   Do not add an opaque header strip or reserve a permanent blank row above
+    the main content.
+
+**3. Floating Inspector / Sidebar**:
+*   On immersive detail pages, place read-only information or controls in a
+    fixed or absolutely positioned clay panel over the primary surface.
+*   The panel must have its own bounded scrolling region when its content is
+    taller than the available viewport. Keep focus indicators and keyboard
+    scrolling functional inside it.
+*   Account for the panel when positioning the primary focal object so an
+    important card, image, or model is not needlessly hidden behind it.
+*   The overlay should feel detachable from the content: use a translucent
+    surface, blur, deep outer shadow, rounded corners, and a clear gap from
+    both navbar and viewport edges.
+
+**4. Layering Model**:
+*   Background lighting and blobs occupy the lowest layer.
+*   Fullscreen content and media occupy the normal layer.
+*   Page-local hints and statuses sit above the media.
+*   Floating sidebars sit above page content.
+*   Global navigation occupies the highest routine application layer.
+*   Avoid arbitrary `z-index` values on ordinary cards; preserve this small,
+    predictable layer hierarchy.
+
+**5. Masonry / Bento Grid**:
+*   Don't use uniform grids. Mix `col-span-1` with `col-span-2` or `row-span-2` cards.
+*   Use `hover:scale-[1.02]` on large grid items for a tactile feel.
+
+**6. Split Layouts**:
+*   Use 50/50 splits for "Product" or "Benefits" sections.
+*   One side text, one side **Abstract 3D Composition** (nested clay shapes, not just an image).
+
+**7. Overlapping Elements**:
+*   Allow elements to break their containers (e.g., a "Popular" badge floating *above* a pricing card).
+*   Use negative margins to pull decorative elements to the edges.
+
+---
+
+## Responsive Strategy
+
+**Mobile-First Approach with Progressive Enhancement**
+
+The Clay design system maintains its playful, tactile personality across all screen sizes while adapting layouts and sizing for optimal mobile experience.
+
+**Typography Scaling**:
+*   Hero headlines: `text-5xl → sm:text-6xl → md:text-7xl → lg:text-8xl`
+*   Section titles: `text-3xl → sm:text-4xl → md:text-5xl`
+*   Body text: `text-base → sm:text-lg → md:text-xl` where appropriate
+*   Always maintain `leading-relaxed` and proper line length constraints
+
+**Layout Transformations**:
+*   **Navigation**: Keep the navbar floating on every viewport. Compact it on
+    mobile (`h-16 rounded-[32px] px-4`) and allow wrapping only when necessary.
+    Hide non-essential nav items before allowing the panel to consume most of
+    the screen.
+*   **Floating Sidebar**: Convert a desktop side inspector into a bottom-docked
+    floating panel on narrow screens. Limit it to roughly 40-50% of the safe
+    viewport height and make its contents independently scrollable so the
+    primary surface remains visible.
+*   **Fullscreen Surfaces**: Preserve the viewport-sized canvas on mobile.
+    Reposition overlays instead of shrinking the primary preview into a small
+    conventional content card.
+*   **Hero**: Stack CTAs vertically (`flex-col gap-6`) → Horizontal on desktop (`sm:flex-row`)
+*   **Stats**: 2-column grid on mobile (`grid-cols-2 gap-6`) → 4 columns on desktop (`md:grid-cols-4 gap-8`)
+*   **Features**: Single column → Bento layout with spans on desktop (`md:grid-cols-2 lg:grid-cols-3` with hero card `md:col-span-2 md:row-span-2`)
+*   **Benefits/Product Detail**: Stack vertically on mobile → Side-by-side split on desktop (`lg:grid-cols-2`)
+*   **Pricing**: Stack cards on mobile → 3 columns on desktop (`md:grid-cols-3`). Scale effect for highlighted card only applies on desktop (`md:scale-110`)
+
+**Component Adjustments**:
+*   **Cards**: Reduce padding on mobile (`p-6 sm:p-8`)
+*   **Border Radii**: Maintain generous radii even on mobile (never less than `rounded-[20px]`)
+*   **Buttons**: Full width on mobile (`w-full sm:w-auto`) for primary CTAs
+*   **Decorative Elements**: Hide some floating shapes on mobile (`hidden lg:block`)
+*   **Shadows**: Keep full shadow stacks—they're essential to the aesthetic
+
+**Touch Targets**:
+*   All interactive elements meet 44px minimum tap target (buttons are `h-14+`)
+*   Increase spacing in mobile navigation for easier tapping
+*   Ensure accordion FAQ items have adequate vertical spacing
+*   Keep floating panels clear of device safe-area insets and browser chrome
+
+**Performance**:
+*   Animations still run on mobile but respect `prefers-reduced-motion`
+*   Blur effects (`backdrop-blur-xl`) remain—they're critical to the glass-clay aesthetic
+*   Background blobs scale with viewport units (`vh`) so they adapt naturally
+
+**What NOT to Change on Mobile**:
+*   Don't flatten the design—keep the shadows and depth
+*   Don't reduce border radii to generic values
+*   Don't remove the candy-store colors or make them muted
+*   Don't disable all animations (only simplify if performance issues arise)
+
+---
+
+## Dos and Don'ts
+
+*   **DO** use pronounced "Squish" animations on click (`active:scale-[0.92]` combined with `shadow-clayPressed`).
+*   **DO** use varying border radii within components (e.g., `rounded-[48px]` for outer container, `rounded-[32px]` for card, `rounded-[24px]` for inner image).
+*   **DO** use "Glass-Clay" hybrid (semi-transparent white `bg-white/60` to `/80` + `backdrop-blur-xl`) for cards to reveal background blobs.
+*   **DO** use multi-layer shadow stacks (4 shadows minimum) to achieve high-fidelity depth.
+*   **DO** let fullscreen content scroll or render underneath translucent
+    floating application chrome.
+*   **DO** give tall overlay panels their own accessible scrolling region.
+*   **DO** apply Nunito font family explicitly to all headings, numbers, and labels via inline styles.
+*   **DO** use vibrant gradient backgrounds for icon containers with varied colors (blue, purple, pink, green, cyan, amber).
+*   **DON'T** use gray text lighter than `#635F69`. This is the minimum for accessibility against light backgrounds.
+*   **DON'T** use sharp corners anywhere. Minimum radius is `rounded-[20px]`, never `rounded-md` or `rounded-lg`.
+*   **DON'T** use flat colors for backgrounds. Always include animated blobs or subtle gradients.
+*   **DON'T** use gradient text for font sizes smaller than `text-5xl` (readability risk).
+*   **DON'T** make buttons too small. Minimum height is `h-11` (44px) for accessibility.
+*   **DON'T** skip the hover lift effect on interactive elements—it's core to the tactile feel.
+*   **DON'T** constrain primary application content to an arbitrary centered
+    maximum width.
+*   **DON'T** turn a floating sidebar into a layout column that shrinks the
+    fullscreen preview.
+*   **DON'T** let floating chrome permanently obscure headings, focal objects,
+    focus indicators, or essential actions.
+
+---
+
+## Implementation Checklist
+- [ ] **Background**: Canvas `#F4F1FA` + Animated Blobs.
+- [ ] **Shadows**: 4-layer box-shadows defined in CSS.
+- [ ] **Typography**: Nunito Black (Headings) + DM Sans (Body).
+- [ ] **Buttons**: Gradient, rounded-2xl, click-squish.
+- [ ] **Cards**: White/60%, backdrop-blur, rounded-3xl.
+- [ ] **Text**: High contrast charcoal/slate, no light grays.
+- [ ] **Main Canvas**: Full viewport width with responsive content gutters.
+- [ ] **Navigation**: Fixed Glass-Clay overlay; content scrolls beneath it.
+- [ ] **Detail Views**: Fullscreen primary surface with floating inspector.
+- [ ] **Mobile Overlays**: Bottom-docked, height-bounded, independently
+      scrollable, and clear of safe-area insets.
+</design-system>
diff --git a/templates/card_index.html b/templates/card_index.html
index 68c6192..0376090 100644
--- a/templates/card_index.html
+++ b/templates/card_index.html
@@ -7,10 +7,10 @@
         <h1>Cards</h1>
     </div>
     <div class="sort-controls" aria-label="Sort cards">
-        <span>Public ID</span>
-        <a href="{{ ascending_url }}"
+        <span class="sort-label">Sort by public ID</span>
+        <a class="sort-button" href="{{ ascending_url }}"
            {% if not descending %}aria-current="page"{% endif %}>Ascending</a>
-        <a href="{{ descending_url }}"
+        <a class="sort-button" href="{{ descending_url }}"
            {% if descending %}aria-current="page"{% endif %}>Descending</a>
     </div>
 </div>
diff --git a/templates/card_view.html b/templates/card_view.html
new file mode 100644
index 0000000..9088fde
--- /dev/null
+++ b/templates/card_view.html
@@ -0,0 +1,100 @@
+{% extends "layout.html" %}
+
+{% block content %}
+<div class="card-view">
+    <section class="card-preview-stage" aria-labelledby="PreviewHeading">
+        <div class="preview-heading">
+            <p id="PreviewHeading" class="eyebrow">Interactive preview</p>
+            <p id="PreviewStatus" class="preview-status" role="status"
+               aria-live="polite">Loading card preview…</p>
+        </div>
+        <canvas id="GLCanvas" aria-label="Interactive preview of {{ name }}">
+        </canvas>
+        <p class="preview-hint">Move the pointer over the card to tilt it.</p>
+    </section>
+
+    <aside class="card-info-panel" aria-labelledby="CardHeading">
+        <a class="back-link" href="{{ back_url }}">← All cards</a>
+        <p class="card-view-id">{{ display_id }}</p>
+        <h1 id="CardHeading">{{ name }}</h1>
+
+        {% if asset_warning %}
+        <div class="asset-warning" role="note">
+            <strong>Some card assets are missing.</strong>
+            <span>
+                Showing a fallback for
+                {% for asset in missing_assets %}
+                    {{ asset }}{% if not loop.is_last %}, {% endif %}
+                {% endfor %}.
+            </span>
+        </div>
+        {% endif %}
+
+        <dl class="card-metadata">
+            <div>
+                <dt>Game</dt>
+                <dd>{{ game }}</dd>
+            </div>
+            <div>
+                <dt>Rarity</dt>
+                <dd>{{ rarity }}</dd>
+            </div>
+            <div>
+                <dt>Finish</dt>
+                <dd>{% if has_foil %}Foil{% else %}Standard{% endif %}</dd>
+            </div>
+        </dl>
+
+        <section class="card-info-section" aria-labelledby="SeriesHeading">
+            <h2 id="SeriesHeading">Series</h2>
+            {% if length(series) == 0 %}
+            <p class="muted-copy">Not part of a series.</p>
+            {% else %}
+            <ul class="series-list">
+                {% for series_name in series %}
+                <li>{{ series_name }}</li>
+                {% endfor %}
+            </ul>
+            {% endif %}
+        </section>
+
+        {% if has_short_description %}
+        <section class="card-info-section" aria-labelledby="SummaryHeading">
+            <h2 id="SummaryHeading">Summary</h2>
+            <p class="description-copy">{{ short_description }}</p>
+        </section>
+        {% endif %}
+
+        {% if has_long_description %}
+        <section class="card-info-section"
+                 aria-labelledby="DescriptionHeading">
+            <h2 id="DescriptionHeading">Description</h2>
+            <p class="description-copy">{{ long_description }}</p>
+        </section>
+        {% endif %}
+    </aside>
+</div>
+
+<script id="CardPageData" type="application/json">
+{
+    "mode": "view",
+    "front_url": "{{ front_url }}",
+    "foil_url": {% if length(foil_url) > 0 %}
+        "{{ foil_url }}"
+    {% else %}
+        null
+    {% endif %},
+    "model_url": "{{ model_url }}",
+    "vertex_shader_url": "{{ shader_vertex_url }}",
+    "fragment_shader_url": "{{ shader_fragment_url }}",
+    "spectral_lut_url": "{{ spectral_lut_url }}"
+}
+</script>
+{% endblock %}
+
+{% block scripts %}
+<script src="{{ url_for("static", "foil/gl-matrix-min.js") }}"></script>
+<script src="{{ url_for("static", "foil/libwebgl.js") }}"></script>
+<script src="{{ url_for("static", "foil/obj.js") }}"></script>
+<script src="{{ preview_script_url }}"></script>
+{% endblock %}
diff --git a/templates/layout.html b/templates/layout.html
index 84aa7de..c7f824a 100644
--- a/templates/layout.html
+++ b/templates/layout.html
@@ -4,21 +4,38 @@
     <meta charset="utf-8">
     <meta name="viewport" content="width=device-width, initial-scale=1">
     <title>{{ title }}</title>
+    <link rel="preconnect" href="https://fonts.googleapis.com">
+    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+    <link
+        href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400..700"
+        rel="stylesheet">
+    <link
+        href="https://fonts.googleapis.com/css2?family=Nunito:wght@700..900"
+        rel="stylesheet">
     <link rel="stylesheet" href="{{ url_for("static", "css/styles.css") }}">
 </head>
 <body>
+    <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">
         <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>
+            <a class="nav-link" href="{{ url_for("card-index") }}">Cards</a>
+            <a class="nav-link" href="{{ url_for("series-index") }}">
+                Series
+            </a>
+            <a class="nav-link nav-link-primary"
+               href="{{ url_for("card-new") }}">Create card</a>
         </nav>
     </header>
     <main>
         {% block content %}{% endblock %}
     </main>
+    {% block scripts %}{% endblock %}
 </body>
 </html>
diff --git a/tests/app_test.cpp b/tests/app_test.cpp
index c892ca2..ac17ad7 100644
--- a/tests/app_test.cpp
+++ b/tests/app_test.cpp
@@ -6,12 +6,14 @@
 #include <optional>
 #include <stdexcept>
 #include <string>
+#include <unordered_map>
 #include <utility>
 #include <vector>
 
 #include <gtest/gtest.h>
 
 #include "data_fake.h"
+#include "public_id.h"
 
 namespace
 {
@@ -139,3 +141,74 @@ TEST(AppTest, RendersCardIndex)
     ASSERT_NE(tenth_position, std::string::npos);
     EXPECT_LT(second_position, tenth_position);
 }
+
+/// Verify a card page renders fake metadata and read-only preview data.
+TEST(AppTest, RendersCardView)
+{
+    Card card = makeCard(2, 2, "<script>Moon card</script>");
+    card.short_description = "A quiet <night>.";
+    card.long_description = "First line\nSecond line";
+    card.rarity = 4;
+    auto data_source = std::make_unique<DataSourceFake>(
+        std::vector<Card>{card},
+        std::vector<Series>{
+            {7, "pkm", "Night Signals", "A series description"},
+        },
+        std::unordered_map<std::int64_t, std::vector<std::int64_t>>{
+            {2, {7}},
+        });
+    App app(
+        makeConfig("https://example.test/collection/"),
+        std::move(data_source));
+    App::Request request;
+    request.path_params.emplace("id", "pkm-2");
+    App::Response response;
+
+    app.handleCardView(request, response);
+
+    EXPECT_EQ(response.status, 200);
+    EXPECT_NE(response.body.find("id=\"GLCanvas\""), std::string::npos);
+    EXPECT_NE(response.body.find("PKM-2"), std::string::npos);
+    EXPECT_NE(response.body.find("Night Signals"), std::string::npos);
+    EXPECT_NE(response.body.find("Some card assets are missing"),
+              std::string::npos);
+    EXPECT_NE(response.body.find("\"foil_url\":"), std::string::npos);
+    EXPECT_NE(response.body.find("&lt;script&gt;Moon card&lt;/script&gt;"),
+              std::string::npos);
+    EXPECT_EQ(response.body.find("<script>Moon card</script>"),
+              std::string::npos);
+}
+
+/// Verify missing and malformed public IDs return a not-found response.
+TEST(AppTest, RejectsUnknownCardView)
+{
+    App app(
+        makeConfig("https://example.test/"),
+        emptyDataSource());
+    App::Request request;
+    request.path_params.emplace("id", "PKM-2");
+    App::Response response;
+
+    app.handleCardView(request, response);
+
+    EXPECT_EQ(response.status, 404);
+}
+
+/// Verify canonical game and loose public IDs parse without ambiguity.
+TEST(PublicIdTest, ParsesCanonicalIds)
+{
+    const auto game_card = parsePublicId("pkm-42");
+    ASSERT_TRUE(game_card);
+    EXPECT_EQ(game_card->game_short_name, "pkm");
+    EXPECT_EQ(game_card->card_number, 42);
+
+    const auto loose_card = parsePublicId("z1");
+    ASSERT_TRUE(loose_card);
+    EXPECT_EQ(loose_card->game_short_name, std::nullopt);
+    EXPECT_EQ(loose_card->card_number, 1261);
+
+    EXPECT_FALSE(parsePublicId("PKM-42"));
+    EXPECT_FALSE(parsePublicId("pkm-042"));
+    EXPECT_FALSE(parsePublicId("pkm-0"));
+    EXPECT_FALSE(parsePublicId("z-1-extra"));
+}