chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// Which palette the page paints in.
//
// Four palettes ship with the page, and the account may publish more as
// dev.pdsjs.app.theme records. A published one carries colours by semantic
// name, which are set as custom properties on the root; the stylesheet's own
// names are the fallback for anything it leaves out.
//
// A reader's choice is theirs alone: it is remembered in this browser and
// never written back to the repo.
import { account } from './git.js';
const THEME_COLLECTION = 'dev.pdsjs.app.theme';
const REMEMBERED = 'git-ui:theme';
/**
* @typedef {Object} Theme
* @property {string} id - the data-theme value, or the record key
* @property {string} name - what a picker calls it
* @property {string} scheme - 'light' or 'dark'
* @property {Record<string, string>} [colors] - a published theme's own
* @property {boolean} [published] - written by the account rather than shipped
*/
/**
* The palettes the stylesheet carries. Following the system is the default,
* and the only one that answers to the reader's own light and dark setting.
* @type {Theme[]}
*/
export const BUILT_IN = [
{ id: '', name: 'System', scheme: 'system' },
{ id: 'light', name: 'Instrument light', scheme: 'light' },
{ id: 'dark', name: 'Instrument dark', scheme: 'dark' },
{
id: 'github-light',
name: 'GitHub light',
scheme: 'light',
},
{ id: 'github', name: 'GitHub dark', scheme: 'dark' },
{
id: 'catppuccin-latte',
name: 'Catppuccin Latte',
scheme: 'light',
},
{
id: 'catppuccin-mocha',
name: 'Catppuccin Mocha',
scheme: 'dark',
},
{ id: 'cyberpunk', name: 'Cyberpunk', scheme: 'dark' },
{ id: 'dracula', name: 'Dracula', scheme: 'dark' },
{ id: 'tokyo-night', name: 'Tokyo Night', scheme: 'dark' },
];
/**
* The names a published theme may set. A record naming anything else is
* ignored rather than trusted: these are the whole of what the page paints
* with, and a palette has no business reaching past them.
*/
const TOKENS = new Set([
'background',
'card',
'muted',
'accent',
'border',
'input',
'foreground',
'muted-foreground',
'faint',
'primary',
'primary-foreground',
'secondary',
'secondary-foreground',
'accent-foreground',
'destructive',
'destructive-dim',
'success',
'success-dim',
'ring',
'key',
'diff-add',
'diff-add-ink',
'diff-del',
'diff-del-ink',
'diff-meta',
'code-comment',
'code-keyword',
'code-string',
'code-number',
'code-title',
'code-attr',
'code-meta',
]);
/**
* A colour the page will set. Custom properties are set as values rather
* than as text, so a stylesheet cannot be broken open through one; this
* keeps a published palette to colours all the same, so a record cannot
* point the page at anything it has to fetch.
* @param {unknown} value
* @returns {boolean}
*/
function isColour(value) {
return (
typeof value === 'string' &&
value.length <= 64 &&
/^(#[0-9a-f]{3,8}|(rgb|hsl|oklch|lab|lch)a?\([0-9a-z.,%/\s-]+\))$/i.test(
value.trim(),
)
);
}
/** Themes by id, filled by loadThemes() and read by the picker. */
/** @type {Map<string, Theme>} */
const known = new Map(BUILT_IN.map((theme) => [theme.id, theme]));
let current = read();
/** @type {Set<() => void>} */
const listeners = new Set();
function read() {
try {
return localStorage.getItem(REMEMBERED) ?? '';
} catch {
// A page that cannot read storage follows the system.
return '';
}
}
/**
* How saturated a colour is, 0 to 1. Only the spread matters here, not the
* exact figure, so this reads the hex the page is painting with rather than
* reaching for a colour library.
* @param {string} colour
*/
function saturation(colour) {
const hex = colour.trim().replace('#', '');
if (hex.length < 6) return 0;
const [r, g, b] = [0, 2, 4].map((at) => {
return Number.parseInt(hex.slice(at, at + 2), 16) / 255;
});
const high = Math.max(r, g, b);
const low = Math.min(r, g, b);
if (high === low) return 0;
const light = (high + low) / 2;
return (high - low) / (light > 0.5 ? 2 - high - low : high + low);
}
/**
* Whether the palette in force has hue to spend. A language gauge asks this
* to decide between linguist's colour per language and one ink stepped back
* by rank; anything else that wants to colour a set of things can ask the
* same.
*
* The palette answers rather than declares. A theme that said it had colour
* and did not would leave a gauge unreadable, and the colours it paints with
* are the honest account of it. The key is what the page reaches for first
* when it wants to stand something out, so it is the one to ask.
* @returns {boolean}
*/
export function hasHue() {
const key = getComputedStyle(document.documentElement).getPropertyValue(
'--key',
);
return saturation(key) > 0.25;
}
/** The theme in force, as a whole record rather than an id. */
export function theme() {
return known.get(current) ?? BUILT_IN[0];
}
/** @param {() => void} listener */
export function onTheme(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
}
export function themeId() {
return current;
}
/** Every palette a reader may pick, shipped and published alike. */
export function themes() {
return [...known.values()];
}
/**
* Paint the page in one palette. A shipped one is a name the stylesheet
* answers to; a published one is that name plus its own colours, set over
* the top.
* @param {string} id
*/
export function applyTheme(id) {
const root = document.documentElement;
// The choice is remembered as given, even where nothing answers to it yet:
// a palette the account publishes is unknown until its record is read, and
// forgetting the name here would lose the reader's pick every reload.
current = id ?? '';
const chosen = known.get(current) ?? BUILT_IN[0];
for (const token of TOKENS) root.style.removeProperty(`--${token}`);
root.style.removeProperty('color-scheme');
if (chosen.id) root.dataset.theme = chosen.id;
else delete root.dataset.theme;
if (chosen.published) {
// The record names no stylesheet block, so it starts from the shipped
// palette that runs the same way round and paints its own over it.
root.dataset.theme = chosen.scheme === 'light' ? 'light' : 'dark';
root.style.setProperty('color-scheme', chosen.scheme);
for (const [token, colour] of Object.entries(chosen.colors ?? {})) {
if (TOKENS.has(token) && isColour(colour)) {
root.style.setProperty(`--${token}`, colour.trim());
}
}
}
try {
if (current) localStorage.setItem(REMEMBERED, current);
else localStorage.removeItem(REMEMBERED);
} catch {
// A page that cannot write storage still paints; it just forgets.
}
for (const listener of listeners) listener();
}
/**
* The palettes the account publishes, added to the ones the page ships.
* Every failure reads as none: a theme is decoration, and a reader who
* cannot have one should still get the page.
* @returns {Promise<Theme[]>}
*/
export async function loadThemes() {
try {
const params = new URLSearchParams({
repo: account.did,
collection: THEME_COLLECTION,
limit: '50',
});
const res = await fetch(
`${account.pds}/xrpc/com.atproto.repo.listRecords?${params}`,
);
if (!res.ok) return [];
const rows =
/** @type {{records?: {uri: string, value: Record<string, unknown>}[]}} */ (
await res.json()
).records;
/** @type {Theme[]} */
const published = [];
for (const row of rows ?? []) {
const id = row.uri.split('/').pop();
const value = row.value ?? {};
if (!id || typeof value.name !== 'string') continue;
published.push({
id,
name: value.name,
scheme: value.scheme === 'light' ? 'light' : 'dark',
colors: /** @type {Record<string, string>} */ (value.colors ?? {}),
published: true,
});
}
for (const one of published) known.set(one.id, one);
// A reader whose remembered choice is one of these was looking at the
// shipped fallback until now.
if (published.some((one) => one.id === current)) applyTheme(current);
for (const listener of listeners) listener();
return published;
} catch {
return [];
}
}