rainbow quox

This commit is contained in:
Rhiannon Morris 2024-12-28 22:27:09 +01:00
parent cad73b232d
commit dff263856c
6 changed files with 371 additions and 234 deletions

25
rainbow-quox/edit.svg Normal file
View file

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<svg xmlns="http://www.w3.org/2000/svg"
width="15" height="15" viewBox="0 0 100 91.21">
<defs>
<linearGradient id="gradient" y1="100%" y2="0%">
<stop offset="20%" stop-color="hsl(60 90% 95%)" />
<stop offset="100%" stop-color="hsl(60 80% 90%)" />
</linearGradient>
<linearGradient id="fade">
<stop offset="25%" stop-color="white" />
<stop offset="87%" stop-color="black" />
</linearGradient>
<mask id="mask">
<rect fill="url(#fade)" x="30" y="80" width="70" height="20" />
</mask>
</defs>
<g fill="url(#gradient)">
<path id="pencil" d="M 70,0 l -70,70 v 21.21 h 21.21 l 70,-70 z" />
<path id="line" mask="url(#mask)" d="M 30,91.21 h 70 v -10 h -60 z" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 784 B

View file

@ -13,11 +13,11 @@ export class HistoryItem {
}
asHtml(): HTMLButtonElement {
const { lines: bg, outer, belly1: belly, fins1: fins } = this.rgb;
const { lines, outer, belly1: belly, fins1: fins } = this.rgb;
const content = `
<svg class=history-colors width=30 height=25 viewBox="-10 -10 140 120">
<rect x=-10 y=-10 width=140 height=120 fill="${bg.css()}" />
<rect x=-10 y=-10 width=140 height=120 fill="${lines.css()}" />
<path fill="${fins.css()}" d="M 60,0 h -57.73 v 100 z">
<title>fin colour: ${fins.css()}</title>
</path>
@ -53,21 +53,23 @@ export class History {
add(name: string): void { this.items.push(name); }
*iterNames(maxLength?: number | null): Iterable<string> {
*iterNames(maxLength: number = 100): Iterable<string> {
let seen = new Set<string>;
let done = 0;
if (maxLength === undefined) maxLength = 100;
for (let i = this.items.length - 1; i >= 0; i--) {
if (maxLength !== null && done > maxLength) break;
if (maxLength >= 0 && done > maxLength) break;
const name = this.items[i]!;
if (!name || seen.has(name)) continue;
seen.add(name); done++;
yield name;
seen.add(name);
done++;
}
}
*iterItems(maxLength?: number | null): Iterable<HistoryItem> {
// pass a negative number to iterate over all
*iterItems(maxLength?: number): Iterable<HistoryItem> {
for (const name of this.iterNames(maxLength)) {
const oklch = Color.colors(new Color.Rand(name), Color.KNOWN[name]);
const rgbs = Color.toRgbs(oklch);
@ -77,37 +79,26 @@ export class History {
}
static validate(x: unknown): History | undefined {
if (!Array.isArray(x)) return;
if (!x.every(i => typeof i === 'string')) return;
return new History(x);
if (Array.isArray(x) && x.every(i => typeof i == 'string'))
return new History(x);
}
toJSON() { return this.items; }
toJSON(): unknown { return this.items; }
save(persist = true) {
save(persist = true): void {
const storage = persist ? localStorage : sessionStorage;
storage.setItem('history', JSON.stringify(this));
}
// if the json was invalid, return it
// if no history exists just start a new one
static load(): History | string {
// if no history exists, or it's invalid, just start a new one
static load(): History {
const json =
sessionStorage.getItem('history') ??
localStorage.getItem('history');
if (json != null) {
let h = History.validate(JSON.parse(json));
if (h) { h.prune(); return h; }
else return json;
} else {
return new History;
}
}
// if the json is invalid, discard it
static loadOrClear(): History {
const h = History.load();
return h instanceof History ? h : new History;
if (json === null) return new History;
return History.validate(JSON.parse(json)) ?? new History;
}
addSave(name: string, persist = true): void {
@ -115,14 +106,9 @@ export class History {
this.save(persist);
}
prune(maxLength?: number | null) {
prune(maxLength?: number): void {
let keep = [];
for (let name of this.iterNames(maxLength)) keep.push(name);
this.items = keep.reverse();
}
pruneSave(maxLength?: number | null, persist = true) {
this.prune(maxLength);
this.save(persist);
}
}

View file

@ -0,0 +1,161 @@
import * as Color from './color.js';
async function loadBitmap(url: string): Promise<ImageBitmap> {
const img0 = new Image;
const img: Promise<ImageBitmapSource> = new Promise((ok, err) => {
img0.addEventListener('load', () => ok(img0));
img0.addEventListener('error', () => err(`couldn't load file: ${url}`));
});
img0.src = url;
return createImageBitmap(await img);
}
export type Buffer = OffscreenCanvasRenderingContext2D;
function dataViaBuffer(bmp: ImageBitmap, buf: Buffer): ImageData {
buf.clearRect(0, 0, bmp.width, bmp.height);
buf.drawImage(bmp, 0, 0);
return buf.getImageData(0, 0, bmp.width, bmp.height);
}
async function loadDataLocking(url: string, buf: Buffer): Promise<ImageData> {
return loadBitmap(url).then(i =>
navigator.locks.request('imagebuf', () => dataViaBuffer(i, buf)));
}
async function loadDataFresh(url: string): Promise<ImageData> {
const img = await loadBitmap(url);
let buf = new OffscreenCanvas(img.width, img.height).getContext('2d')!;
return dataViaBuffer(img, buf);
}
export function loadImageData(url: string, buf?: Buffer): Promise<ImageData> {
if (buf && navigator.locks) return loadDataLocking(url, buf);
else return loadDataFresh(url);
}
export const WIDTH = 1040;
export const HEIGHT = 713;
export function makeBuffer(width = WIDTH, height = HEIGHT): Buffer {
return new OffscreenCanvas(width, height).getContext('2d')!;
}
function makeBufferIfLocks(width?: number, height?: number): Buffer | undefined {
if (navigator.locks) return makeBuffer(width, height);
}
export type Layer = 'stroke' | 'static' | 'eyeshine' | Color.Layer;
// in compositing order
export const allLayers: Layer[] =
['stroke', 'static', 'outer', 'spines', 'stripes', 'cuffs', 'fins1', 'fins2',
'fins3', 'belly1', 'belly2', 'masks', 'claws', 'vitiligo1', 'vitiligo2',
'vitiligo3', 'vitiligo4', 'eyes', 'eyeshine', 'lines'];
export function makeLayerInfo<A>(f: (l: Layer) => A): Record<Layer, A> {
return Object.fromEntries(allLayers.map(l => [l, f(l)])) as Record<Layer, A>;
}
export async function makeLayerInfoAsync<A>(f: (l: Layer) => Promise<A>):
Promise<Record<Layer, A>> {
let list = await Promise.all(allLayers.map(l => f(l).then(res => [l, res])));
return Object.fromEntries(list);
}
export function loadLayers(dir: string): Promise<Record<Layer, ImageData>> {
let buf = makeBufferIfLocks(WIDTH, HEIGHT);
return makeLayerInfoAsync(l => loadImageData(`./${dir}/${l}.webp`, buf));
}
export type Position = [x: number, y: number];
export type Positions = Record<Layer, Position>;
export async function loadPos(dir: string): Promise<Positions> {
return (await fetch(`./${dir}/pos.json`)).json();
}
export type Side = 'front' | 'back';
export function swapSide(s: Side): Side {
return s == 'front' ? 'back' : 'front';
}
export type SideData = Record<Layer, [ImageData, Position]>;
export type Data = {
front: SideData, back: SideData,
frontImage?: ImageData, backImage?: ImageData,
};
export type ComposedData = Required<Data>;
export async function loadData(): Promise<Data> {
let [fl, fp, bl, bp] = await Promise.all([
loadLayers('front'), loadPos('front'),
loadLayers('back'), loadPos('back')
]);
return {
front: makeLayerInfo(l => [fl[l], fp[l]]),
back: makeLayerInfo(l => [bl[l], bp[l]]),
}
}
function recolor({ data }: ImageData, { r, g, b }: Color.Rgb) {
for (let i = 0; i < data.length; i += 4) {
data[i] = r; data[i+1] = g; data[i+2] = b;
}
}
export async function recolorAll(layers: Data, cols: Color.Rgbs) {
await Promise.all(Color.allLayers.map(l => {
recolor(layers.front[l][0], cols[l]);
recolor(layers.back[l][0], cols[l]);
}));
delete layers.frontImage; delete layers.backImage;
}
export type ComposeLayer = [ImageData, Position, GlobalCompositeOperation];
async function compose(buf: Buffer, layers: ComposeLayer[],
width: number, height: number): Promise<ImageData> {
buf.save();
buf.clearRect(0, 0, width, height);
const bmps = await Promise.all(layers.map(async ([l, [x, y], m]) =>
[await createImageBitmap(l), x, y, m] as const));
for (const [bmp, x, y, m] of bmps) {
buf.globalCompositeOperation = m;
buf.drawImage(bmp, x, y);
}
buf.restore();
return buf.getImageData(0, 0, width, height);
}
export async function
ensureComposed(buf: Buffer, data: Data): Promise<ComposedData> {
let { front, back } = data;
data.frontImage ??= await composeLayers(front);
data.backImage ??= await composeLayers(back);
return data as ComposedData;
function composeLayers(sdata: SideData): Promise<ImageData> {
return compose(buf, allLayers.map(l => makeLayer(l, sdata)), WIDTH, HEIGHT);
}
function makeLayer(l: Layer, sdata: SideData): ComposeLayer {
let [i, p] = sdata[l];
return [i, p, l == 'eyeshine' ? 'luminosity' : 'source-over'];
}
}
export async function redraw(ctx: CanvasRenderingContext2D,
buf: Buffer, data: ComposedData, side: Side) {
await ensureComposed(buf, data);
ctx.putImageData(data[`${side}Image`], 0, 0);
}

View file

@ -0,0 +1,64 @@
import { Rgb, Rgbs, rgb } from './color.js';
import { Layer } from './layer.js';
export type Color =
Exclude<Layer, 'eyeshine' | 'stroke' | 'static'>
| 'collars' | 'bells' | 'tongues' | 'socks' | 'sclera';
// in palette order
export const COLORS: Color[] =
['lines', 'outer', 'vitiligo1', 'spines', 'fins1', 'fins2', 'fins3',
'vitiligo4', 'belly1', 'vitiligo3', 'belly2', 'vitiligo2', 'sclera',
'eyes', 'tongues', 'masks', 'claws', 'socks', 'stripes', 'cuffs',
'collars', 'bells'];
export const NAMES: Partial<Record<Color, string>> = {
outer: 'outer body',
stripes: 'sock stripes',
cuffs: 'sock cuffs',
fins1: 'fins (outer)',
fins2: 'fins (mid)',
fins3: 'fins (inner)',
belly1: 'belly 1',
belly2: 'belly 2',
vitiligo1: 'outer body vitiligo',
vitiligo2: 'belly 2 vitiligo',
vitiligo3: 'belly 1 vitiligo',
vitiligo4: 'fins vitiligo',
};
export function name(l: Color): string {
return NAMES[l] ?? l;
}
export type StaticColor = Exclude<Color, Layer>;
export const STATIC_COLS: Record<StaticColor, Rgb> = {
collars: rgb(206, 75, 101),
bells: rgb(235, 178, 79),
tongues: rgb(222, 165, 184),
socks: rgb(238, 239, 228),
sclera: rgb(238, 239, 228),
};
export function get(col: Color, palette: Rgbs): Rgb {
type PPalette = Partial<Record<Color, Rgb>>;
let p = palette as PPalette;
let s = STATIC_COLS as PPalette;
return (p[col] ?? s[col])!;
}
export function make(seed: string, palette: Rgbs): Blob {
let lines = [
"GIMP Palette\n",
`Name: quox ${seed}\n`,
"Columns: 6\n\n",
];
for (const col of COLORS) {
let { r, g, b } = get(col, palette);
lines.push(`${r} ${g} ${b} ${name(col)}\n`);
}
return new Blob(lines, { type: 'application/x-gimp-palette' });
}

View file

@ -1,177 +1,16 @@
import * as Color from './color.js';
import { History } from './history.js';
import * as Layer from './layer.js';
import * as Palette from './palette.js';
async function loadBitmap(url: string): Promise<ImageBitmap> {
const img0 = new Image;
const img: Promise<ImageBitmapSource> = new Promise((ok, err) => {
img0.addEventListener('load', () => ok(img0));
img0.addEventListener('error', () => err(`couldn't load file: ${url}`));
});
img0.src = url;
return createImageBitmap(await img);
}
type Buffer = OffscreenCanvasRenderingContext2D;
function dataViaBuffer(bmp: ImageBitmap, buf: Buffer): ImageData {
buf.clearRect(0, 0, bmp.width, bmp.height);
buf.drawImage(bmp, 0, 0);
return buf.getImageData(0, 0, bmp.width, bmp.height);
}
async function loadDataLocking(url: string, buf: Buffer): Promise<ImageData> {
return loadBitmap(url).then(i =>
navigator.locks.request('imagebuf', () => dataViaBuffer(i, buf)));
}
async function loadDataFresh(url: string): Promise<ImageData> {
const img = await loadBitmap(url);
let buf = new OffscreenCanvas(img.width, img.height).getContext('2d')!;
return dataViaBuffer(img, buf);
}
function loadImageData(url: string, buf?: Buffer): Promise<ImageData> {
if (buf && navigator.locks) return loadDataLocking(url, buf);
else return loadDataFresh(url);
}
const WIDTH = 1040;
const HEIGHT = 713;
function makeBuffer(width = WIDTH, height = HEIGHT): Buffer {
return new OffscreenCanvas(width, height).getContext('2d')!;
}
function makeBufferIfLocks(width?: number, height?: number): Buffer | undefined {
if (navigator.locks) makeBuffer(width, height);
else return undefined;
}
export type Layer = 'stroke' | 'static' | 'eyeshine' | Color.Layer;
// in compositing order
export const allLayers: Layer[] =
['stroke', 'static', 'outer', 'spines', 'stripes', 'cuffs', 'fins1', 'fins2',
'fins3', 'belly1', 'belly2', 'masks', 'claws', 'vitiligo1', 'vitiligo2',
'vitiligo3', 'vitiligo4', 'eyes', 'eyeshine', 'lines'];
function makeLayerInfo<A>(f: (l: Layer) => A): Record<Layer, A> {
return Object.fromEntries(allLayers.map(l => [l, f(l)])) as Record<Layer, A>;
}
async function makeLayerInfoAsync<A>(f: (l: Layer) => Promise<A>):
Promise<Record<Layer, A>> {
let list = await Promise.all(allLayers.map(l => f(l).then(res => [l, res])));
return Object.fromEntries(list);
}
function loadLayers(dir: string): Promise<Record<Layer, ImageData>> {
let buf = makeBufferIfLocks(WIDTH, HEIGHT);
return makeLayerInfoAsync(l => loadImageData(`./${dir}/${l}.webp`, buf));
}
type Position = [x: number, y: number];
type Positions = Record<Layer, Position>;
async function loadPos(dir: string): Promise<Positions> {
return (await fetch(`./${dir}/pos.json`)).json();
}
type Side = 'front' | 'back';
function swapSide(s: Side): Side {
return s == 'front' ? 'back' : 'front';
}
type SideData = Record<Layer, [ImageData, Position]>;
type LayerData = {
front: SideData, back: SideData,
frontImage?: ImageData, backImage?: ImageData,
};
type ComposedData = Required<LayerData>;
async function loadData(): Promise<LayerData> {
let [fl, fp, bl, bp] = await Promise.all([
loadLayers('front'), loadPos('front'),
loadLayers('back'), loadPos('back')
]);
return {
front: makeLayerInfo(l => [fl[l], fp[l]]),
back: makeLayerInfo(l => [bl[l], bp[l]]),
}
}
function singleColor({ data }: ImageData, { r, g, b }: Color.Rgb) {
for (let i = 0; i < data.length; i += 4) {
data[i] = r; data[i+1] = g; data[i+2] = b;
}
}
async function recolorLayers(layers: LayerData, cols: Color.Rgbs) {
await Promise.all(Color.allLayers.map(l => {
singleColor(layers.front[l][0], cols[l]);
singleColor(layers.back[l][0], cols[l]);
}));
delete layers.frontImage; delete layers.backImage;
}
type ComposeLayer = [ImageData, Position, GlobalCompositeOperation];
async function compose(buf: Buffer, layers: ComposeLayer[],
width: number, height: number): Promise<ImageData> {
buf.save();
buf.clearRect(0, 0, width, height);
const bmps = await Promise.all(layers.map(async ([l, [x, y], m]) =>
[await createImageBitmap(l), x, y, m] as const));
for (const [bmp, x, y, m] of bmps) {
buf.globalCompositeOperation = m;
buf.drawImage(bmp, x, y);
}
buf.restore();
return buf.getImageData(0, 0, width, height);
}
async function
ensureComposed(buf: Buffer, data: LayerData): Promise<ComposedData> {
let { front, back } = data;
data.frontImage ??= await composeLayers(front);
data.backImage ??= await composeLayers(back);
return data as ComposedData;
function composeLayers(sdata: SideData): Promise<ImageData> {
return compose(buf, allLayers.map(l => makeLayer(l, sdata)), WIDTH, HEIGHT);
}
function makeLayer(l: Layer, sdata: SideData): ComposeLayer {
let [i, p] = sdata[l];
return [i, p, l == 'eyeshine' ? 'luminosity' : 'source-over'];
}
}
async function redraw(ctx: CanvasRenderingContext2D,
buf: Buffer, data: ComposedData, side: Side) {
await ensureComposed(buf, data);
ctx.putImageData(data[`${side}Image`], 0, 0);
}
function message(msg: string, error = false) {
function message(msg: string, size = 100) {
const ctx = getCanvasCtx('main');
const size = error ? 30 : 100;
ctx.save();
ctx.clearRect(0, 0, WIDTH, HEIGHT);
ctx.clearRect(0, 0, Layer.WIDTH, Layer.HEIGHT);
ctx.font = `bold ${size}px Muller, sans-serif`;
ctx.textAlign = 'center';
ctx.fillText(msg, WIDTH/2, HEIGHT/2, WIDTH-10);
ctx.fillText(msg, Layer.WIDTH/2, Layer.HEIGHT/2, Layer.WIDTH-10);
ctx.restore();
}
@ -187,19 +26,19 @@ function updateUrl(seed: string): void {
type ApplyStateOpts = {
seed: string,
side?: Side,
side?: Layer.Side,
firstLoad?: boolean,
buf?: Buffer,
buf?: Layer.Buffer,
history?: History,
done?: Done,
};
async function
applyState(data: LayerData, opts: ApplyStateOpts): Promise<string> {
applyState(data: Layer.Data, opts: ApplyStateOpts): Promise<string> {
let { side, seed, firstLoad, buf, history, done } = opts;
side ??= 'front';
firstLoad ??= false;
buf ??= new OffscreenCanvas(WIDTH, HEIGHT).getContext('2d')!;
buf ??= Layer.makeBuffer();
done ??= () => {};
let rand = new Color.Rand(seed);
@ -208,7 +47,7 @@ applyState(data: LayerData, opts: ApplyStateOpts): Promise<string> {
const rgb = Color.toRgbs(oklch);
const newSeed = rand.alphaNum();
await recolorLayers(data, rgb);
await Layer.recolorAll(data, rgb);
updateBg(oklch);
updateSvgs(oklch, rgb);
@ -216,7 +55,7 @@ applyState(data: LayerData, opts: ApplyStateOpts): Promise<string> {
updateUrl(seed);
if (firstLoad) {
await instantUpdateImage(side, await ensureComposed(buf, data));
await instantUpdateImage(side, await Layer.ensureComposed(buf, data));
done();
} else {
await animateUpdateImage(buf, side, data, done);
@ -235,7 +74,7 @@ function getCanvasCtx(id: CanvasId) {
}
async function
instantUpdateImage(side: Side, data: ComposedData) {
instantUpdateImage(side: Layer.Side, data: Layer.ComposedData) {
getCanvasCtx('main').putImageData(data[`${side}Image`], 0, 0);
}
@ -244,9 +83,10 @@ type Done = () => void;
const noAnim = matchMedia('(prefers-reduced-motion: reduce)');
async function
animateUpdateImage(buf: Buffer, side: Side, data: LayerData, done: Done) {
animateUpdateImage(buf: Layer.Buffer, side: Layer.Side,
data: Layer.Data, done: Done) {
if (noAnim.matches) {
instantUpdateImage(side, await ensureComposed(buf, data));
instantUpdateImage(side, await Layer.ensureComposed(buf, data));
done();
return;
}
@ -257,11 +97,11 @@ animateUpdateImage(buf: Buffer, side: Side, data: LayerData, done: Done) {
const aux = getCanvasCtx('aux');
document.documentElement.dataset.running = 'reroll';
const cdata = await ensureComposed(buf, data);
redraw(aux, buf, cdata, side);
const cdata = await Layer.ensureComposed(buf, data);
Layer.redraw(aux, buf, cdata, side);
aux.canvas.addEventListener('animationend', async () => {
await redraw(main, buf, cdata, side);
await Layer.redraw(main, buf, cdata, side);
aux.canvas.style.removeProperty('animation');
delete document.documentElement.dataset.running;
done();
@ -273,7 +113,8 @@ animateUpdateImage(buf: Buffer, side: Side, data: LayerData, done: Done) {
}
async function
animateSwapImage(buf: Buffer, newSide: Side, data: ComposedData, done: Done) {
animateSwapImage(buf: Layer.Buffer, newSide: Layer.Side,
data: Layer.ComposedData, done: Done) {
if (noAnim.matches) {
instantUpdateImage(newSide, data);
done();
@ -286,10 +127,10 @@ animateSwapImage(buf: Buffer, newSide: Side, data: ComposedData, done: Done) {
const aux = getCanvasCtx('aux');
document.documentElement.dataset.running = 'swap';
await redraw(aux, buf, data, newSide);
await Layer.redraw(aux, buf, data, newSide);
aux.canvas.addEventListener('animationend', async () => {
const image = aux.getImageData(0, 0, WIDTH, HEIGHT);
const image = aux.getImageData(0, 0, Layer.WIDTH, Layer.HEIGHT);
main.putImageData(image, 0, 0);
main.canvas.style.removeProperty('animation');
@ -342,7 +183,7 @@ function updateSvgs(oklch: Color.Colors, rgb: Color.Rgbs) {
}
function showHistory(history: History, data: LayerData,
function showHistory(history: History, data: Layer.Data,
opts: Omit<ApplyStateOpts, 'seed' | 'history'>) {
const list = document.getElementById('history-items');
if (!list) return;
@ -369,7 +210,7 @@ function showHistory(history: History, data: LayerData,
function closeHistory() {
document.getElementById('history-items')?.
scroll({top: 0, left: 0, behavior: 'smooth'});
scroll({ top: 0, left: 0, behavior: 'smooth' });
let field = document.getElementById('history-close-target');
if (field) field.parentElement?.removeChild(field);
document.documentElement.dataset.state = 'ready';
@ -377,18 +218,7 @@ function closeHistory() {
function download(seed: string) {
const colors = Color.toRgbs(Color.colors(new Color.Rand(seed)));
let lines = [
"GIMP Palette\n",
`Name: quox ${seed}\n\n`,
];
for (const name of Color.allLayers) {
let { r, g, b } = colors[name];
lines.push(`${r} ${g} ${b} ${name}\n`);
}
const blob = new Blob(lines, { type: 'application/x-gimp-palette' });
const blob = Palette.make(seed, colors);
// there must be a better way to push out a file than
// this autohotkey-ass nonsense
@ -403,15 +233,15 @@ function download(seed: string) {
async function setup() {
message('loading layers…');
let data = await loadData().catch(e => { message(e, true); throw e });
let history = History.loadOrClear();
let data = await Layer.loadData().catch(e => { message(e, 30); throw e });
let history = History.load();
let buf = new OffscreenCanvas(WIDTH, HEIGHT).getContext('2d')!;
let buf = Layer.makeBuffer();
let prevSeed = urlState() ?? new Color.Rand().alphaNum();
let seed =
await applyState(data, { seed: prevSeed, buf, history, firstLoad: true });
let side: Side = 'front';
let side: Layer.Side = 'front';
const reroll = document.getElementById('reroll')!;
const swap = document.getElementById('swap')!;
@ -473,8 +303,8 @@ async function setup() {
}
function runSwap() {
run(async k => {
side = swapSide(side);
const cdata = await ensureComposed(buf, data);
side = Layer.swapSide(side);
const cdata = await Layer.ensureComposed(buf, data);
await animateSwapImage(buf, side, cdata, k);
});
}

View file

@ -0,0 +1,71 @@
$box-texture: url(3px-tile.png);
$box-bg: oklch(0.3 0.2 var(--hue));
$box-fg: oklch(0.95 0.075 var(--c-hue));
$button-bg: oklch(0.5 0.25 var(--hue));
$button-fg: oklch(0.98 0.1 var(--c-hue));
// https://oakreef.ie/transy :)
$transition-duration: 250ms;
$transition-curve: cubic-bezier(.47,.74,.61,1.2);
@mixin transy($prop: transform, $duration: $transition-duration) {
transition: $prop $duration $transition-curve;
}
@mixin shadow {
filter: drop-shadow(6px 6px 0 oklch(0.4 0.2 var(--hue) / 0.45));
@media (prefers-color-scheme: dark) {
filter: drop-shadow(6px 6px 0 oklch(0.1 0.125 var(--hue) / 0.45));
}
}
@mixin box-base {
@include shadow;
// respecify font family for <button>
font: 700 20pt var(--font);
padding: 0.2rem 0.5rem;
background-blend-mode: hard-light;
border: 3px solid oklch(0.2 0.05 var(--hue));
}
@mixin button {
@include box-base;
background: $box-texture, $button-bg;
color: $button-fg;
}
@mixin box {
@include box-base;
background: $box-texture, $box-bg;
color: $box-fg;
}
@mixin image-button {
@include button;
padding: 5px;
> * { display: block; }
}
@mixin nested-image-button {
@include image-button;
border: 2px solid $button-fg;
}
$arrowhead:
conic-gradient(from -124deg at 100% 50%,
currentcolor, currentcolor 68deg, transparent 68deg);
@mixin arrow-button {
@include button;
&::before {
content: '';
display: inline-block;
height: 1.25ex; aspect-ratio: 2/3;
background: $arrowhead;
margin-right: 0.5ex;
}
}