~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
274 lines · 8.1 KB Raw History
// 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 [];
  }
}