diff --git a/scripts/build-site.mjs b/scripts/build-site.mjs
index 5519410..83a4bbc 100644
--- a/scripts/build-site.mjs
+++ b/scripts/build-site.mjs
@@ -104,6 +104,10 @@ async function main() {
'src="./app.js"',
`src="./app.js?v=${buildId}"`,
);
+ html = html.replaceAll(
+ 'src="./clients.js"',
+ `src="./clients.js?v=${buildId}"`,
+ );
html = html.replaceAll(
'href="./favicon.svg"',
`href="./favicon.svg?v=${buildId}"`,
diff --git a/site/app.js b/site/app.js
index c179036..f6b35ad 100644
--- a/site/app.js
+++ b/site/app.js
@@ -280,6 +280,7 @@ async function main() {
const html = globalThis.marked.parse(md);
contentEl.innerHTML = html;
globalThis.Prism?.highlightAllUnder(contentEl);
+ applyProfileClientPreference(contentEl);
renderHeadingToc();
window.scrollTo(0, 0);
@@ -307,3 +308,46 @@ async function main() {
}
main();
+
+// atproto profile links honor the "open profiles in" preference (clients.js,
+// shared across the zzstoatzz/zat.dev sites via the same localStorage key).
+// Authors write plain bsky.app profile URLs in markdown; render tags them
+// with data-profile-did so clients.js keeps them fresh on preference change.
+function applyProfileClientPreference(root) {
+ if (!window.LeafletClients) return;
+ root.querySelectorAll('a[href^="https://bsky.app/profile/"]').forEach((a) => {
+ const id = a.href.slice("https://bsky.app/profile/".length).replace(/\/+$/, "");
+ if (!id) return;
+ a.setAttribute("data-profile-did", id);
+ a.href = window.LeafletClients.profileUrlFor(id);
+ });
+}
+
+// settings popover: where atproto identities open
+(function initClientSettings() {
+ const btn = document.querySelector(".client-settings-toggle");
+ if (!btn || !window.LeafletClients) return;
+ const pop = document.getElementById("client-settings");
+ function renderOptions() {
+ const current = window.LeafletClients.getPreferredClient().value;
+ pop.innerHTML =
+ '
open profiles in
' +
+ window.LeafletClients.CLIENTS.map(
+ (c) =>
+ ``,
+ ).join("");
+ }
+ btn.addEventListener("click", (e) => {
+ e.stopPropagation();
+ renderOptions();
+ pop.hidden = !pop.hidden;
+ });
+ pop.addEventListener("change", (e) => {
+ if (e.target.name === "profile-client") window.LeafletClients.setPreferredClient(e.target.value);
+ });
+ document.addEventListener("click", (e) => {
+ if (!pop.hidden && !pop.contains(e.target)) pop.hidden = true;
+ });
+})();
diff --git a/site/clients.js b/site/clients.js
new file mode 100644
index 0000000..322093f
--- /dev/null
+++ b/site/clients.js
@@ -0,0 +1,63 @@
+// shared: bluesky-compatible client registry + preferred-client preference.
+// mirrors the shape used in ../@zzstoatzz.io/status and ../@chicago.at so
+// users get consistent behavior across these atproto experiments.
+//
+// stored in localStorage under `leaflet.preferredClient`. default = bsky.
+// `profileUrlFor(handle)` returns the resolved profile URL for the current
+// preference; unknown values fall back to the first registered client.
+
+(function() {
+ 'use strict';
+
+ var STORAGE_KEY = 'leaflet.preferredClient';
+
+ var CLIENTS = [
+ { value: 'bsky', label: 'Bluesky', profileUrl: function(h) { return 'https://bsky.app/profile/' + h; }, iconUrl: 'https://web-cdn.bsky.app/static/apple-touch-icon.png' },
+ { value: 'blacksky', label: 'Blacksky', profileUrl: function(h) { return 'https://blacksky.community/profile/' + h; }, iconUrl: 'https://blacksky.community/static/apple-touch-icon.png' },
+ { value: 'witchsky', label: 'Witchsky', profileUrl: function(h) { return 'https://witchsky.app/profile/' + h; }, iconUrl: 'https://witchsky.app/favicon.ico' },
+ { value: 'reddwarf', label: 'Red Dwarf', profileUrl: function(h) { return 'https://reddwarf.app/profile/' + h; }, iconUrl: 'https://reddwarf.app/redstar.png' },
+ { value: 'pdsls', label: 'PDSls', profileUrl: function(h) { return 'https://pdsls.dev/at/' + h; }, iconUrl: 'https://pdsls.dev/favicon.ico' },
+ ];
+
+ function readPreferred() {
+ try { return localStorage.getItem(STORAGE_KEY); } catch (e) { return null; }
+ }
+
+ function writePreferred(value) {
+ try { localStorage.setItem(STORAGE_KEY, value); } catch (e) {}
+ }
+
+ function getPreferredClient() {
+ var v = readPreferred();
+ return CLIENTS.find(function(c) { return c.value === v; }) || CLIENTS[0];
+ }
+
+ function setPreferredClient(value) {
+ if (CLIENTS.some(function(c) { return c.value === value; })) {
+ writePreferred(value);
+ window.dispatchEvent(new CustomEvent('leaflet:preferred-client-changed', { detail: value }));
+ }
+ }
+
+ function profileUrlFor(handleOrDid) {
+ return getPreferredClient().profileUrl(handleOrDid);
+ }
+
+ // Profile links bake the preferred-client URL into their href at render time.
+ // Rather than make every page listen for changes (easy to forget on a new
+ // surface), keep them fresh here: any anchor tagged `data-profile-did` gets
+ // its href rewritten app-wide when the preference changes. Render sites just
+ // need the attribute — no per-page wiring.
+ window.addEventListener('leaflet:preferred-client-changed', function() {
+ document.querySelectorAll('a[data-profile-did]').forEach(function(a) {
+ a.href = profileUrlFor(a.getAttribute('data-profile-did'));
+ });
+ });
+
+ window.LeafletClients = {
+ CLIENTS: CLIENTS,
+ getPreferredClient: getPreferredClient,
+ setPreferredClient: setPreferredClient,
+ profileUrlFor: profileUrlFor,
+ };
+})();
diff --git a/site/index.html b/site/index.html
index da5cdb5..4abf41a 100644
--- a/site/index.html
+++ b/site/index.html
@@ -37,6 +37,10 @@