From c195853b64de3ca0c49f088d88233869b0e53c5b Mon Sep 17 00:00:00 2001
From: "@permadeath.com"
Date: Fri, 21 Aug 2026 11:32:33 -0400
Subject: [PATCH] feat(social-presence): link the account and the repositories
from the footer
Both footers read one list, and the FAQ's Tangled link moves off tangled.net,
which answers 404.
Change-Id: Ia103fb2738bfa77b8c9efc4bd8a4df5efb2ee677
---
web/scripts/project-links.test.mjs | 57 ++++++++++++++++++++++++++++++
web/src/chrome.ts | 16 +++++++++
web/src/components/Footer.astro | 9 +++++
web/src/content/faq.json | 2 +-
web/src/project-links.ts | 23 ++++++++++++
web/src/styles.css | 10 ++++++
6 files changed, 116 insertions(+), 1 deletion(-)
create mode 100644 web/scripts/project-links.test.mjs
create mode 100644 web/src/project-links.ts
diff --git a/web/scripts/project-links.test.mjs b/web/scripts/project-links.test.mjs
new file mode 100644
index 0000000..9db1323
--- /dev/null
+++ b/web/scripts/project-links.test.mjs
@@ -0,0 +1,57 @@
+/**
+ * The project's off-site addresses are in one list, and three files show them.
+ *
+ * project-links.ts is read by both footers — Footer.astro at build time,
+ * chrome.ts at run time — so those two cannot drift from each other. What can
+ * drift is a URL written by hand somewhere else: the FAQ carried
+ * `tangled.net/lance.blue`, which answers 404, for as long as nothing compared
+ * it to anything.
+ *
+ * Run with `npm test`.
+ */
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { readFile } from "node:fs/promises";
+import { join } from "node:path";
+
+const web = join(new URL(".", import.meta.url).pathname, "..");
+const read = (path) => readFile(join(web, path), "utf8");
+
+const links = await read("src/project-links.ts");
+const astroFooter = await read("src/components/Footer.astro");
+const chrome = await read("src/chrome.ts");
+const faq = await read("src/content/faq.json");
+
+const hrefs = [...links.matchAll(/href:\s*"([^"]+)"/g)].map((m) => m[1]);
+assert.ok(hrefs.length >= 2, "project-links.ts no longer declares hrefs");
+
+test("both footers render the list rather than their own copy", () => {
+ for (const [name, source] of [
+ ["Footer.astro", astroFooter],
+ ["chrome.ts", chrome],
+ ]) {
+ assert.match(source, /PROJECT_LINKS/, `${name} does not read the list`);
+ assert.doesNotMatch(
+ source,
+ /https:\/\/(bsky\.app|tangled\.)/,
+ `${name} writes an address the list already holds`,
+ );
+ }
+});
+
+test("the FAQ points at the same hosts the footer does", () => {
+ // Same first label, different host: tangled.net against tangled.org, or a
+ // bsky.social profile URL against bsky.app. Anything sharing no label with
+ // a project link is some other site and is not this test's business.
+ const known = new Map(
+ hrefs
+ .map((href) => new URL(href).host)
+ .map((host) => [host.split(".")[0], host]),
+ );
+ for (const [, url] of faq.matchAll(/"(https:\/\/[^"]+)"/g)) {
+ const host = new URL(url).host;
+ const expected = known.get(host.split(".")[0]);
+ if (!expected) continue;
+ assert.equal(host, expected, `faq.json points at ${host}, not ${expected}`);
+ }
+});
diff --git a/web/src/chrome.ts b/web/src/chrome.ts
index b6246fb..76aa774 100644
--- a/web/src/chrome.ts
+++ b/web/src/chrome.ts
@@ -10,6 +10,7 @@ import { alphaStamp } from "./alpha-stamp";
import { apiVersion as sharedVersion } from "./api";
import { type AboutPageId, destinationForRoute } from "./destinations";
import { el } from "./dom";
+import { PROJECT_LINKS } from "./project-links";
export type Route =
| "home"
@@ -117,6 +118,21 @@ export function footer(): HTMLElement {
textContent:
"lance.blue is a non-commercial project, in alpha, and undergoing development. Please see our credits page for attribution, copyright, trademark, and licenses information.",
}),
+ projectLinks(),
debug,
]);
}
+
+/**
+ * The account and the repositories, on every page of the app.
+ *
+ * components/Footer.astro carries the same row on the pages the app never
+ * runs on, off the same list - see project-links.ts.
+ */
+function projectLinks(): HTMLElement {
+ const row = el("p", { className: "footer-links" });
+ for (const link of PROJECT_LINKS) {
+ row.append(el("a", { href: link.href, textContent: link.label }));
+ }
+ return row;
+}
diff --git a/web/src/components/Footer.astro b/web/src/components/Footer.astro
index 0e3b27b..5f05055 100644
--- a/web/src/components/Footer.astro
+++ b/web/src/components/Footer.astro
@@ -6,6 +6,8 @@
* arena builds it just talked to. Nothing here talks to the API, so this one
* names the site's build and stops.
*/
+import { PROJECT_LINKS } from "../project-links";
+
const version = import.meta.env.DEV
? "dev"
: (import.meta.env.VITE_BUILD_REF ?? "unstamped");
@@ -29,5 +31,12 @@ const version = import.meta.env.DEV
development. Please see our credits page for attribution, copyright,
trademark, and licenses information.
+ {
+ /* The account and the repositories. chrome.ts renders the same row from
+ the same list on the app's own footer. */
+ }
+
site {version}
diff --git a/web/src/content/faq.json b/web/src/content/faq.json
index f438d5f..a0d1cdb 100644
--- a/web/src/content/faq.json
+++ b/web/src/content/faq.json
@@ -70,7 +70,7 @@
", ",
{"a": "the lance.blue blog", "href": "https://lance.blue/blog"},
" and ",
- {"a": "lance.blue on Tangled", "href": "https://tangled.net/lance.blue"},
+ {"a": "lance.blue on Tangled", "href": "https://tangled.org/lance.blue"},
"!" ]
}
]
diff --git a/web/src/project-links.ts b/web/src/project-links.ts
new file mode 100644
index 0000000..3ec65d3
--- /dev/null
+++ b/web/src/project-links.ts
@@ -0,0 +1,23 @@
+/**
+ * Where the project lives when it is not this site: the account it posts
+ * from, and the repositories it is written in.
+ *
+ * Two footers need them and they run at different times — Footer.astro is
+ * rendered by the build, chrome.ts by the app — which is the same split
+ * destinations.ts exists for, and the same reason this module imports
+ * nothing and touches no DOM. The alternative was writing both URLs twice and
+ * finding out they had drifted from a report that one of them 404s.
+ *
+ * They were reachable only from two FAQ answers before, which is a page a
+ * reader opens after deciding the project is worth looking into rather than
+ * before.
+ */
+export type ProjectLink = {
+ readonly href: string;
+ readonly label: string;
+};
+
+export const PROJECT_LINKS: readonly ProjectLink[] = [
+ { href: "https://bsky.app/profile/lance.blue", label: "@lance.blue" },
+ { href: "https://tangled.org/lance.blue", label: "Source on Tangled" },
+];
diff --git a/web/src/styles.css b/web/src/styles.css
index 53d3c20..873e658 100644
--- a/web/src/styles.css
+++ b/web/src/styles.css
@@ -2777,6 +2777,16 @@ footer p {
margin: 0;
}
+/* The account and the repositories, on one line above the version. A row
+ rather than a sentence: they are two addresses, not prose, and at a phone's
+ width they wrap onto two lines instead of pushing the footer wide. */
+.footer-links {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.25rem 0.9rem;
+ margin-top: 0.5rem;
+}
+
/* The version line: quiet, but there when a bug report needs it. */
footer .debug {
margin-top: 0.5rem;
--
2.51.2