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

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
244 lines · 9.8 KB Raw History
// The colours the pages are drawn in, as the viewer chose at
// /settings/appearance. The browser keeps the choice in a cookie, `theme`,
// which the server reads and writes on <html> as `data-theme`
// (src/hooks.server.ts), so a page comes in its theme from the first paint;
// with none, the pages follow the system's light or dark. Each theme's
// colours are in src/tokens.css.
//
// A theme of the viewer's own (a dev.knotless.theme's palette, copied into
// their preferences) is `custom-light` or `custom-dark`, with its colours
// in a second cookie, `palette`, which the server writes on <html> as its
// style, over light's or dark's.
//
// A pair, one theme by day and another at night, keeps its night half in
// `theme-night` and `palette-night` too. The server writes both halves on
// <html>, as `data-day` and `data-night`, with any palette of either in a
// <style> of its own; and app.html's script, before the page is drawn, takes the
// half the system's light or dark says, as `data-theme` and `data-half`,
// and again as it changes.

/** The cookie the browser keeps its theme in. */
export const THEME = 'theme';

/** The cookie the browser keeps a theme of its own's colours in. */
export const PALETTE = 'palette';

/** The cookies a pair's night half is kept in. */
export const NIGHT = 'theme-night';
export const NIGHT_PALETTE = 'palette-night';

/** The <style> a pair's palettes are drawn from. */
const OWN = 'theme-own';

/** A theme to choose: its name in `data-theme`, and what it is called. */
export type Theme = { id: string; name: string };

/** Light and dark, as knotless draws them. */
export const PLAIN: Theme[] = [
	{ id: 'light', name: 'Light' },
	{ id: 'dark', name: 'Dark' }
];

/** Themes made after editors' and terminals' own. */
export const MORE: Theme[] = [
	{ id: 'latte', name: 'Catppuccin Latte' },
	{ id: 'mocha', name: 'Catppuccin Mocha' },
	{ id: 'dracula', name: 'Dracula' }
];

/** Themes made after apps of the Atmosphere, from their own stylesheets. */
export const ATMOSPHERE: Theme[] = [
	{ id: 'pckt', name: 'pckt' },
	{ id: 'plyr', name: 'plyr.fm' },
	{ id: 'blacksky', name: 'Blacksky' }
];

/** Each of a theme of one's own, over light's or dark's. */
const CUSTOM = ['custom-light', 'custom-dark'];

/** `said` if it names a theme, else null: the system's. */
export const themeOf = (said: string | undefined | null): string | null =>
	[...PLAIN, ...MORE, ...ATMOSPHERE].some((t) => t.id === said) || CUSTOM.includes(said ?? '')
		? (said as string)
		: null;

/** A dev.knotless.theme#palette: its scheme, and its colours as #rrggbb. */
export type Palette = { colorScheme: string; syntax?: Record<string, string | undefined> } & Record<
	string,
	unknown
>;

/** A colour of a palette: its name there, for what it colours, the token it is drawn as, which names its hue, what it is called, and whether it is `syntax`'s. */
export type Colour = { field: string; token: string; name: string; code?: true };

/** A palette's colours, in the order the editor lists them. */
export const COLOURS: Colour[] = [
	{ field: 'background', token: 'bg', name: 'Background' },
	{ field: 'panel', token: 'panel', name: 'Panels' },
	{ field: 'soft', token: 'soft', name: 'Code blocks and hovered rows' },
	{ field: 'border', token: 'line', name: 'Borders' },
	{ field: 'text', token: 'fg', name: 'Text' },
	{ field: 'textMuted', token: 'mute', name: 'Muted text' },
	{ field: 'textFaint', token: 'faint', name: 'Faint text' },
	{ field: 'primary', token: 'primary', name: 'Main buttons' },
	{ field: 'onPrimary', token: 'on-primary', name: 'Text on main buttons' },
	{ field: 'accent', token: 'accent', name: 'Links' },
	{ field: 'success', token: 'green', name: 'Open and passed' },
	{ field: 'danger', token: 'red', name: 'Failed and removed' },
	{ field: 'done', token: 'purple', name: 'Merged' },
	{ field: 'attention', token: 'yellow', name: 'Waiting and notices' },
	{ field: 'neutral', token: 'gray', name: 'Closed and skipped' },
	{ field: 'comments', token: 'comment', name: 'Comments', code: true },
	{ field: 'strings', token: 'string', name: 'Strings', code: true },
	{ field: 'keywords', token: 'keyword', name: 'Keywords', code: true },
	{ field: 'numbers', token: 'number', name: 'Numbers', code: true },
	{ field: 'functions', token: 'function', name: 'Functions', code: true },
	{ field: 'types', token: 'type', name: 'Types', code: true },
	{ field: 'tags', token: 'tag', name: 'Tags', code: true }
];

/** What each token is called, for saying which falls short. */
export const NAMED: Record<string, string> = {
	...Object.fromEntries(COLOURS.map((c) => [c.token, c.name.toLowerCase()])),
	'accent-soft': 'highlighted links',
	heat: 'fresh files',
	'on-solid': 'text on state pills',
	notice: 'notices',
	'notice-fg': 'notice text',
	ins: 'added lines',
	del: 'removed lines',
	hunk: 'diff headers',
	'ins-x': 'added words',
	'del-x': 'removed words',
	attr: 'attributes'
};

const HEX = /^#[0-9a-f]{6}$/i;

/** `palette`'s colours that are #rrggbb, by the token each is drawn as. */
export function tokensOf(palette: Palette): Record<string, string> {
	const at = (c: Colour) => (c.code ? palette.syntax?.[c.field] : palette[c.field]);
	const given = COLOURS.map((c) => [c.token, at(c)] as const).filter(
		([, v]) => typeof v === 'string' && HEX.test(v)
	);
	return Object.fromEntries(given.map(([t, v]) => [t, (v as string).toLowerCase()]));
}

/** `palette` as an element's style: its colours, and its scheme for the browser's own controls. */
export function styleOf(palette: Palette): string {
	const each = Object.entries(tokensOf(palette)).map(([t, v]) => `--${t}:${v}`);
	return [`color-scheme:${schemeOf(palette)}`, ...each].join(';');
}

/** Whether `palette` is light or dark. */
export const schemeOf = (palette: Palette): 'light' | 'dark' =>
	palette.colorScheme === 'dark' ? 'dark' : 'light';

/** `palette` as the cookie `palette` keeps it: its scheme, then each colour as `name-rrggbb`, between dots, as knotless-serve writes it too. */
export function paletteCookie(palette: Palette): string {
	const tokens = tokensOf(palette);
	const each = COLOURS.filter((c) => tokens[c.token]).map(
		(c) => `${c.field}-${tokens[c.token].slice(1)}`
	);
	return [schemeOf(palette), ...each].join('.');
}

/** The palette the cookie `said` keeps, if it keeps one. */
export function paletteOf(said: string | undefined | null): Palette | null {
	const [scheme, ...each] = (said ?? '').split('.');
	if (scheme !== 'light' && scheme !== 'dark') return null;
	const palette: Palette = { colorScheme: scheme };
	for (const pair of each) {
		const [field, digits] = pair.split('-');
		const c = COLOURS.find((c) => c.field === field);
		if (!c || !/^[0-9a-f]{6}$/i.test(digits ?? '')) continue;
		if (c.code) palette.syntax = { ...palette.syntax, [field]: `#${digits}` };
		else palette[field] = `#${digits}`;
	}
	return palette;
}

/** The theme a palette is drawn in. */
export const customOf = (palette: Palette) => `custom-${schemeOf(palette)}`;

/** Sets the cookie `name` to `value`, for a year, or with none takes it away. */
function keep(name: string, value: string | null) {
	const secure = location.protocol === 'https:' ? '; secure' : '';
	const lasts = value ? 'max-age=31536000' : 'max-age=0';
	document.cookie = `${name}=${value ?? ''}; path=/; ${lasts}; samesite=lax${secure}`;
}

/** One half of a pair, or a theme alone: a theme by name, or `palette`, one of one's own, for `custom`. */
export type Chosen = { theme: string; palette: Palette | null; source?: string };

/** What `data-theme` names `chosen` by. */
export const drawnAs = (chosen: Chosen) =>
	chosen.palette ? customOf(chosen.palette) : chosen.theme;

/** The rules a pair's palettes are drawn by, each on <html> while its half is shown. */
export function halvesStyle(day: Palette | null, night: Palette | null): string {
	const rule = (half: string, p: Palette | null) =>
		p ? `:root[data-half='${half}']{${styleOf(p)}}` : '';
	return rule('day', day) + rule('night', night);
}

/** Shows the half of a pair the system's light or dark says, as app.html's script does. */
function flip() {
	const html = document.documentElement;
	const half = matchMedia('(prefers-color-scheme: dark)').matches ? 'night' : 'day';
	const id = html.dataset[half];
	if (!id) return;
	html.dataset.half = half;
	html.dataset.theme = id;
}

/** Leaves no pair on the page, nor in the cookies. */
function unpair() {
	const html = document.documentElement;
	for (const key of ['day', 'night', 'half']) delete html.dataset[key];
	document.getElementById(OWN)?.remove();
	keep(NIGHT, null);
	keep(NIGHT_PALETTE, null);
}

/** Draws the page in `day` while the system is light and `night` while it is dark; and keeps both for the pages the server draws next. */
export function chooseBoth(day: Chosen, night: Chosen) {
	const html = document.documentElement;
	html.removeAttribute('style');
	html.dataset.day = drawnAs(day);
	html.dataset.night = drawnAs(night);
	let own = document.getElementById(OWN);
	if (!own) {
		own = document.createElement('style');
		own.id = OWN;
		document.head.append(own);
	}
	own.textContent = halvesStyle(day.palette, night.palette);
	keep(THEME, drawnAs(day));
	keep(PALETTE, day.palette ? paletteCookie(day.palette) : null);
	keep(NIGHT, drawnAs(night));
	keep(NIGHT_PALETTE, night.palette ? paletteCookie(night.palette) : null);
	flip();
}

/** Draws the page in `theme`, or the system's for null, or in `palette`, a theme of its own; and keeps it for the pages the server draws next. */
export function choose(theme: string | null, palette: Palette | null = null) {
	const html = document.documentElement;
	unpair();
	html.removeAttribute('style');
	if (palette) {
		html.dataset.theme = customOf(palette);
		html.setAttribute('style', styleOf(palette));
		keep(THEME, customOf(palette));
		keep(PALETTE, paletteCookie(palette));
		return;
	}
	keep(PALETTE, null);
	if (theme) {
		html.dataset.theme = theme;
		keep(THEME, theme);
	} else {
		delete html.dataset.theme;
		keep(THEME, null);
	}
}