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

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
225 lines · 7.4 KB Raw History
<script lang="ts">
	import type * as createTheme from '#lib/lexicons/dev/knotless/createTheme.ts';
	import type * as defs from '#lib/lexicons/dev/knotless/defs.ts';
	import { untrack } from 'svelte';
	import { goto } from '$app/navigation';
	import css from '../../tokens.css?raw';
	import {
		block,
		blocks,
		colour,
		customTokens,
		hex,
		shortfalls,
		type Tokens
	} from '#lib/contrast.ts';
	import { call } from '#lib/data.ts';
	import { APPEARANCE_PREF, putPrefs } from '#lib/preferences.ts';
	import {
		ATMOSPHERE,
		COLOURS,
		MORE,
		NAMED,
		PLAIN,
		choose,
		customOf,
		styleOf,
		tokensOf,
		type Palette
	} from '#lib/theme.ts';
	/**
	 * Making a theme of one's own colours, from one there is or from
	 * `palette`, the viewer's own: each colour picked, the theme drawn as it
	 * is picked, and each text that would not read on what it is drawn on
	 * said, as the pages' own themes are held to (src/lib/contrast.ts). Used,
	 * the pages are drawn in it; published, anyone can choose it.
	 */
	let { palette }: { palette: Palette | null } = $props();
	const OWN = 'own';
	const starts = [
		...(untrack(() => palette) ? [{ id: OWN, name: 'Your own' }] : []),
		...PLAIN,
		...MORE,
		...ATMOSPHERE
	];
	const b = blocks(css);
	/** Theme `id`'s tokens, as tokens.css draws it. */
	function tokensFor(id: string): Tokens {
		if (id === OWN && palette)
			return customTokens(
				css,
				palette.colorScheme === 'dark' ? 'dark' : 'light',
				tokensOf(palette)
			);
		if (id === 'light') return b.light;
		if (id === 'dark') return b.chosenDark;
		return { ...b.light, ...b.shared, ...block(css, `[data-theme='${id}']`) };
	}
	/** Theme `id`'s colours as #rrggbb, by token, and whether it is dark. */
	function from(id: string): { scheme: 'light' | 'dark'; values: Tokens } {
		const env = tokensFor(id);
		const values = Object.fromEntries(
			COLOURS.map((c) => [c.token, hex(colour(env, `var(--${c.token})`))])
		);
		const [r, g, bl] = colour(env, 'var(--bg)');
		return { scheme: (r + g + bl) / 3 < 128 ? 'dark' : 'light', values };
	}
	let start = $state(starts[0].id);
	let scheme = $state(untrack(() => from(start).scheme));
	let values = $state(untrack(() => from(start).values));
	let name = $state('');
	let busy = $state<'use' | 'publish' | null>(null);
	let problem = $state('');
	function restart() {
		({ scheme, values } = from(start));
	}
	const made: Palette = $derived({
		colorScheme: scheme,
		...Object.fromEntries(COLOURS.filter((c) => !c.code).map((c) => [c.field, values[c.token]])),
		syntax: Object.fromEntries(COLOURS.filter((c) => c.code).map((c) => [c.field, values[c.token]]))
	});
	const short = $derived(shortfalls(customTokens(css, scheme, tokensOf(made))));
	const SHOWN = 6;
	const said = (token: string) => NAMED[token] ?? token;
	async function use(source?: string) {
		choose(null, made);
		await putPrefs([
			{
				$type: APPEARANCE_PREF,
				theme: 'custom',
				palette: made as defs.ThemeView['palette'],
				source: source as defs.ThemeView['uri'] | undefined
			}
		]);
	}
	async function act(what: 'use' | 'publish') {
		busy = what;
		problem = '';
		try {
			if (what === 'publish') {
				const shown = await call<typeof createTheme.main>('dev.knotless.createTheme', {
					name,
					palette: made as defs.ThemeView['palette']
				});
				await use(shown.uri);
			} else await use();
			await goto('/settings/appearance', { invalidateAll: true });
		} catch (err) {
			problem = (err as Error).message;
		} finally {
			busy = null;
		}
	}
</script>

{#snippet picker(group: 'page' | 'states' | 'code')}
	<div class="colours">
		{#each COLOURS.filter( (c) => (group === 'code' ? c.code : group === 'states' ? !c.code && ['green', 'red', 'purple', 'yellow', 'gray'].includes(c.token) : !c.code && !['green', 'red', 'purple', 'yellow', 'gray'].includes(c.token)) ) as c (c.token)}
			<label class="colour"
				><input type="color" bind:value={values[c.token]} /><span>{c.name}</span></label
			>
		{/each}
	</div>
{/snippet}

<div class="settings-section"><h2>Make a theme</h2></div>
<div class="theme-editor">
	<p>
		Pick each colour, starting from a theme there is. What you make is checked as you go, so every
		text reads on what it is drawn on.
	</p>
	<div
		class="theme-sample"
		data-theme={customOf(made)}
		style={styleOf(made)}
		aria-label="How the theme looks"
	>
		<p class="theme-sample-head"><b>alice.test / tally</b></p>
		<p>A counter for the command line, with <a href="#sample">a link</a> in its text.</p>
		<p class="mute">Updated 3 hours ago</p>
		<p class="faint">Press / to search</p>
		<p class="theme-sample-pills">
			<span class="pill open">Open</span><span class="pill merged">Merged</span><span
				class="pill closed">Closed</span
			>
		</p>
		<pre class="theme-sample-code"><span class="theme-sample-c">// Count to n</span>
<span class="theme-sample-k">fn</span> <span class="theme-sample-f">count</span>(n: <span
				class="theme-sample-t">u32</span
			>) {'{'} <span class="theme-sample-s">"{'{'}n{'}'}"</span>; <span class="theme-sample-n"
				>42</span
			> {'}'}</pre>
		<p class="theme-sample-pills">
			<span class="btn primary">Merge</span><span class="btn">Cancel</span>
		</p>
	</div>
	<form class="settings-form" onsubmit={(e) => (e.preventDefault(), act('publish'))}>
		<label class="field"
			><span class="field-label">Start from</span>
			<select bind:value={start} onchange={restart}>
				{#each starts as t (t.id)}<option value={t.id}>{t.name}</option>{/each}
			</select>
		</label>
		<fieldset class="field">
			<legend class="field-label">Light or dark</legend>
			<div class="choices">
				<label class="check"
					><input type="radio" name="scheme" value="light" bind:group={scheme} />Light</label
				>
				<label class="check"
					><input type="radio" name="scheme" value="dark" bind:group={scheme} />Dark</label
				>
			</div>
			<p class="hint">What the browser draws its own controls in, as scrollbars.</p>
		</fieldset>
		<fieldset class="field">
			<legend class="field-label">Page</legend>{@render picker('page')}
		</fieldset>
		<fieldset class="field">
			<legend class="field-label">States</legend>{@render picker('states')}
		</fieldset>
		<fieldset class="field">
			<legend class="field-label">Code</legend>{@render picker('code')}
		</fieldset>
		{#if short.length}
			<div class="theme-short" role="status">
				<p>Some text would be hard to read. Each needs a contrast of 4.5 to 1.</p>
				<ul>
					{#each short.slice(0, SHOWN) as s (`${s.text} ${s.on}`)}<li>
							{`${said(s.text)} on ${said(s.on)} is ${s.ratio.toFixed(1)} to 1`}
						</li>{/each}
					{#if short.length > SHOWN}<li>{`And ${short.length - SHOWN} more`}</li>{/if}
				</ul>
			</div>
		{/if}
		<label class="field"
			><span class="field-label">Name</span><input
				name="name"
				maxlength="64"
				placeholder="Sea glass"
				bind:value={name}
			/></label
		>
		<p class="hint">
			Publishing adds it to your account, where anyone can find and choose it. Use it without
			publishing to keep it to yourself.
		</p>
		{#if problem}<p class="problem" role="alert">{problem}</p>{/if}
		<div class="theme-actions">
			<button
				class="btn primary"
				type="submit"
				disabled={!!busy || !!short.length || !name.trim()}
				aria-busy={busy === 'publish'}
				>{busy === 'publish' ? 'Publishing…' : 'Publish and use'}</button
			>
			<button
				class="btn"
				type="button"
				disabled={!!busy || !!short.length}
				aria-busy={busy === 'use'}
				onclick={() => act('use')}>{busy === 'use' ? 'Saving…' : 'Use without publishing'}</button
			>
		</div>
	</form>
</div>