From d022fab5d6207502a1b5a886d2418c1b7bc97778 Mon Sep 17 00:00:00 2001
From: Bad Manners <me@badmanners.xyz>
Date: Sun, 18 Aug 2024 22:52:45 -0300
Subject: [PATCH] Improvements to a11y and scripts

---
 .prettierignore                          |   2 +-
 package-lock.json                        |   4 +-
 package.json                             |   2 +-
 src/components/AgeRestrictedModal.astro  |  48 +++--
 src/components/AutoDarkMode.astro        |   4 +
 src/components/DarkModeScript.astro      |   7 +-
 src/components/MastodonComments.astro    | 260 ++++++++++++++---------
 src/content/config.ts                    |   8 +-
 src/i18n/index.ts                        |  20 +-
 src/layouts/GalleryLayout.astro          |  15 +-
 src/layouts/PublishedContentLayout.astro |  11 +-
 src/pages/api/export-story/[...slug].ts  |   2 +-
 src/pages/games.astro                    |  30 ++-
 src/pages/index.astro                    |  34 ++-
 src/pages/stories/[page].astro           |  34 ++-
 src/pages/tags.astro                     |   2 +-
 src/pages/tags/[slug].astro              | 115 +++++++---
 17 files changed, 384 insertions(+), 214 deletions(-)
 create mode 100644 src/components/AutoDarkMode.astro

diff --git a/.prettierignore b/.prettierignore
index 768bd26..a37bf70 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -1,3 +1,3 @@
-src/components/DarkModeScript.astro
+src/components/AutoDarkMode.astro
 .astro/
 dist/
diff --git a/package-lock.json b/package-lock.json
index 7ee01e3..ae4a1db 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "gallery.badmanners.xyz",
-  "version": "1.7.5",
+  "version": "1.7.6",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "gallery.badmanners.xyz",
-      "version": "1.7.5",
+      "version": "1.7.6",
       "hasInstallScript": true,
       "dependencies": {
         "@astrojs/check": "^0.9.2",
diff --git a/package.json b/package.json
index a9deb33..6d5ad19 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
 {
   "name": "gallery.badmanners.xyz",
   "type": "module",
-  "version": "1.7.5",
+  "version": "1.7.6",
   "scripts": {
     "postinstall": "astro sync",
     "dev": "astro dev",
diff --git a/src/components/AgeRestrictedModal.astro b/src/components/AgeRestrictedModal.astro
index 6d85e6b..62ac4a3 100644
--- a/src/components/AgeRestrictedModal.astro
+++ b/src/components/AgeRestrictedModal.astro
@@ -53,30 +53,34 @@
 <script>
   (function () {
     if (localStorage.getItem("ageVerified") !== "true") {
-      document.body.appendChild(
-        document
-          .querySelector<HTMLElementTagNameMap["template"]>("template#template-modal-age-restricted")!
-          .content.cloneNode(true),
+      const fragment = document
+        .querySelector<HTMLElementTagNameMap["template"]>("template#template-modal-age-restricted")!
+        .content.cloneNode(true) as DocumentFragment;
+      const modal = fragment.firstElementChild as HTMLElementTagNameMap["div"];
+      const controller = new AbortController();
+      const { signal } = controller;
+      modal.querySelector<HTMLElementTagNameMap["button"]>("button[data-modal-reject]")!.addEventListener(
+        "click",
+        (e: MouseEvent) => {
+          e.preventDefault();
+          location.href = "about:blank";
+        },
+        { signal },
+      );
+      modal.querySelector<HTMLElementTagNameMap["button"]>("button[data-modal-accept]")!.addEventListener(
+        "click",
+        (e: MouseEvent) => {
+          e.preventDefault();
+          controller.abort();
+          localStorage.setItem("ageVerified", "true");
+          document.body.style.overflow = "auto";
+          modal.remove();
+        },
+        { signal },
       );
-      const modal = document.querySelector<HTMLElementTagNameMap["div"]>("body > div#modal-age-restricted")!;
-      const rejectButton = modal.querySelector<HTMLElementTagNameMap["button"]>("button[data-modal-reject]")!;
-      const acceptButton = modal.querySelector<HTMLElementTagNameMap["button"]>("button[data-modal-accept]")!;
-      function onRejectButtonClick(e: MouseEvent) {
-        e.preventDefault();
-        location.href = "about:blank";
-      }
-      function onAcceptButtonClick(e: MouseEvent) {
-        e.preventDefault();
-        rejectButton.removeEventListener("click", onRejectButtonClick);
-        acceptButton.removeEventListener("click", onAcceptButtonClick);
-        localStorage.setItem("ageVerified", "true");
-        document.body.style.overflow = "auto";
-        modal.remove();
-      }
-      rejectButton.addEventListener("click", onRejectButtonClick);
-      acceptButton.addEventListener("click", onAcceptButtonClick);
       document.body.style.overflow = "hidden";
-      rejectButton.focus();
+      document.body.appendChild(fragment);
+      document.querySelector<HTMLElementTagNameMap["button"]>("button[data-modal-reject]")?.focus();
     }
   })();
 </script>
diff --git a/src/components/AutoDarkMode.astro b/src/components/AutoDarkMode.astro
new file mode 100644
index 0000000..67423e2
--- /dev/null
+++ b/src/components/AutoDarkMode.astro
@@ -0,0 +1,4 @@
+---
+---
+
+<script is:inline>!function(){var a="dark",b="auto",c="colorScheme",d=document.body.classList,e=localStorage,f=e.getItem(c);f&&f!==b?f===a&&d.add(a):(e.setItem(c,b),matchMedia("(prefers-color-scheme: dark)").matches&&d.add(a))}();</script>
diff --git a/src/components/DarkModeScript.astro b/src/components/DarkModeScript.astro
index 5dda7eb..16645fc 100644
--- a/src/components/DarkModeScript.astro
+++ b/src/components/DarkModeScript.astro
@@ -1,8 +1,8 @@
 ---
+import DarkModeInline from "./AutoDarkMode.astro";
+---
 
----
-
-<script is:inline>!function(){var a="dark",b="auto",c="colorScheme",d=document.body.classList,e=localStorage,f=e.getItem(c);f&&f!==b?f===a&&d.add(a):(e.setItem(c,b),matchMedia("(prefers-color-scheme: dark)").matches&&d.add(a))}();</script>
+<DarkModeInline />
 
 <script>
   (function () {
@@ -12,6 +12,7 @@
     }
     document.querySelectorAll<HTMLElementTagNameMap["button"]>("button[data-dark-mode]").forEach((button) => {
       button.classList.remove("hidden");
+      button.style.removeProperty("display");
       button.setAttribute("aria-hidden", "false");
       button.addEventListener("click", (e) => {
         e.preventDefault();
diff --git a/src/components/MastodonComments.astro b/src/components/MastodonComments.astro
index 94c7048..5467e4e 100644
--- a/src/components/MastodonComments.astro
+++ b/src/components/MastodonComments.astro
@@ -13,55 +13,70 @@ const { link, instance, user, postId } = Astro.props;
 ---
 
 <section
-  id="comment-section"
+  id="comments-section"
   class="px-2 font-serif"
-  aria-describedby="title-comment-section"
+  aria-describedby="title-comments-section"
   data-link={link}
   data-instance={instance}
   data-user={user}
   data-post-id={postId}
 >
-  <h2 id="title-comment-section" class="py-2 font-serif text-xl font-semibold text-stone-800 dark:text-stone-100">
+  <h2 id="title-comments-section" class="py-2 font-serif text-xl font-semibold text-stone-800 dark:text-stone-100">
     Comments
   </h2>
-  <div class="text-stone-800 dark:text-stone-100" id="comments">
-    <p class="my-1">
-      <a class="text-link underline" href={link} target="_blank">View comments on Mastodon</a>.
-    </p>
-  </div>
-</section>
-
-<template id="template-button">
+  <p id="comments-description" class="my-1 text-stone-800 dark:text-stone-100">
+    <span data-noscript
+      ><a class="u-syndication text-link underline" href={link} target="_blank">View comments on Mastodon</a>.</span
+    >
+    <span style={{ display: "none" }} data-no-comments
+      >No comments yet. <a class="text-link underline" href={link} target="_blank"
+        >Be the first to join the conversation on Mastodon</a
+      >.</span
+    >
+    <span style={{ display: "none" }} data-comments
+      >Join the conversation <a class="text-link underline" href={link} target="_blank">by replying on Mastodon</a
+      >.</span
+    >
+    <span style={{ display: "none" }} data-error>Unable to load comments. Please try again later.</span>
+  </p>
   <button
-    class="mx-auto w-64 rounded-lg bg-bm-300 px-4 py-1 underline disabled:bg-bm-400 disabled:no-underline dark:bg-green-800 dark:disabled:bg-green-900"
+    style={{ display: "none" }}
+    class="group mx-auto w-64 rounded-lg bg-bm-300 px-4 py-1 text-stone-800 disabled:bg-bm-400 dark:bg-green-800 dark:text-stone-100 dark:disabled:bg-green-900"
     id="load-comments-button"
   >
-    <span>Click to load comments</span>
+    <span class="block underline group-disabled:hidden">Click to load comments</span>
+    <span class="hidden group-disabled:block">
+      <svg
+        style={{ width: "1.25rem", height: "1.25rem", display: "inline" }}
+        class="-mt-1 mr-1 animate-spin"
+        fill="none"
+        viewBox="0 0 24 24"
+        aria-hidden="true"
+      >
+        <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
+        <path
+          class="opacity-100"
+          fill="currentColor"
+          d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
+        ></path>
+      </svg>
+      Loading...
+    </span>
   </button>
-</template>
+  <div id="comments" style={{ display: "none" }}></div>
+</section>
 
-<template id="template-button-loading">
-  <svg
-    style={{ width: "1.25rem", height: "1.25rem", display: "inline" }}
-    class="-mt-1 mr-1 animate-spin"
-    fill="none"
-    viewBox="0 0 24 24"
-    aria-hidden="true"
-  >
-    <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
-    <path
-      class="opacity-100"
-      fill="currentColor"
-      d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
-    ></path>
-  </svg>
-  <span>Loading...</span>
+<template id="template-comment-emoji">
+  <picture>
+    <source media="(prefers-reduced-motion: reduce)" />
+    <img width={20} class="mx-[1px] inline" />
+  </picture>
 </template>
 
 <template id="template-comment-box">
   <div
     role="article"
-    class="p-comment h-entry my-2 rounded-md border-2 border-stone-400 bg-stone-100 p-2 dark:border-stone-600 dark:bg-stone-800"
+    class="p-comment h-entry my-2 rounded-md border-2 border-stone-400 bg-stone-100 p-2 text-stone-800 dark:border-stone-600 dark:bg-stone-800 dark:text-stone-100"
   >
     <div class="ml-1">
       <a
@@ -69,18 +84,27 @@ const { link, instance, user, postId } = Astro.props;
         class="p-author h-card u-url text-link flex items-center text-lg hover:underline focus:underline"
         target="_blank"
       >
-        <img data-avatar class="u-photo mr-2 w-10 rounded-full border border-stone-400 dark:border-stone-600" />
-        <span data-display-name class="p-nickname"></span>
+        <picture>
+          <source data-avatar-static media="(prefers-reduced-motion: reduce)" />
+          <img data-avatar width={40} class="u-photo mr-2 rounded-full border border-stone-400 dark:border-stone-600" />
+        </picture>
+        <span data-display-name class="p-nickname" aria-label="Display name"></span>
       </a>
       <a
         data-post-link
         class="u-url text-link my-1 flex items-center text-sm font-light hover:underline focus:underline"
         target="_blank"
       >
-        <time class="dt-published mr-1" data-publish-date aria-label="Publish date"></time>
+        <time class="dt-published mr-1" data-published-date aria-label="Publish date"></time>
+        <time style={{ display: "none" }} class="italic" data-edited-date>(edited)</time>
       </a>
     </div>
-    <div data-content class="e-content prose-a:text-link prose-story prose my-1 dark:prose-invert prose-img:my-0"></div>
+    <div
+      data-content
+      class="e-content prose-a:text-link prose-story prose my-1 dark:prose-invert prose-img:my-0"
+      aria-label="Comment body"
+    >
+    </div>
     <div class="ml-1 flex flex-row pb-2 pt-1">
       <div class="flex" aria-label="Favorites">
         <span data-favorites></span>
@@ -99,25 +123,25 @@ const { link, instance, user, postId } = Astro.props;
         </svg>
       </div>
     </div>
-    <div data-comment-thread class="-mb-2" aria-hidden="true"></div>
+    <div data-comment-thread class="-mb-2" aria-hidden="true" aria-label="Replies"></div>
   </div>
 </template>
 
 <script>
-  interface Post {
+  interface MastodonPost {
     link: string;
     instance: string;
     user: string;
     postId: string;
   }
 
-  interface Emoji {
+  interface CustomEmoji {
     shortcode: string;
     url: string;
     static_url: string;
   }
 
-  interface Comment {
+  interface Status {
     id: string;
     in_reply_to_id: string;
     url: string;
@@ -132,85 +156,107 @@ const { link, instance, user, postId } = Astro.props;
       url: string;
       avatar: string;
       avatar_static: string;
-      emojis: Emoji[];
+      emojis: CustomEmoji[];
     };
     content: string;
-    emojis: Emoji[];
+    emojis: CustomEmoji[];
   }
 
-  interface ApiResponse {
-    ancestors: Comment[];
-    descendants: Comment[];
+  interface Context {
+    ancestors: Status[];
+    descendants: Status[];
   }
 
   (function () {
-    function replaceEmojis(text: string, emojis: Emoji[]) {
-      return emojis.reduce(
-        (acc, emoji) =>
-          acc.replaceAll(
-            `:${emoji.shortcode}:`,
-            `<img class="inline mx-[1px] w-5" alt=":${emoji.shortcode}: emoji" src="${emoji.url}" />`,
-          ),
-        text,
-      );
-    }
-
-    async function renderComments(section: Element, post: Post) {
+    async function renderComments(section: Element, post: MastodonPost) {
+      const commentsDescription = section.querySelector<HTMLElementTagNameMap["p"]>("p#comments-description")!;
+      const loadCommentsButton = section.querySelector<HTMLElementTagNameMap["button"]>("button#load-comments-button")!;
       const commentsDiv = section.querySelector<HTMLElementTagNameMap["div"]>("div#comments")!;
       try {
         const response = await fetch(`https://${post.instance}/api/v1/statuses/${post.postId}/context`);
         if (!response.ok) {
           throw new Error(`Received error status ${response.status} - ${response.statusText}!`);
         }
-        const data: ApiResponse = await response.json();
+        const data: Context = await response.json();
 
-        const commentsList: HTMLElement[] = [];
+        const emojiTemplate = document.querySelector<HTMLElementTagNameMap["template"]>(
+          "template#template-comment-emoji",
+        )!;
+        const emojiMap: Record<string, string> = {};
+        const replaceEmojis = (text: string, emojis: CustomEmoji[]) =>
+          emojis.reduce((acc, emoji) => {
+            let emojiHTML = emojiMap[emoji.url];
+            if (!emojiHTML) {
+              const emojiPicture = emojiTemplate.content.cloneNode(true) as DocumentFragment;
+              const emojiStatic = emojiPicture.querySelector("source")!;
+              emojiStatic.srcset = emoji.static_url;
+              const emojiImg = emojiPicture.querySelector("img")!;
+              emojiImg.src = emoji.url;
+              emojiImg.alt = `:${emoji.shortcode}: emoji`;
+              emojiHTML = emojiPicture.firstElementChild!.outerHTML;
+              emojiMap[emoji.url] = emojiHTML;
+            }
+            return acc.replaceAll(`:${emoji.shortcode}:`, emojiHTML);
+          }, text);
+        const commentsList: DocumentFragment[] = [];
         const commentMap: Record<string, number> = {};
         const commentTemplate = document.querySelector<HTMLElementTagNameMap["template"]>(
           "template#template-comment-box",
         )!;
+
         data.descendants.forEach((comment) => {
-          const commentBox = commentTemplate.content.cloneNode(true) as HTMLDivElement;
-          commentBox.id = `comment-${comment.id}`;
+          const commentBox = commentTemplate.content.cloneNode(true) as DocumentFragment;
+          commentBox.firstElementChild!.id = `comment-${comment.id}`;
 
           const commentBoxAuthor = commentBox.querySelector<HTMLElementTagNameMap["a"]>("a[data-author]")!;
           commentBoxAuthor.href = comment.account.url;
+          commentBoxAuthor.title = comment.account.acct;
+          commentBoxAuthor.setAttribute("aria-label", comment.account.acct);
           const avatar = commentBoxAuthor.querySelector<HTMLElementTagNameMap["img"]>("img[data-avatar]")!;
           avatar.src = comment.account.avatar;
-          avatar.alt = `Avatar of ${comment.account.username}`;
+          avatar.alt = `Avatar of @${comment.account.acct}`;
+          const avatarStatic =
+            commentBoxAuthor.querySelector<HTMLElementTagNameMap["source"]>("source[data-avatar-static]")!;
+          avatarStatic.srcset = comment.account.avatar_static;
           const displayName = commentBoxAuthor.querySelector<HTMLElementTagNameMap["span"]>("span[data-display-name]")!;
-          displayName.innerHTML = replaceEmojis(comment.account.display_name, comment.account.emojis);
+          displayName.insertAdjacentHTML(
+            "afterbegin",
+            replaceEmojis(comment.account.display_name, comment.account.emojis),
+          );
 
           const commentBoxPostLink = commentBox.querySelector<HTMLElementTagNameMap["a"]>("a[data-post-link]")!;
           commentBoxPostLink.href = comment.url;
           const publishDate =
-            commentBoxPostLink.querySelector<HTMLElementTagNameMap["time"]>("time[data-publish-date]")!;
-          publishDate.setAttribute("datetime", comment.created_at);
-          publishDate.innerText = new Date(Date.parse(comment.created_at)).toLocaleString("en-US", {
-            month: "short",
-            day: "numeric",
-            year: "numeric",
-            hour: "2-digit",
-            minute: "2-digit",
-          });
+            commentBoxPostLink.querySelector<HTMLElementTagNameMap["time"]>("time[data-published-date]")!;
+          publishDate.dateTime = comment.created_at;
+          publishDate.insertAdjacentText(
+            "afterbegin",
+            new Date(Date.parse(comment.created_at)).toLocaleString("en-US", {
+              month: "short",
+              day: "numeric",
+              year: "numeric",
+              hour: "2-digit",
+              minute: "2-digit",
+            }),
+          );
 
           if (comment.edited_at) {
-            const edited = document.createElement("time");
-            edited.className = "dt-updated italic";
-            edited.setAttribute("datetime", comment.edited_at);
-            edited.setAttribute("title", comment.edited_at);
-            edited.innerText = "(edited)";
-            commentBoxPostLink.appendChild(edited);
+            const edited = commentBoxPostLink.querySelector<HTMLElementTagNameMap["time"]>("time[data-edited-date]")!;
+            edited.dateTime = comment.edited_at;
+            edited.title = comment.edited_at;
+            edited.classList.remove("hidden");
+            edited.classList.add("dt-updated");
+            edited.style.removeProperty("display");
           }
 
           const commentBoxContent = commentBox.querySelector<HTMLElementTagNameMap["div"]>("div[data-content]")!;
-          commentBoxContent.innerHTML = replaceEmojis(comment.content, comment.emojis);
+          commentBoxContent.insertAdjacentHTML("afterbegin", replaceEmojis(comment.content, comment.emojis));
 
           const commentBoxFavorites = commentBox.querySelector<HTMLElementTagNameMap["span"]>("span[data-favorites]")!;
-          commentBoxFavorites.innerText = comment.favourites_count.toString();
+          commentBoxFavorites.insertAdjacentText("afterbegin", comment.favourites_count.toString());
 
           const commentBoxReblogs = commentBox.querySelector<HTMLElementTagNameMap["span"]>("span[data-reblogs]")!;
-          commentBoxReblogs.innerText = comment.reblogs_count.toString();
+          commentBoxReblogs.insertAdjacentText("afterbegin", comment.reblogs_count.toString());
 
           if (comment.in_reply_to_id === post.postId || !(comment.in_reply_to_id in commentMap)) {
             commentMap[comment.id] = commentsList.length;
@@ -221,24 +267,36 @@ const { link, instance, user, postId } = Astro.props;
             const parentThreadDiv =
               commentsList[commentsIndex].querySelector<HTMLElementTagNameMap["div"]>("div[data-comment-thread]")!;
             parentThreadDiv.setAttribute("aria-hidden", "false");
-            parentThreadDiv.setAttribute("aria-label", "Replies");
             parentThreadDiv.appendChild(commentBox);
           }
         });
-        if (commentsList.length === 0) {
-          commentsDiv.innerHTML = `<p class="my-1">No comments yet. <a class="text-link underline" href="${post.link}" target="_blank">Be the first to join the conversation on Mastodon</a>.</p>`;
+        if (commentsList.length) {
+          const fragment = document.createDocumentFragment();
+          commentsList.forEach((comment) => fragment.appendChild(comment));
+          commentsDiv.appendChild(fragment);
+          commentsDescription
+            .querySelector<HTMLElementTagNameMap["span"]>("span[data-comments]")!
+            .style.removeProperty("display");
+          commentsDiv.style.removeProperty("display");
         } else {
-          commentsDiv.innerHTML = `<p class="my-1">Join the conversation <a class="text-link underline" href="${post.link}" target="_blank">by replying on Mastodon</a>.</p>`;
-          commentsDiv.append(...commentsList);
+          commentsDescription
+            .querySelector<HTMLElementTagNameMap["span"]>("span[data-no-comments]")!
+            .style.removeProperty("display");
         }
       } catch (e) {
-        commentsDiv.innerHTML = `<p class="my-1">Unable to load comments. Please try again later.</p>`;
         console.error("Fetch Mastodon comments error", e);
+        commentsDescription
+          .querySelector<HTMLElementTagNameMap["span"]>("span[data-error]")!
+          .style.removeProperty("display");
+      } finally {
+        loadCommentsButton.style.display = "none";
+        loadCommentsButton.blur();
+        commentsDescription.style.removeProperty("display");
       }
     }
 
     function initCommentSection() {
-      const commentSection = document.querySelector<HTMLElementTagNameMap["section"]>("section#comment-section");
+      const commentSection = document.querySelector<HTMLElementTagNameMap["section"]>("section#comments-section");
       if (!commentSection) {
         return;
       }
@@ -251,21 +309,21 @@ const { link, instance, user, postId } = Astro.props;
       if (!post.link || !post.instance || !post.user || !post.postId) {
         return;
       }
-      const commentsContainer = commentSection.querySelector<HTMLElementTagNameMap["div"]>("div#comments")!;
-      commentsContainer.replaceChildren(
-        document.querySelector<HTMLElementTagNameMap["template"]>("template#template-button")!.content.cloneNode(true),
+      const loadCommentsButton =
+        commentSection.querySelector<HTMLElementTagNameMap["button"]>("button#load-comments-button")!;
+      loadCommentsButton.addEventListener(
+        "click",
+        (e) => {
+          e.preventDefault();
+          loadCommentsButton.disabled = true;
+          renderComments(commentSection, post as MastodonPost);
+        },
+        { once: true },
       );
-      const loadCommentsButton = commentsContainer.querySelector("button")!;
-      loadCommentsButton.addEventListener("click", (e) => {
-        e.preventDefault();
-        loadCommentsButton.setAttribute("disabled", "true");
-        loadCommentsButton.replaceChildren(
-          document
-            .querySelector<HTMLElementTagNameMap["template"]>("template#template-button-loading")!
-            .content.cloneNode(true),
-        );
-        renderComments(commentSection, post as Post);
-      });
+      const commentsDescription = commentSection.querySelector<HTMLElementTagNameMap["p"]>("p#comments-description")!;
+      commentsDescription.style.display = "none";
+      commentsDescription.querySelector<HTMLElementTagNameMap["span"]>("span[data-noscript]")!.style.display = "none";
+      loadCommentsButton.style.removeProperty("display");
     }
 
     initCommentSection();
diff --git a/src/content/config.ts b/src/content/config.ts
index 71c8b51..ed8b506 100644
--- a/src/content/config.ts
+++ b/src/content/config.ts
@@ -133,7 +133,7 @@ const userList = z
 const copyrightedCharacters = z
   .record(z.string(), reference("users"))
   .refine(
-    (value) => !("" in value) || Object.keys(value).length == 1,
+    (value) => !("" in value) || Object.keys(value).length === 1,
     `"copyrightedCharacters" cannot mix empty catch-all key with other keys`,
   )
   .default({});
@@ -145,7 +145,7 @@ const publishedContent = z.object({
   title: z.string(),
   authors: userList,
   contentWarning: z.string().trim(),
-  // Required parameters, but optional for drafts (isDraft == true)
+  // Required parameters, but optional for drafts (isDraft === true)
   pubDate: z
     .date()
     .transform((date: Date) => new Date(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 0, 0, 0))
@@ -230,7 +230,7 @@ const storiesCollection = defineCollection({
   schema: ({ image }) =>
     z
       .object({
-        // Required parameters, but optional for drafts (isDraft == true)
+        // Required parameters, but optional for drafts (isDraft === true)
         wordCount: z.number().int().optional(),
         thumbnail: image().optional(),
         // Optional parameters
@@ -260,7 +260,7 @@ const gamesCollection = defineCollection({
   schema: ({ image }) =>
     z
       .object({
-        // Required parameters, but optional for drafts (isDraft == true)
+        // Required parameters, but optional for drafts (isDraft === true)
         platforms: z.array(platform).default([]),
         thumbnail: image().optional(),
         // Optional parameters
diff --git a/src/i18n/index.ts b/src/i18n/index.ts
index 8fa7084..ccc56cc 100644
--- a/src/i18n/index.ts
+++ b/src/i18n/index.ts
@@ -7,7 +7,7 @@ const UI_STRINGS = {
     en: (names: string[]) =>
       names.length <= 1
         ? names.join("")
-        : names.length == 2
+        : names.length === 2
           ? `${names[0]} and ${names[1]}`
           : `${names.slice(0, -1).join(", ")}, and ${names[names.length - 1]}`,
     tok: (names: string[]) => names.join(" en "),
@@ -17,10 +17,10 @@ const UI_STRINGS = {
   },
   "util/enumerate": {
     en: (count: number, nounSingular: string, nounPlural?: string) => {
-      if (count == 0) {
+      if (count === 0) {
         return `no ${nounPlural ?? nounSingular}`;
       }
-      if (count == 1) {
+      if (count === 1) {
         return `one ${nounSingular}`;
       }
       return `${count} ${nounPlural ?? nounSingular}`;
@@ -123,14 +123,14 @@ const UI_STRINGS = {
     tok: "nimi lipu",
   },
   "story/authors_aria_label": {
-    en: (authors: any[]) => (authors.length == 1 ? "Author" : "Authors"),
+    en: (authors: any[]) => (authors.length === 1 ? "Author" : "Authors"),
     tok: (_authors: any[]) => "jan pi pali lipu",
   },
   "story/requesters_aria_label": {
-    en: (requesters: any[]) => (requesters.length == 1 ? "Requester" : "Requesters"),
+    en: (requesters: any[]) => (requesters.length === 1 ? "Requester" : "Requesters"),
   },
   "story/commissioners_aria_label": {
-    en: (commissioners: any[]) => (commissioners.length == 1 ? "Commissioner" : "Commissioners"),
+    en: (commissioners: any[]) => (commissioners.length === 1 ? "Commissioner" : "Commissioners"),
   },
   "story/warnings": {
     en: (wordCount: number | string | undefined, contentWarning: string) =>
@@ -182,7 +182,7 @@ const UI_STRINGS = {
   },
   "game/platforms": {
     en: (platforms: GamePlatform[]) => {
-      if (platforms.length == 0) {
+      if (platforms.length === 0) {
         return "";
       }
       const translatedPlatforms = platforms.map((platform) => {
@@ -238,9 +238,9 @@ const UI_STRINGS = {
   },
   "characters/characters_are_copyrighted_by": {
     en: (owner: string, charactersList: string[]) =>
-      charactersList.length == 0
+      charactersList.length === 0
         ? `All characters are © ${owner}`
-        : charactersList.length == 1
+        : charactersList.length === 1
           ? `${charactersList[0]} is © ${owner}`
           : `${UI_STRINGS["util/join_names"].en(charactersList)} are © ${owner}`,
   },
@@ -254,7 +254,7 @@ const UI_STRINGS = {
       if (gamesCount > 0) {
         content.push(UI_STRINGS["util/enumerate"].en(gamesCount, "game", "games"));
       }
-      if (content.length == 0) {
+      if (content.length === 0) {
         return `No works tagged with "${tag}".`;
       }
       return UI_STRINGS["util/capitalize"].en(`${UI_STRINGS["util/join_names"].en(content)} tagged with "${tag}".`);
diff --git a/src/layouts/GalleryLayout.astro b/src/layouts/GalleryLayout.astro
index 6c7a928..723d187 100644
--- a/src/layouts/GalleryLayout.astro
+++ b/src/layouts/GalleryLayout.astro
@@ -42,7 +42,7 @@ const currentYear = new Date().getFullYear().toString();
       <div class="pt-4 text-center text-xs text-black dark:text-white">
         <span
           >&copy; {
-            currentYear == "2024" ? (
+            currentYear === "2024" ? (
               <time datetime="2024">2024</time>
             ) : (
               <>
@@ -64,7 +64,7 @@ const currentYear = new Date().getFullYear().toString();
               d="M575.8 255.5c0 18-15 32.1-32 32.1h-32l.7 160.2c0 2.7-.2 5.4-.5 8.1V472c0 22.1-17.9 40-40 40H456c-1.1 0-2.2 0-3.3-.1c-1.4 .1-2.8 .1-4.2 .1H416 392c-22.1 0-40-17.9-40-40V448 384c0-17.7-14.3-32-32-32H256c-17.7 0-32 14.3-32 32v64 24c0 22.1-17.9 40-40 40H160 128.1c-1.5 0-3-.1-4.5-.2c-1.2 .1-2.4 .2-3.6 .2H104c-22.1 0-40-17.9-40-40V360c0-.9 0-1.9 .1-2.8V287.6H32c-18 0-32-14-32-32.1c0-9 3-17 10-24L266.4 8c7-7 15-8 22-8s15 2 21 7L564.8 231.5c8 7 12 15 11 24z"
             ></path>
           </svg>
-          <span id="label-main-website" class="hidden">Main website</span>
+          <span id="label-main-website" class="sr-only">Main website</span>
         </a>
         <a class="text-link p-1" href="/feed.xml" target="_blank" aria-labelledby="label-rss-feed">
           <svg
@@ -76,9 +76,14 @@ const currentYear = new Date().getFullYear().toString();
               d="M64 32C28.7 32 0 60.7 0 96V416c0 35.3 28.7 64 64 64H384c35.3 0 64-28.7 64-64V96c0-35.3-28.7-64-64-64H64zM96 136c0-13.3 10.7-24 24-24c137 0 248 111 248 248c0 13.3-10.7 24-24 24s-24-10.7-24-24c0-110.5-89.5-200-200-200c-13.3 0-24-10.7-24-24zm0 96c0-13.3 10.7-24 24-24c83.9 0 152 68.1 152 152c0 13.3-10.7 24-24 24s-24-10.7-24-24c0-57.4-46.6-104-104-104c-13.3 0-24-10.7-24-24zm0 120a32 32 0 1 1 64 0 32 32 0 1 1 -64 0z"
             ></path>
           </svg>
-          <span id="label-rss-feed" class="hidden">RSS feed</span>
+          <span id="label-rss-feed" class="sr-only">RSS feed</span>
         </a>
-        <button data-dark-mode class="text-link hidden p-1" aria-labelledby="label-toggle-dark-mode" aria-hidden="true">
+        <button
+          data-dark-mode
+          style={{ display: "none" }}
+          class="text-link p-1"
+          aria-labelledby="label-toggle-dark-mode"
+        >
           <svg
             style={{ width: "1.5rem", height: "1.5rem", fill: "currentColor" }}
             viewBox="0 0 512 512"
@@ -99,7 +104,7 @@ const currentYear = new Date().getFullYear().toString();
               d="M223.5 32C100 32 0 132.3 0 256S100 480 223.5 480c60.6 0 115.5-24.2 155.8-63.4c5-4.9 6.3-12.5 3.1-18.7s-10.1-9.7-17-8.5c-9.8 1.7-19.8 2.6-30.1 2.6c-96.9 0-175.5-78.8-175.5-176c0-65.8 36-123.1 89.3-153.3c6.1-3.5 9.2-10.5 7.7-17.3s-7.3-11.9-14.3-12.5c-6.3-.5-12.6-.8-19-.8z"
             ></path>
           </svg>
-          <span id="label-toggle-dark-mode" class="hidden">{t("en", "published_content/toggle_dark_mode")}</span>
+          <span id="label-toggle-dark-mode" class="sr-only">{t("en", "published_content/toggle_dark_mode")}</span>
         </button>
       </div>
     </div>
diff --git a/src/layouts/PublishedContentLayout.astro b/src/layouts/PublishedContentLayout.astro
index 399e93f..00e7f4e 100644
--- a/src/layouts/PublishedContentLayout.astro
+++ b/src/layouts/PublishedContentLayout.astro
@@ -126,7 +126,7 @@ const thumbnail =
               d="M48.5 224H40c-13.3 0-24-10.7-24-24V72c0-9.7 5.8-18.5 14.8-22.2s19.3-1.7 26.2 5.2L98.6 96.6c87.6-86.5 228.7-86.2 315.8 1c87.5 87.5 87.5 229.3 0 316.8s-229.3 87.5-316.8 0c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0c62.5 62.5 163.8 62.5 226.3 0s62.5-163.8 0-226.3c-62.2-62.2-162.7-62.5-225.3-1L185 183c6.9 6.9 8.9 17.2 5.2 26.2s-12.5 14.8-22.2 14.8H48.5z"
             ></path>
           </svg>
-          <span class="hidden" id="label-return-to"
+          <span class="sr-only" id="label-return-to"
             >{
               series ? t(props.lang, "published_content/return_to_series", series.data.name) : props.labelReturnTo.title
             }</span
@@ -146,14 +146,15 @@ const thumbnail =
               d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM216 336h24V272H216c-13.3 0-24-10.7-24-24s10.7-24 24-24h48c13.3 0 24 10.7 24 24v88h8c13.3 0 24 10.7 24 24s-10.7 24-24 24H216c-13.3 0-24-10.7-24-24s10.7-24 24-24zm40-208a32 32 0 1 1 0 64 32 32 0 1 1 0-64z"
             ></path>
           </svg>
-          <span class="hidden" id="label-go-to-description">{t(props.lang, "published_content/go_to_description")}</span
+          <span class="sr-only" id="label-go-to-description"
+            >{t(props.lang, "published_content/go_to_description")}</span
           >
         </a>
         <button
           data-dark-mode
-          class="text-link my-1 hidden border-l border-stone-300 p-2 dark:border-stone-700"
+          style={{ display: "none" }}
+          class="text-link my-1 border-l border-stone-300 p-2 dark:border-stone-700"
           aria-labelledby="label-toggle-dark-mode"
-          aria-hidden="true"
         >
           <svg
             style={{ width: "1.25rem", height: "1.25rem", fill: "currentColor" }}
@@ -175,7 +176,7 @@ const thumbnail =
               d="M223.5 32C100 32 0 132.3 0 256S100 480 223.5 480c60.6 0 115.5-24.2 155.8-63.4c5-4.9 6.3-12.5 3.1-18.7s-10.1-9.7-17-8.5c-9.8 1.7-19.8 2.6-30.1 2.6c-96.9 0-175.5-78.8-175.5-176c0-65.8 36-123.1 89.3-153.3c6.1-3.5 9.2-10.5 7.7-17.3s-7.3-11.9-14.3-12.5c-6.3-.5-12.6-.8-19-.8z"
             ></path>
           </svg>
-          <span class="hidden" id="label-toggle-dark-mode">{t(props.lang, "published_content/toggle_dark_mode")}</span>
+          <span class="sr-only" id="label-toggle-dark-mode">{t(props.lang, "published_content/toggle_dark_mode")}</span>
         </button>
       </div>
     </div>
diff --git a/src/pages/api/export-story/[...slug].ts b/src/pages/api/export-story/[...slug].ts
index 1d70b59..b2982d2 100644
--- a/src/pages/api/export-story/[...slug].ts
+++ b/src/pages/api/export-story/[...slug].ts
@@ -78,7 +78,7 @@ export const GET: APIRoute<Props, Params> = async ({ props: { story }, site }) =
               commissionersList.map((commissioner) => u(commissioner)),
             ),
           ...copyrightedCharacters.map(({ user, characters }) =>
-            t(lang, "characters/characters_are_copyrighted_by", u(user), characters[0] == "" ? [] : characters),
+            t(lang, "characters/characters_are_copyrighted_by", u(user), characters[0] === "" ? [] : characters),
           ),
         ].reduce(async (promise, data) => {
           if (!data) {
diff --git a/src/pages/games.astro b/src/pages/games.astro
index fce5810..525efdb 100644
--- a/src/pages/games.astro
+++ b/src/pages/games.astro
@@ -2,7 +2,7 @@
 import { Image } from "astro:assets";
 import { getCollection, getEntries, type CollectionEntry } from "astro:content";
 import GalleryLayout from "../layouts/GalleryLayout.astro";
-import { DEFAULT_LANG, t } from "../i18n";
+import { t } from "../i18n";
 import UserComponent from "../components/UserComponent.astro";
 
 type GameWithPubDate = CollectionEntry<"games"> & { data: { pubDate: Date } };
@@ -24,7 +24,7 @@ const games = await Promise.all(
   <ul class="my-6 flex flex-wrap items-start justify-center gap-4 text-center md:justify-normal">
     {
       games.map((game, i) => (
-        <li class="h-entry">
+        <li class="h-entry" lang={game.data.lang}>
           <a
             class="u-url text-link hover:underline focus:underline"
             href={`/games/${game.slug}`}
@@ -42,17 +42,31 @@ const games = await Promise.all(
               </div>
             ) : null}
             <div class="max-w-[288px] text-sm">
-              <span class="p-name">{game.data.title}</span>
+              <span class="p-name" aria-label="Title">
+                {game.data.title}
+              </span>
               <br />
-              <time class="dt-published italic" datetime={game.data.pubDate.toISOString().slice(0, 10)}>
+              <time
+                class="dt-published italic"
+                datetime={game.data.pubDate.toISOString().slice(0, 10)}
+                aria-label="Publish date"
+              >
                 {game.data.pubDate.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
               </time>
             </div>
           </a>
-          <div style={{ display: "none" }}>
-            {game.authors.map((author) => (
-              <UserComponent rel="author" class="p-author" user={author} lang={DEFAULT_LANG} />
-            ))}
+          <div class="sr-only">
+            <p class="p-category" aria-label="Category">
+              Game
+            </p>
+            <p class="p-summary" aria-label="Summary">
+              {t(game.data.lang, "game/warnings", game.data.platforms, game.data.contentWarning)}
+            </p>
+            <div aria-label="Authors">
+              {game.authors.map((author) => (
+                <UserComponent rel="author" class="p-author" user={author} lang={game.data.lang} />
+              ))}
+            </div>
           </div>
         </li>
       ))
diff --git a/src/pages/index.astro b/src/pages/index.astro
index 952d61d..a95ccde 100644
--- a/src/pages/index.astro
+++ b/src/pages/index.astro
@@ -3,7 +3,7 @@ import type { ImageMetadata } from "astro";
 import { type CollectionEntry, type CollectionKey, getCollection, getEntries } from "astro:content";
 import { Image } from "astro:assets";
 import GalleryLayout from "../layouts/GalleryLayout.astro";
-import { DEFAULT_LANG, t, type Lang } from "../i18n";
+import { t, type Lang } from "../i18n";
 import UserComponent from "../components/UserComponent.astro";
 
 const MAX_ITEMS = 10;
@@ -60,7 +60,7 @@ const latestItems: LatestItemsEntry[] = await Promise.all(
           href: `/games/${game.slug}`,
           title: game.data.title,
           authors: await getEntries(game.data.authors),
-          lang: DEFAULT_LANG,
+          lang: game.data.lang,
           altText: t(game.data.lang, "game/warnings", game.data.platforms, game.data.contentWarning),
           pubDate: game.data.pubDate,
         }) satisfies LatestItemsEntry,
@@ -99,7 +99,7 @@ const latestItems: LatestItemsEntry[] = await Promise.all(
     <ul class="flex flex-wrap items-start justify-center gap-4 text-center md:justify-normal">
       {
         latestItems.map((entry) => (
-          <li class="h-entry break-inside-avoid">
+          <li class="h-entry break-inside-avoid" lang={entry.lang}>
             <a class="u-url text-link hover:underline focus:underline" href={entry.href} title={entry.altText}>
               {entry.thumbnail ? (
                 <div class="flex aspect-square max-w-[192px] justify-center">
@@ -113,20 +113,34 @@ const latestItems: LatestItemsEntry[] = await Promise.all(
                 </div>
               ) : null}
               <div class="max-w-[192px] text-sm">
-                <span class="p-name">{entry.title}</span>
+                <span class="p-name" aria-label="Title">
+                  {entry.title}
+                </span>
                 <br />
                 <span class="italic">
-                  <span class="p-category">{entry.type}</span> &ndash;{" "}
-                  <time class="dt-published" datetime={entry.pubDate.toISOString().slice(0, 10)}>
+                  <span class="p-category" aria-label="Category">
+                    {entry.type}
+                  </span>{" "}
+                  &ndash;{" "}
+                  <time
+                    class="dt-published"
+                    datetime={entry.pubDate.toISOString().slice(0, 10)}
+                    aria-label="Publish date"
+                  >
                     {entry.pubDate.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
                   </time>
                 </span>
               </div>
             </a>
-            <div style={{ display: "none" }}>
-              {entry.authors.map((author) => (
-                <UserComponent rel="author" class="p-author" user={author} lang={entry.lang} />
-              ))}
+            <div class="sr-only">
+              <p class="p-summary" aria-label="Summary">
+                {entry.altText}
+              </p>
+              <div aria-label="Authors">
+                {entry.authors.map((author) => (
+                  <UserComponent rel="author" class="p-author" user={author} lang={entry.lang} />
+                ))}
+              </div>
             </div>
           </li>
         ))
diff --git a/src/pages/stories/[page].astro b/src/pages/stories/[page].astro
index 3176da2..5038cea 100644
--- a/src/pages/stories/[page].astro
+++ b/src/pages/stories/[page].astro
@@ -35,7 +35,7 @@ const totalPages = Math.ceil(page.total / page.size);
     <p class="my-4">The bulk of my content!</p>
     <p class="text-center font-light text-stone-950 dark:text-white">
       {
-        page.start == page.end
+        page.start === page.end
           ? `Displaying story #${page.start + 1}`
           : `Displaying stories #${page.start + 1}–${page.end + 1}`
       } / {page.total}
@@ -53,7 +53,7 @@ const totalPages = Math.ceil(page.total / page.size);
       [...Array(totalPages).keys()]
         .map((p) => p + 1)
         .map((p) =>
-          p == page.currentPage ? (
+          p === page.currentPage ? (
             <span class="border-r border-stone-400 px-4 py-1 font-semibold text-stone-900 dark:border-stone-500 dark:text-stone-50">
               {p}
             </span>
@@ -78,7 +78,7 @@ const totalPages = Math.ceil(page.total / page.size);
   <ul class="flex flex-wrap items-start justify-center gap-4 text-center md:justify-normal">
     {
       page.data.map((story, i) => (
-        <li class="h-entry break-inside-avoid">
+        <li class="h-entry break-inside-avoid" lang={story.data.lang}>
           <a
             class="u-url text-link hover:underline focus:underline"
             href={`/stories/${story.slug}`}
@@ -96,17 +96,31 @@ const totalPages = Math.ceil(page.total / page.size);
               </div>
             ) : null}
             <div class="max-w-[192px] text-sm">
-              <span class="p-name">{story.data.title}</span>
+              <span class="p-name" aria-label="Title">
+                {story.data.title}
+              </span>
               <br />
-              <time class="italic" datetime={story.data.pubDate.toISOString().slice(0, 10)}>
+              <time
+                class="dt-published italic"
+                datetime={story.data.pubDate.toISOString().slice(0, 10)}
+                aria-label="Publish date"
+              >
                 {story.data.pubDate.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
               </time>
             </div>
           </a>
-          <div style={{ display: "none" }}>
-            {story.authors.map((author) => (
-              <UserComponent rel="author" class="p-author" user={author} lang={story.data.lang} />
-            ))}
+          <div class="sr-only">
+            <p class="p-category" aria-label="Category">
+              Story
+            </p>
+            <p class="p-summary" aria-label="Summary">
+              {t(story.data.lang, "story/warnings", story.data.wordCount, story.data.contentWarning)}
+            </p>
+            <div aria-label="Authors">
+              {story.authors.map((author) => (
+                <UserComponent rel="author" class="p-author" user={author} lang={story.data.lang} />
+              ))}
+            </div>
           </div>
         </li>
       ))
@@ -124,7 +138,7 @@ const totalPages = Math.ceil(page.total / page.size);
       [...Array(totalPages).keys()]
         .map((p) => p + 1)
         .map((p) =>
-          p == page.currentPage ? (
+          p === page.currentPage ? (
             <span class="border-r border-stone-400 px-4 py-1 font-semibold text-stone-900 dark:border-stone-500 dark:text-stone-50">
               {p}
             </span>
diff --git a/src/pages/tags.astro b/src/pages/tags.astro
index 5373f99..9d5cf9a 100644
--- a/src/pages/tags.astro
+++ b/src/pages/tags.astro
@@ -31,7 +31,7 @@ uncategorizedTagsSet.forEach((tag) => draftOnlyTagsSet.delete(tag));
 const uniqueSlugs = new Set<string>();
 const categorizedTags = tagCategories
   .sort((a, b) => {
-    if (a.data.index == b.data.index) {
+    if (a.data.index === b.data.index) {
       throw new Error(`Found tag categories with same index value ${a.data.index} ("${a.id}", "${b.id}")`);
     }
     return a.data.index - b.data.index;
diff --git a/src/pages/tags/[slug].astro b/src/pages/tags/[slug].astro
index b8e49ce..09b169a 100644
--- a/src/pages/tags/[slug].astro
+++ b/src/pages/tags/[slug].astro
@@ -1,13 +1,14 @@
 ---
 import type { GetStaticPaths } from "astro";
 import { Image } from "astro:assets";
-import { type CollectionEntry, type CollectionKey, getCollection } from "astro:content";
+import { type CollectionEntry, type CollectionKey, getCollection, getEntries } from "astro:content";
 import { Markdown } from "@astropub/md";
 import { slug } from "github-slugger";
 import GalleryLayout from "../../layouts/GalleryLayout.astro";
 import Prose from "../../components/Prose.astro";
 import { t, DEFAULT_LANG } from "../../i18n";
 import { qualifyLocalURLsInMarkdown } from "../../utils/qualify_local_urls_in_markdown";
+import UserComponent from "../../components/UserComponent.astro";
 
 type EntryWithPubDate<C extends CollectionKey> = CollectionEntry<C> & { data: { pubDate: Date } };
 
@@ -15,8 +16,8 @@ type Props = {
   tag: string;
   description?: string;
   related?: string[];
-  stories: EntryWithPubDate<"stories">[];
-  games: EntryWithPubDate<"games">[];
+  stories: (EntryWithPubDate<"stories"> & { authors: CollectionEntry<"users">[] })[];
+  games: (EntryWithPubDate<"games"> & { authors: CollectionEntry<"users">[] })[];
 };
 
 type Params = {
@@ -46,7 +47,7 @@ export const getStaticPaths: GetStaticPaths = async () => {
     (acc, category) => {
       category.data.tags.forEach(({ name, description, related }) => {
         related = related.filter((relatedTag) => {
-          if (relatedTag == name) {
+          if (relatedTag === name) {
             console.warn(`WARNING: Tag "${name}" should not have itself as a related tag; removing...`);
             return false;
           }
@@ -66,26 +67,42 @@ export const getStaticPaths: GetStaticPaths = async () => {
     },
     {} as Record<string, { description?: string; related?: string[] }>,
   );
-  return [...tags]
-    .filter((tag) => !seriesTags.has(tag))
-    .map((tag) => ({
-      params: { slug: slug(tag) } satisfies Params,
-      props: {
-        tag,
-        description: tagDescriptions[tag]?.description,
-        related: tagDescriptions[tag]?.related,
-        stories: (
-          stories.filter(
-            (story) => !story.data.isDraft && story.data.pubDate && story.data.tags.includes(tag),
-          ) as EntryWithPubDate<"stories">[]
-        ).sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime()),
-        games: (
-          games.filter(
-            (game) => !game.data.isDraft && game.data.pubDate && game.data.tags.includes(tag),
-          ) as EntryWithPubDate<"games">[]
-        ).sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime()),
-      } satisfies Props,
-    }));
+  return await Promise.all(
+    [...tags]
+      .filter((tag) => !seriesTags.has(tag))
+      .map(async (tag) => ({
+        params: { slug: slug(tag) } satisfies Params,
+        props: {
+          tag,
+          description: tagDescriptions[tag]?.description,
+          related: tagDescriptions[tag]?.related,
+          stories: await Promise.all(
+            (
+              stories.filter(
+                (story) => !story.data.isDraft && story.data.pubDate && story.data.tags.includes(tag),
+              ) as EntryWithPubDate<"stories">[]
+            )
+              .sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime())
+              .map(async (story) => ({
+                ...story,
+                authors: await getEntries(story.data.authors),
+              })),
+          ),
+          games: await Promise.all(
+            (
+              games.filter(
+                (game) => !game.data.isDraft && game.data.pubDate && game.data.tags.includes(tag),
+              ) as EntryWithPubDate<"games">[]
+            )
+              .sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime())
+              .map(async (game) => ({
+                ...game,
+                authors: await getEntries(game.data.authors),
+              })),
+          ),
+        } satisfies Props,
+      })),
+  );
 };
 
 const { props } = Astro;
@@ -130,7 +147,7 @@ const totalWorksWithTag = t(
         </h2>
         <ul class="flex flex-wrap items-start justify-center gap-4 text-center md:justify-normal">
           {props.stories.map((story) => (
-            <li class="h-entry break-inside-avoid">
+            <li class="h-entry break-inside-avoid" lang={story.data.lang}>
               <a
                 class="u-url text-link hover:underline focus:underline"
                 href={`/stories/${story.slug}`}
@@ -147,9 +164,15 @@ const totalWorksWithTag = t(
                   </div>
                 ) : null}
                 <div class="max-w-[192px] text-sm">
-                  <span class="p-name">{story.data.title}</span>
+                  <span class="p-name" aria-label="Title">
+                    {story.data.title}
+                  </span>
                   <br />
-                  <time class="dt-published italic" datetime={story.data.pubDate.toISOString().slice(0, 10)}>
+                  <time
+                    class="dt-published italic"
+                    datetime={story.data.pubDate.toISOString().slice(0, 10)}
+                    aria-label="Publish date"
+                  >
                     {story.data.pubDate.toLocaleDateString("en-US", {
                       month: "short",
                       day: "numeric",
@@ -158,6 +181,19 @@ const totalWorksWithTag = t(
                   </time>
                 </div>
               </a>
+              <div class="sr-only">
+                <p class="p-category" aria-label="Category">
+                  Story
+                </p>
+                <p class="p-summary" aria-label="Summary">
+                  {t(story.data.lang, "story/warnings", story.data.wordCount, story.data.contentWarning)}
+                </p>
+                <div aria-label="Authors">
+                  {story.authors.map((author) => (
+                    <UserComponent rel="author" class="p-author" user={author} lang={story.data.lang} />
+                  ))}
+                </div>
+              </div>
             </li>
           ))}
         </ul>
@@ -172,7 +208,7 @@ const totalWorksWithTag = t(
         </h2>
         <ul class="flex flex-wrap items-start justify-center gap-4 text-center md:justify-normal">
           {props.games.map((game) => (
-            <li class="h-entry break-inside-avoid">
+            <li class="h-entry break-inside-avoid" lang={game.data.lang}>
               <a
                 class="u-url text-link hover:underline focus:underline"
                 href={`/games/${game.slug}`}
@@ -189,13 +225,32 @@ const totalWorksWithTag = t(
                   </div>
                 ) : null}
                 <div class="max-w-[192px] text-sm">
-                  <span class="p-name">{game.data.title}</span>
+                  <span class="p-name" aria-label="Title">
+                    {game.data.title}
+                  </span>
                   <br />
-                  <time class="dt-published italic" datetime={game.data.pubDate.toISOString().slice(0, 10)}>
+                  <time
+                    class="dt-published italic"
+                    datetime={game.data.pubDate.toISOString().slice(0, 10)}
+                    aria-label="Publish date"
+                  >
                     {game.data.pubDate.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
                   </time>
                 </div>
               </a>
+              <div class="sr-only">
+                <p class="p-category" aria-label="Category">
+                  Game
+                </p>
+                <p class="p-summary" aria-label="Summary">
+                  {t(game.data.lang, "game/warnings", game.data.platforms, game.data.contentWarning)}
+                </p>
+                <div aria-label="Authors">
+                  {game.authors.map((author) => (
+                    <UserComponent rel="author" class="p-author" user={author} lang={game.data.lang} />
+                  ))}
+                </div>
+              </div>
             </li>
           ))}
         </ul>