From 328e84ccc7dc677844b08e3dadfa23348324810f Mon Sep 17 00:00:00 2001
From: Bad Manners <me@badmanners.xyz>
Date: Wed, 25 Sep 2024 13:38:04 -0300
Subject: [PATCH] Migrate some scripts to Alpine and add 18+ icon

---
 astro.config.mjs                              |  58 ++++----
 package-lock.json                             |  43 ++++++
 package.json                                  |   3 +
 src/components/AgeRestrictedModal.astro       | 136 ++++++------------
 .../AgeRestrictedScriptInline.astro           |   2 +-
 src/components/DarkModeScript.astro           |  35 -----
 src/components/DarkModeScriptInline.astro     |   2 +-
 src/components/NoteTooltip.astro              |   6 +-
 src/components/icons/IconNoOneUnder18.astro   |  20 +++
 src/components/icons/index.ts                 |   1 +
 src/content/blog/taken-in-breakdown.mdx       |   2 +-
 src/data/licenses.toml                        |  11 ++
 src/layouts/BaseLayout.astro                  |  21 ++-
 src/layouts/GalleryLayout.astro               |   3 +-
 src/layouts/PublishedContentLayout.astro      |   3 +-
 src/pages/blog/index.astro                    |   7 +-
 src/pages/games/index.astro                   |   7 +-
 src/pages/index.astro                         |  11 +-
 src/pages/stories/[...page].astro             |   7 +-
 src/pages/tags/[slug].astro                   |  16 +++
 20 files changed, 220 insertions(+), 174 deletions(-)
 delete mode 100644 src/components/DarkModeScript.astro
 create mode 100644 src/components/icons/IconNoOneUnder18.astro

diff --git a/astro.config.mjs b/astro.config.mjs
index 7ea28ac..9db12e6 100644
--- a/astro.config.mjs
+++ b/astro.config.mjs
@@ -6,39 +6,35 @@ import htaccessIntegration from "astro-htaccess";
 import pagefindIntegration from "astro-pagefind";
 import { AI_BOTS } from "./src/data/ai_bots";
 
+import alpinejs from "@astrojs/alpinejs";
+
 // https://astro.build/config
 export default defineConfig({
   site: "https://gallery.badmanners.xyz",
-  integrations: [
-    tailwindIntegration({
-      applyBaseStyles: false,
-    }),
-    markdownIntegration(),
-    mdxIntegration(),
-    htaccessIntegration({
-      generateHtaccessFile: import.meta.env.APACHE_CONFIG === "true",
-      customRules: [
-        // Block AI bots
-        "<IfModule mod_rewrite.c>",
-        "  RewriteEngine on",
-        "  RewriteBase /",
-        `  RewriteCond %{HTTP_USER_AGENT} ${AI_BOTS.map((bot) => `^${bot}$`).join("|")} [NC]`,
-        "  RewriteRule ^ – [F]",
-        "</IfModule>",
-      ],
-      redirects: [
-        {
-          match: "/story/",
-          url: "/stories/",
-        },
-        {
-          match: "/game/",
-          url: "/games/",
-        },
-      ],
-    }),
-    pagefindIntegration(),
-  ],
+  integrations: [tailwindIntegration({
+    applyBaseStyles: false,
+  }), markdownIntegration(), mdxIntegration(), htaccessIntegration({
+    generateHtaccessFile: import.meta.env.APACHE_CONFIG === "true",
+    customRules: [
+      // Block AI bots
+      "<IfModule mod_rewrite.c>",
+      "  RewriteEngine on",
+      "  RewriteBase /",
+      `  RewriteCond %{HTTP_USER_AGENT} ${AI_BOTS.map((bot) => `^${bot}$`).join("|")} [NC]`,
+      "  RewriteRule ^ – [F]",
+      "</IfModule>",
+    ],
+    redirects: [
+      {
+        match: "/story/",
+        url: "/stories/",
+      },
+      {
+        match: "/game/",
+        url: "/games/",
+      },
+    ],
+  }), pagefindIntegration(), alpinejs()],
   markdown: {
     smartypants: false,
     shikiConfig: {
@@ -67,4 +63,4 @@ export default defineConfig({
       },
     },
   },
-});
+});
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index f584577..8e10fb6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,12 +9,15 @@
       "version": "1.10.1",
       "hasInstallScript": true,
       "dependencies": {
+        "@astrojs/alpinejs": "^0.4.0",
         "@astrojs/check": "^0.9.3",
         "@astrojs/mdx": "^3.1.6",
         "@astrojs/rss": "^4.0.7",
         "@astrojs/tailwind": "^5.1.0",
         "@astropub/md": "^1.0.0",
         "@tailwindcss/typography": "^0.5.15",
+        "@types/alpinejs": "^3.13.10",
+        "alpinejs": "^3.14.1",
         "astro": "^4.15.5",
         "astro-htaccess": "^0.2.0",
         "astro-pagefind": "^1.6.0",
@@ -69,6 +72,16 @@
         "node": ">=6.0.0"
       }
     },
+    "node_modules/@astrojs/alpinejs": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/@astrojs/alpinejs/-/alpinejs-0.4.0.tgz",
+      "integrity": "sha512-68BY1CA0XuielLW3WdX2sfh9F4sSTnFqQ//IE9AditbiYJ77HJDb4uZx07pTFDtr1jOMU7lCvH+iS9gmNafM1g==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@types/alpinejs": "^3.0.0",
+        "alpinejs": "^3.0.0"
+      }
+    },
     "node_modules/@astrojs/check": {
       "version": "0.9.3",
       "resolved": "https://registry.npmjs.org/@astrojs/check/-/check-0.9.3.tgz",
@@ -1967,6 +1980,12 @@
         "@types/estree": "*"
       }
     },
+    "node_modules/@types/alpinejs": {
+      "version": "3.13.10",
+      "resolved": "https://registry.npmjs.org/@types/alpinejs/-/alpinejs-3.13.10.tgz",
+      "integrity": "sha512-ah53tF6mWuuwerpDE7EHwbZErNDJQlsLISPqJhYj2RZ9nuTYbRknSkqebUd3igkhLIZKkPa7IiXjSn9qsU9O2w==",
+      "license": "MIT"
+    },
     "node_modules/@types/babel__core": {
       "version": "7.20.5",
       "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -2205,6 +2224,21 @@
       "integrity": "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==",
       "license": "MIT"
     },
+    "node_modules/@vue/reactivity": {
+      "version": "3.1.5",
+      "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz",
+      "integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/shared": "3.1.5"
+      }
+    },
+    "node_modules/@vue/shared": {
+      "version": "3.1.5",
+      "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz",
+      "integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==",
+      "license": "MIT"
+    },
     "node_modules/acorn": {
       "version": "8.12.1",
       "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz",
@@ -2242,6 +2276,15 @@
         "url": "https://github.com/sponsors/epoberezkin"
       }
     },
+    "node_modules/alpinejs": {
+      "version": "3.14.1",
+      "resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.14.1.tgz",
+      "integrity": "sha512-ICar8UsnRZAYvv/fCNfNeKMXNoXGUfwHrjx7LqXd08zIP95G2d9bAOuaL97re+1mgt/HojqHsfdOLo/A5LuWgQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/reactivity": "~3.1.1"
+      }
+    },
     "node_modules/ansi-align": {
       "version": "3.0.1",
       "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz",
diff --git a/package.json b/package.json
index dfff505..539ed0d 100644
--- a/package.json
+++ b/package.json
@@ -16,12 +16,15 @@
     "deploy-lftp": "dotenv tsx scripts/deploy-lftp.ts --"
   },
   "dependencies": {
+    "@astrojs/alpinejs": "^0.4.0",
     "@astrojs/check": "^0.9.3",
     "@astrojs/mdx": "^3.1.6",
     "@astrojs/rss": "^4.0.7",
     "@astrojs/tailwind": "^5.1.0",
     "@astropub/md": "^1.0.0",
     "@tailwindcss/typography": "^0.5.15",
+    "@types/alpinejs": "^3.13.10",
+    "alpinejs": "^3.14.1",
     "astro": "^4.15.5",
     "astro-htaccess": "^0.2.0",
     "astro-pagefind": "^1.6.0",
diff --git a/src/components/AgeRestrictedModal.astro b/src/components/AgeRestrictedModal.astro
index 56930bd..c20f6e1 100644
--- a/src/components/AgeRestrictedModal.astro
+++ b/src/components/AgeRestrictedModal.astro
@@ -3,100 +3,54 @@ import AgeRestrictedScriptInline from "./AgeRestrictedScriptInline.astro";
 import { IconTriangleExclamation } from "./icons";
 ---
 
-<div
-  id="modal-age-restricted"
-  class="fixed inset-0 bg-stone-100 dark:bg-stone-900"
-  role="dialog"
-  aria-labelledby="title-age-restricted"
-  hidden
->
-  <div class="mx-auto flex min-h-screen max-w-3xl flex-col items-center justify-center text-center tracking-tight">
-    <div class="text-bm-500 dark:text-bm-400">
-      <IconTriangleExclamation width="3rem" height="3rem" />
-    </div>
-    <div
-      id="title-age-restricted"
-      class="pb-3 pt-2 text-3xl font-light text-stone-700 sm:pb-4 sm:pt-2 dark:text-stone-50"
-    >
-      Age verification
-    </div>
-    <div
-      class="mx-6 mb-4 max-w-xl border-b border-stone-300 pb-4 text-xl text-stone-700 dark:border-stone-300 dark:text-stone-50"
-    >
-      You must be 18+ to access this page.
-    </div>
-    <p class="px-8 text-lg font-light leading-snug text-stone-700 sm:max-w-2xl dark:text-stone-50">
-      By confirming that you are at least 18 years old, your selection will be saved to your browser to prevent this
-      screen from appearing in the future.
-    </p>
-    <div
-      id="age-verification-button-list"
-      class="flex w-full max-w-md flex-col-reverse justify-evenly gap-y-5 px-6 pt-5 sm:max-w-2xl sm:flex-row"
-      hidden
-    >
-      <button
-        data-modal-reject
-        id="age-verification-reject"
-        class="rounded bg-stone-400 py-3 text-lg text-stone-900 hover:bg-stone-500 hover:text-stone-50 focus:bg-stone-500 focus:text-stone-50 sm:px-9 dark:bg-stone-300 dark:text-stone-900 dark:hover:bg-stone-600 dark:hover:text-stone-50 dark:focus:bg-stone-600 dark:focus:text-stone-50"
+<template x-if="!ageVerified">
+  <div
+    id="modal-age-restricted"
+    class="fixed inset-0 bg-stone-100 dark:bg-stone-900"
+    role="dialog"
+    aria-labelledby="title-age-restricted"
+  >
+    <div class="mx-auto flex min-h-screen max-w-3xl flex-col items-center justify-center text-center tracking-tight">
+      <div class="text-bm-500 dark:text-bm-400">
+        <IconTriangleExclamation width="3rem" height="3rem" />
+      </div>
+      <div
+        id="title-age-restricted"
+        class="pb-3 pt-2 text-3xl font-light text-stone-700 sm:pb-4 sm:pt-2 dark:text-stone-50"
       >
-        Cancel
-      </button>
-      <button
-        data-modal-accept
-        id="age-verification-accept"
-        class="rounded bg-bm-500 py-3 text-lg text-stone-900 hover:bg-stone-500 hover:text-stone-50 focus:bg-stone-500 focus:text-stone-50 sm:px-9 dark:bg-bm-400 dark:text-stone-900 dark:hover:bg-stone-600 dark:hover:text-stone-50 dark:focus:bg-stone-600 dark:focus:text-stone-50"
+        Age verification
+      </div>
+      <div
+        class="mx-6 mb-4 max-w-xl border-b border-stone-300 pb-4 text-xl text-stone-700 dark:border-stone-300 dark:text-stone-50"
       >
-        I'm at least 18 years old
-      </button>
+        You must be 18+ to access this page.
+      </div>
+      <p class="px-8 text-lg font-light leading-snug text-stone-700 sm:max-w-2xl dark:text-stone-50">
+        By confirming that you are at least 18 years old, your selection will be saved to your browser to prevent this
+        screen from appearing in the future.
+      </p>
+      <div
+        id="age-verification-button-list"
+        class="flex w-full max-w-md flex-col-reverse justify-evenly gap-y-5 px-6 pt-5 sm:max-w-2xl sm:flex-row"
+        hidden
+      >
+        <button
+          id="age-verification-reject"
+          class="rounded bg-stone-400 py-3 text-lg text-stone-900 hover:bg-stone-500 hover:text-stone-50 focus:bg-stone-500 focus:text-stone-50 sm:px-9 dark:bg-stone-300 dark:text-stone-900 dark:hover:bg-stone-600 dark:hover:text-stone-50 dark:focus:bg-stone-600 dark:focus:text-stone-50"
+          @click="location.href = 'about:blank'"
+        >
+          Cancel
+        </button>
+        <button
+          id="age-verification-accept"
+          class="rounded bg-bm-500 py-3 text-lg text-stone-900 hover:bg-stone-500 hover:text-stone-50 focus:bg-stone-500 focus:text-stone-50 sm:px-9 dark:bg-bm-400 dark:text-stone-900 dark:hover:bg-stone-600 dark:hover:text-stone-50 dark:focus:bg-stone-600 dark:focus:text-stone-50"
+          @click="localStorage.ageVerified = 'true'; ageVerified = true"
+        >
+          I'm at least 18 years old
+        </button>
+      </div>
     </div>
   </div>
-</div>
+</template>
 
 <AgeRestrictedScriptInline />
-
-<script>
-  const ageRestrictedModalSetup = () => {
-    const modal = document.querySelector<HTMLElementTagNameMap["div"]>("div#modal-age-restricted");
-    if (!modal) {
-      // Not an age-restricted page
-      return;
-    }
-    if (modal !== document.querySelector("body>div#modal-age-restricted")) {
-      throw new Error("#modal-age-restricted must be a direct child of the body element!");
-    }
-    const addAgeVerifiedQueryToLinks = () =>
-      document.body.querySelectorAll<HTMLElementTagNameMap["a"]>("a[href][data-age-restricted]").forEach((el) => {
-        let newHref = new URL(el.href);
-        newHref.searchParams.set("ageVerified", "true");
-        el.href = newHref.toString();
-      });
-    if (localStorage.ageVerified === "true") {
-      addAgeVerifiedQueryToLinks();
-    } else {
-      const rejectButton = modal.querySelector<HTMLElementTagNameMap["button"]>("button[data-modal-reject]")!;
-      const onRejectButtonClick = (e: MouseEvent) => {
-        e.preventDefault();
-        location.href = "about:blank";
-      };
-      rejectButton.addEventListener("click", onRejectButtonClick);
-      const acceptButton = modal.querySelector<HTMLElementTagNameMap["button"]>("button[data-modal-accept]")!;
-      acceptButton.addEventListener(
-        "click",
-        (e: MouseEvent) => {
-          e.preventDefault();
-          rejectButton.removeEventListener("click", onRejectButtonClick);
-          localStorage.ageVerified = "true";
-          document.body.style.overflow = "auto";
-          document.querySelectorAll("body>:not(#modal-age-restricted)").forEach((el) => el.removeAttribute("inert"));
-          modal.hidden = true;
-          addAgeVerifiedQueryToLinks();
-        },
-        { once: true },
-      );
-      modal.querySelector<HTMLElementTagNameMap["div"]>("div#age-verification-button-list")!.hidden = false;
-      rejectButton.focus();
-    }
-  };
-
-  ageRestrictedModalSetup();
-</script>
diff --git a/src/components/AgeRestrictedScriptInline.astro b/src/components/AgeRestrictedScriptInline.astro
index 8a6164a..87c3d3f 100644
--- a/src/components/AgeRestrictedScriptInline.astro
+++ b/src/components/AgeRestrictedScriptInline.astro
@@ -1,4 +1,4 @@
 ---
 ---
 
-<script is:inline>(a=>{let b="body>",c="#modal-age-restricted",d="true",e="ageVerified",f="searchParams",g=localStorage,h=new URL(location),i=x=>a.querySelectorAll(x),j=i(b+c)[0];h[f].get(e)==d&&(g[e]=d,h[f].delete(e),history.replaceState({},"",h));j&&g[e]!=d&&((a.body.style.overflow="hidden"),i(b+":not("+c+")").forEach(x=>x.setAttribute("inert",d)),(j.hidden=!1))})(document)</script>
+<script is:inline>(()=>{let a="true",b="ageVerified",c="searchParams",d=localStorage,e=new URL(location);e[c].get(b)==a&&(d[b]=a,e[c].delete(b),history.replaceState({},"",e))})()</script>
diff --git a/src/components/DarkModeScript.astro b/src/components/DarkModeScript.astro
deleted file mode 100644
index 96655df..0000000
--- a/src/components/DarkModeScript.astro
+++ /dev/null
@@ -1,35 +0,0 @@
----
-import DarkModeScriptInline from "./DarkModeScriptInline.astro";
----
-
-<DarkModeScriptInline />
-
-<script>
-  type ColorScheme = "auto" | "dark" | "light" | undefined;
-
-  const colorSchemeSetup = () => {
-    let colorScheme: ColorScheme = localStorage.colorScheme;
-    if (!colorScheme || colorScheme === "auto") {
-      colorScheme = matchMedia("(prefers-color-scheme:dark)").matches ? "dark" : "light";
-    }
-    const toggleColorScheme = (e: MouseEvent) => {
-      e.preventDefault();
-      if (colorScheme === "dark") {
-        colorScheme = "light";
-        document.body.classList.remove("dark");
-      } else {
-        colorScheme = "dark";
-        document.body.classList.add("dark");
-      }
-      localStorage.colorScheme = colorScheme;
-    };
-    document.querySelectorAll<HTMLElementTagNameMap["button"]>("button[data-dark-mode]").forEach((button) => {
-      button.addEventListener("click", toggleColorScheme);
-      button.classList.remove("hidden");
-      button.hidden = false;
-      button.setAttribute("aria-hidden", "false");
-    });
-  };
-
-  colorSchemeSetup();
-</script>
diff --git a/src/components/DarkModeScriptInline.astro b/src/components/DarkModeScriptInline.astro
index 7adce1f..7a8db30 100644
--- a/src/components/DarkModeScriptInline.astro
+++ b/src/components/DarkModeScriptInline.astro
@@ -1,4 +1,4 @@
 ---
 ---
 
-<script is:inline>(a=>{var b="dark",c="colorScheme",d=localStorage,e=d[c];(e=="auto"||!e?matchMedia("(prefers-color-scheme:dark)").matches:e==b)&&a.body.classList.add(b)})(document)</script>
+<script is:inline>let g=document,f=a=>{var b="dark",c=localStorage,d=c.colorScheme;(d!="light"&&(d==b||matchMedia("(prefers-color-scheme:dark)").matches))&&a.body.classList.add(b)};g.addEventListener('astro:before-swap',e=>f(e.newDocument));f(g)</script>
diff --git a/src/components/NoteTooltip.astro b/src/components/NoteTooltip.astro
index 313cf02..14f55f9 100644
--- a/src/components/NoteTooltip.astro
+++ b/src/components/NoteTooltip.astro
@@ -1,6 +1,6 @@
 ---
 type Props = {
-  id: number;
+  id: number | string;
   title: string;
   text: string;
 };
@@ -12,6 +12,6 @@ const { id, title, text } = Astro.props;
   <a class="decoration-dotted" id={`note-${id}`} href={`#note-${id}`} title={title} data-tooltip>
     {text}
   </a>
-  <sup>{id}</sup>
-  <em class="sr-only">({title})</em>
+  {Number.isInteger(id) ? <sup>{id}</sup> : null}
+  <em class="sr-only"> ({title})</em>
 </Fragment>
diff --git a/src/components/icons/IconNoOneUnder18.astro b/src/components/icons/IconNoOneUnder18.astro
new file mode 100644
index 0000000..30b5456
--- /dev/null
+++ b/src/components/icons/IconNoOneUnder18.astro
@@ -0,0 +1,20 @@
+---
+import SVGIcon from "./SVGIcon.astro";
+
+type Props = {
+  width: string;
+  height: string;
+  class?: string;
+};
+---
+
+<SVGIcon {...Astro.props} viewBox="0 0 36 36" aria-label="🔞">
+  <path fill="#000000" d="M34.999 17.999c0 9.389-7.611 17-17 17s-17-7.611-17-17 7.611-17 17-17 17 7.611 17 17"
+  ></path><path
+    fill="#F5F8FA"
+    d="M9.521 12.245H7.85c-1.358 0-1.924-.991-1.924-1.953 0-.99.707-1.952 1.924-1.952h4.019c1.217 0 1.896.876 1.896 2.007v16.104c0 1.414-.906 2.207-2.122 2.207-1.216 0-2.122-.793-2.122-2.207V12.245zm7.307 10.13c0-2.264 1.245-3.934 3.027-4.895-1.33-.963-2.15-2.265-2.15-4.047 0-3.312 2.745-5.434 6.112-5.434 3.283 0 6.14 2.093 6.14 5.434 0 1.583-.791 3.17-2.178 4.047 1.924.96 3.027 2.715 3.027 4.895 0 3.934-3.197 6.451-6.989 6.451-3.906 0-6.989-2.658-6.989-6.451zm4.413-.283c0 1.443.849 2.832 2.575 2.832 1.612 0 2.576-1.389 2.576-2.832 0-1.783-1.02-2.83-2.576-2.83-1.641 0-2.575 1.246-2.575 2.83zm.538-8.206c0 1.274.736 2.151 2.037 2.151 1.302 0 2.066-.877 2.066-2.151 0-1.217-.736-2.151-2.066-2.151-1.33 0-2.037.934-2.037 2.151z"
+  ></path><path
+    fill="#DD2E44"
+    d="M18 0C8.059 0 0 8.06 0 18c0 9.941 8.059 18 18 18s18-8.059 18-18c0-9.94-8.059-18-18-18zm16 18c0 3.969-1.453 7.592-3.845 10.389L7.612 5.845C10.409 3.453 14.032 2 18 2c8.837 0 16 7.164 16 16zM2 18c0-3.968 1.453-7.591 3.844-10.387l22.543 22.543C25.591 32.548 21.968 34 18 34 9.164 34 2 26.837 2 18z"
+  ></path>
+</SVGIcon>
diff --git a/src/components/icons/index.ts b/src/components/icons/index.ts
index 3bb5d5c..aa72a27 100644
--- a/src/components/icons/index.ts
+++ b/src/components/icons/index.ts
@@ -10,6 +10,7 @@ export { default as IconGamepad } from "./IconGamepad.astro";
 export { default as IconHome } from "./IconHome.astro";
 export { default as IconMagnifyingGlass } from "./IconMagnifyingGlass.astro";
 export { default as IconMoon } from "./IconMoon.astro";
+export { default as IconNoOneUnder18 } from "./IconNoOneUnder18.astro";
 export { default as IconRetweet } from "./IconRetweet.astro";
 export { default as IconSquareRSS } from "./IconSquareRSS.astro";
 export { default as IconStar } from "./IconStar.astro";
diff --git a/src/content/blog/taken-in-breakdown.mdx b/src/content/blog/taken-in-breakdown.mdx
index 16b2c3b..dc04be1 100644
--- a/src/content/blog/taken-in-breakdown.mdx
+++ b/src/content/blog/taken-in-breakdown.mdx
@@ -39,7 +39,7 @@ count = 0;
 
 Going over the story and breaking it down was a fun process, all in all. But this was originally a text document, which I had to completely manually refit into this post you're reading (oof...!). Still, for the sake of posteriority, I think it was worth the effort.
 
-If you're up for this non-linear read, then I hope you enjoy this sneak-peek into my thoughts! Just hover or long-press over the <span class="text-link underline decoration-dotted" title="Just like that!" data-tooltip>links with dotted underlines</span>, and it should show the relevant annotations. Now, without further ado, let me reintroduce...
+If you're up for this non-linear read, then I hope you enjoy this sneak-peek into my thoughts! Just hover or long-press over the <NoteTooltip id="example" title="Just like that!" text="links with dotted underlines"/>, and it should show the relevant annotations. Now, without further ado, let me reintroduce...
 
 <h2>
   <NoteTooltip
diff --git a/src/data/licenses.toml b/src/data/licenses.toml
index 7fa958a..1b58450 100644
--- a/src/data/licenses.toml
+++ b/src/data/licenses.toml
@@ -101,3 +101,14 @@ items = [
   "tags",
   "triangle-exclamation",
 ]
+
+[[attributions]]
+title = "Twemoji"
+author = "Twitter"
+description = "Emoji-based icons."
+type = "icons"
+source = "https://github.com/twitter/twemoji"
+license = { name = "CC-BY-4.0", url = "https://creativecommons.org/licenses/by/4.0/" }
+items = [
+  "U+1F51E No One Under Eighteen Symbol",
+]
diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro
index 93436fe..87cb4dc 100644
--- a/src/layouts/BaseLayout.astro
+++ b/src/layouts/BaseLayout.astro
@@ -1,7 +1,7 @@
 ---
 import "../styles/base.css";
 import "../styles/fonts.css";
-import DarkModeScript from "@components/DarkModeScript.astro";
+import DarkModeScriptInline from "@components/DarkModeScriptInline.astro";
 import AgeRestrictedModal from "@components/AgeRestrictedModal.astro";
 
 type Props = {
@@ -35,10 +35,25 @@ const { pageTitle, lang = "en", isAgeRestricted } = Astro.props;
       href={new URL("feed.xml", Astro.site)}
     />
     <slot name="head" />
+    <script>
+      import Alpine from "alpinejs";
+      document.addEventListener("astro:after-preparation", () => {
+        Alpine.stopObservingMutations();
+      });
+      document.addEventListener("astro:page-load", () => {
+        document.dispatchEvent(new Event("alpine:init"));
+        Alpine.initTree(document.documentElement);
+        Alpine.startObservingMutations();
+      });
+    </script>
   </head>
-  <body>
+  <body
+    :class="!ageRestricted || ageVerified ? 'overflow-auto' : 'overflow-hidden'"
+    x-effect="darkMode ? $el.classList.add('dark') : $el.classList.remove('dark')"
+    x-data={`{ darkMode: localStorage.colorScheme != 'light' && (localStorage.colorScheme == 'dark' || matchMedia('(prefers-color-scheme:dark)').matches), ageVerified: new URL(location).searchParams.get('ageVerified') == 'true' || localStorage.ageVerified == 'true', ageRestricted: ${isAgeRestricted} }`}
+  >
     <slot />
-    <DarkModeScript />
+    <DarkModeScriptInline />
     {isAgeRestricted ? <AgeRestrictedModal /> : null}
   </body>
 </html>
diff --git a/src/layouts/GalleryLayout.astro b/src/layouts/GalleryLayout.astro
index 4354dd3..6f25738 100644
--- a/src/layouts/GalleryLayout.astro
+++ b/src/layouts/GalleryLayout.astro
@@ -105,10 +105,9 @@ const isCurrentRoute = (path: string) =>
         </li>
         <li>
           <button
-            data-dark-mode
-            hidden
             class="text-link group"
             aria-label={t("en", "published_content/toggle_dark_mode")}
+            @click="darkMode = !darkMode; localStorage.colorScheme = darkMode ? 'dark' : 'light'"
           >
             <IconSun width="1.25rem" height="1.25rem" class="order-1 hidden align-text-top dark:inline" />
             <IconMoon width="1.25rem" height="1.25rem" class="order-1 inline align-text-top dark:hidden" />
diff --git a/src/layouts/PublishedContentLayout.astro b/src/layouts/PublishedContentLayout.astro
index e0207b7..d517b1b 100644
--- a/src/layouts/PublishedContentLayout.astro
+++ b/src/layouts/PublishedContentLayout.astro
@@ -137,10 +137,9 @@ const returnTo = series
           <IconCircleInfo width="1.25rem" height="1.25rem" />
         </a>
         <button
-          data-dark-mode
-          hidden
           class="text-link my-1 border-l border-stone-300 p-2 dark:border-stone-700"
           aria-label={t(props.lang, "published_content/toggle_dark_mode")}
+          @click="darkMode = !darkMode; localStorage.colorScheme = darkMode ? 'dark' : 'light'"
         >
           <IconSun width="1.25rem" height="1.25rem" class="hidden dark:block" />
           <IconMoon width="1.25rem" height="1.25rem" class="block dark:hidden" />
diff --git a/src/pages/blog/index.astro b/src/pages/blog/index.astro
index b5d319e..0cf7534 100644
--- a/src/pages/blog/index.astro
+++ b/src/pages/blog/index.astro
@@ -4,7 +4,7 @@ import { getCollection, getEntries, type CollectionEntry } from "astro:content";
 import GalleryLayout from "@layouts/GalleryLayout.astro";
 import UserComponent from "@components/UserComponent.astro";
 import { markdownToPlaintext } from "@utils/markdown_to_plaintext";
-import { IconSquareRSS } from "@components/icons";
+import { IconNoOneUnder18, IconSquareRSS } from "@components/icons";
 
 type PostWithPubDate = CollectionEntry<"blog"> & { data: { pubDate: Date } };
 
@@ -55,6 +55,11 @@ const posts = await Promise.all(
                 {post.data.title}
               </span>
               <br />
+              {post.data.isAgeRestricted ? (
+                <span class="inline-block align-middle" aria-label="Age restricted">
+                  <IconNoOneUnder18 width="1.25rem" height="1.25rem" />
+                </span>
+              ) : null}
               <time
                 class="dt-published italic"
                 datetime={post.data.pubDate.toISOString().slice(0, 10)}
diff --git a/src/pages/games/index.astro b/src/pages/games/index.astro
index 900bc91..0fb828d 100644
--- a/src/pages/games/index.astro
+++ b/src/pages/games/index.astro
@@ -4,7 +4,7 @@ import { getCollection, getEntries, type CollectionEntry } from "astro:content";
 import GalleryLayout from "@layouts/GalleryLayout.astro";
 import { t } from "@i18n";
 import UserComponent from "@components/UserComponent.astro";
-import { IconSquareRSS } from "@components/icons";
+import { IconNoOneUnder18, IconSquareRSS } from "@components/icons";
 
 type GameWithPubDate = CollectionEntry<"games"> & { data: { pubDate: Date } };
 
@@ -55,6 +55,11 @@ const games = await Promise.all(
                 {game.data.title}
               </span>
               <br />
+              {game.data.isAgeRestricted ? (
+                <span class="inline-block align-middle" aria-label="Age restricted">
+                  <IconNoOneUnder18 width="1.25rem" height="1.25rem" />
+                </span>
+              ) : null}
               <time
                 class="dt-published italic"
                 datetime={game.data.pubDate.toISOString().slice(0, 10)}
diff --git a/src/pages/index.astro b/src/pages/index.astro
index 4b0ccf4..151ba87 100644
--- a/src/pages/index.astro
+++ b/src/pages/index.astro
@@ -6,12 +6,13 @@ import GalleryLayout from "@layouts/GalleryLayout.astro";
 import { t, type Lang } from "@i18n";
 import UserComponent from "@components/UserComponent.astro";
 import { markdownToPlaintext } from "@utils/markdown_to_plaintext";
-import { IconSquareRSS } from "@components/icons";
+import { IconNoOneUnder18, IconSquareRSS } from "@components/icons";
 
 const MAX_ITEMS = 10;
 
 interface LatestItemsEntry {
   type: string;
+  isAgeRestricted: boolean;
   thumbnail?: ImageMetadata;
   href: string;
   title: string;
@@ -49,6 +50,7 @@ const latestItems: LatestItemsEntry[] = await Promise.all(
       fn: async () =>
         ({
           type: "Story",
+          isAgeRestricted: story.data.isAgeRestricted,
           thumbnail: story.data.thumbnail,
           href: `/stories/${story.slug}`,
           title: story.data.title,
@@ -63,6 +65,7 @@ const latestItems: LatestItemsEntry[] = await Promise.all(
       fn: async () =>
         ({
           type: "Game",
+          isAgeRestricted: game.data.isAgeRestricted,
           thumbnail: game.data.thumbnail,
           href: `/games/${game.slug}`,
           title: game.data.title,
@@ -77,6 +80,7 @@ const latestItems: LatestItemsEntry[] = await Promise.all(
       fn: async () =>
         ({
           type: "Blog post",
+          isAgeRestricted: post.data.isAgeRestricted,
           thumbnail: post.data.thumbnail,
           href: `/blog/${post.slug}`,
           title: post.data.title,
@@ -140,6 +144,11 @@ const latestItems: LatestItemsEntry[] = await Promise.all(
                 </span>
                 <br />
                 <span class="italic">
+                  {entry.isAgeRestricted ? (
+                    <span class="inline-block align-middle" aria-label="Age restricted">
+                      <IconNoOneUnder18 width="1.25rem" height="1.25rem" />
+                    </span>
+                  ) : null}
                   <span class="p-category" aria-label="Category">
                     {entry.type}
                   </span>{" "}
diff --git a/src/pages/stories/[...page].astro b/src/pages/stories/[...page].astro
index 9e2ab30..149ce04 100644
--- a/src/pages/stories/[...page].astro
+++ b/src/pages/stories/[...page].astro
@@ -5,7 +5,7 @@ import { getCollection, getEntries, type CollectionEntry } from "astro:content";
 import GalleryLayout from "@layouts/GalleryLayout.astro";
 import { t } from "@i18n";
 import UserComponent from "@components/UserComponent.astro";
-import { IconSquareRSS } from "@components/icons";
+import { IconNoOneUnder18, IconSquareRSS } from "@components/icons";
 
 type StoryWithPubDate = CollectionEntry<"stories"> & { data: { pubDate: Date } };
 
@@ -111,6 +111,11 @@ const totalPages = Math.ceil(page.total / page.size);
                 {story.data.title}
               </span>
               <br />
+              {story.data.isAgeRestricted ? (
+                <span class="inline-block align-middle" aria-label="Age restricted">
+                  <IconNoOneUnder18 width="1.25rem" height="1.25rem" />
+                </span>
+              ) : null}
               <time
                 class="dt-published italic"
                 datetime={story.data.pubDate.toISOString().slice(0, 10)}
diff --git a/src/pages/tags/[slug].astro b/src/pages/tags/[slug].astro
index 538b21c..26ad01d 100644
--- a/src/pages/tags/[slug].astro
+++ b/src/pages/tags/[slug].astro
@@ -10,6 +10,7 @@ import { qualifyLocalURLsInMarkdown } from "@utils/qualify_local_urls_in_markdow
 import { markdownToPlaintext } from "@utils/markdown_to_plaintext";
 import Prose from "@components/Prose.astro";
 import UserComponent from "@components/UserComponent.astro";
+import { IconNoOneUnder18 } from "@components/icons";
 
 type EntryWithPubDate<C extends CollectionKey> = CollectionEntry<C> & { data: { pubDate: Date } };
 
@@ -179,6 +180,11 @@ const totalWorksWithTag = t(
                     {story.data.title}
                   </span>
                   <br />
+                  {story.data.isAgeRestricted ? (
+                    <span class="inline-block align-middle" aria-label="Age restricted">
+                      <IconNoOneUnder18 width="1.25rem" height="1.25rem" />
+                    </span>
+                  ) : null}
                   <time
                     class="dt-published italic"
                     datetime={story.data.pubDate.toISOString().slice(0, 10)}
@@ -242,6 +248,11 @@ const totalWorksWithTag = t(
                     {game.data.title}
                   </span>
                   <br />
+                  {game.data.isAgeRestricted ? (
+                    <span class="inline-block align-middle" aria-label="Age restricted">
+                      <IconNoOneUnder18 width="1.25rem" height="1.25rem" />
+                    </span>
+                  ) : null}
                   <time
                     class="dt-published italic"
                     datetime={game.data.pubDate.toISOString().slice(0, 10)}
@@ -301,6 +312,11 @@ const totalWorksWithTag = t(
                     {post.data.title}
                   </span>
                   <br />
+                  {post.data.isAgeRestricted ? (
+                    <span class="inline-block align-middle" aria-label="Age restricted">
+                      <IconNoOneUnder18 width="1.25rem" height="1.25rem" />
+                    </span>
+                  ) : null}
                   <time
                     class="dt-published italic"
                     datetime={post.data.pubDate.toISOString().slice(0, 10)}