johnandersen777.bsky.social/knotless
git that lives in your Atmosphere account
Forked from chadtmiller.com/knotless
0
0
<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>