history, reduced-motion, editable name box, an easter egg

This commit is contained in:
Rhiannon Morris 2024-12-13 03:14:37 +01:00
parent d52151e787
commit 0a59aa66f6
14 changed files with 782 additions and 127 deletions

View file

@ -2,6 +2,8 @@
<svg xmlns="http://www.w3.org/2000/svg"
width="65" height="65" viewBox="-5 -2.5 65 65"
stroke="#123" stroke-width="2">
<title>go back</title>
<desc>back to the cube.</desc>
<defs>
<linearGradient id="g-arrow-mask">

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2 KiB

16
rainbow-quox/close.svg Normal file
View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<svg xmlns="http://www.w3.org/2000/svg"
width="25" height="25" viewBox="0 0 65 65">
<title>close</title>
<defs>
<linearGradient id="cross-gradient" y1="100%" y2="0%">
<stop offset="20%" stop-color="hsl(60 90% 95%)" />
<stop offset="100%" stop-color="hsl(60 80% 90%)" />
</linearGradient>
</defs>
<path fill="url(#cross-gradient)" transform='rotate(45 32.5 32.5)'
d="M 27.5,0 v 27.5 h -27.5 v 10 h 27.5 v 27.5 h 10
v -27.5 h 27.5 v -10 h -27.5 v -27.5 z" />
</svg>

After

Width:  |  Height:  |  Size: 549 B

43
rainbow-quox/history.svg Normal file
View file

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<svg xmlns="http://www.w3.org/2000/svg"
width="30" height="30" viewBox="0 0 100 100">
<defs>
<linearGradient id="arrow-color-fade" 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="arrow-alpha-fade" y1="100%" y2="0%">
<stop offset="25%" stop-color="white" />
<stop offset="75%" stop-color="black" />
</linearGradient>
<path id="arrow-path"
d="M 50,10 a 40,40 0 1 0 28.28,68.28
v15 h10 v-32.07 h-32.07 v10 h15
a 30,30 0 1 1 -21.21,-51.21 z" />
<mask id="arrow-mask">
<rect width="100" height="100" fill="black" />
<use href="#arrow-path" fill="white" />
</mask>
<mask id="fade-mask">
<rect width="100" height="100" fill="white" />
<rect width="55" height="55" fill="url(#arrow-alpha-fade)" />
</mask>
</defs>
<g id="arrow" mask="url(#fade-mask)">
<use href="#arrow-path" fill="none" />
<g id="arrow-fill" mask="url(#arrow-mask)">
<rect width="100" height="100" fill="hsl(60 90% 95%)" />
<rect x="40" y="40" width="60" height="60" fill="url(#arrow-color-fade)" />
</g>
</g>
<path id="hands" fill="url(#arrow-color-fade)" stroke="#123"
d="M 46,28 v 28 h 28 v -8 h -20 v -20 h -8 z" />
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -1,5 +1,6 @@
<!doctype html>
<html lang=en data-state=loading>
<meta charset=utf-8>
<meta name=viewport content='width=device-width, initial-scale=0.5'>
@ -15,14 +16,31 @@
</div>
<div id=showui-holder>
<img id=showui src=showui.svg alt='show ui' title='show ui'>
<button id=showui>
<img src=showui.svg alt='show ui' title='show ui'>
</button>
</div>
<nav id=back>
<a href=/><img src=back.svg alt=back title=back></a>
<a href=/><img src=back.svg alt=back title='back to the cube.'></a>
</nav>
<div id=state-message class=empty> quox #<span id=state>0</span> </div>
<div id=history>
<button id=close-history>
<img src=close.svg alt=close title=close>
</button>
<div id=current>
<span id=current-hello>hello my name is</span>
<span id=current-name contenteditable>anonymous</span>
<button id=history-button>
<img src=history.svg alt=history title=history>
</button>
</div>
<div id=history-items>
</div>
</div>
<div id=pic-holder>
<canvas id=main width=1000 height=673>

View file

@ -52,11 +52,26 @@ function isLight(l: Luma): boolean { return l >= MINL_LIGHT; }
export namespace Rand { export type State = R.State; }
export class Rand extends R.Rand {
constructor(seed?: R.State) { super(seed); }
type CloseFar = 'close' | 'far';
lightFor(baseL: Luma): Luma { return this.float(baseL, MAXL); }
darkFor(baseL: Luma): Luma { return this.float(MINL, baseL); }
export class Rand extends R.Rand {
constructor();
constructor([a, b, c, d]: Rand.State);
constructor(str: string);
constructor(st?: Rand.State | string) {
if (st === undefined) super();
else if (typeof st === 'string') super(st);
else super(st);
}
lightFor(baseL: Luma, d: CloseFar = 'close'): Luma {
let maxl = d == 'close' ? min(MAXL, baseL * 1.25) : MAXL;
return this.float(baseL, maxl);
}
darkFor(baseL: Luma, d: CloseFar = 'close'): Luma {
let minl = d == 'close' ? max(MINL, baseL * 0.8) : MINL
return this.float(minl, baseL);
}
brightFor(l: Luma, baseC: Chroma): Chroma {
return this.float(baseC, isLight(l) ? MAXC_LIGHT : MAXC_DARK);
@ -140,14 +155,6 @@ export class Oklch {
}
}
css(alpha: number = 1): string {
const l = (this.l * 100).toFixed(0);
const c = (this.c * 250).toFixed(0);
const h = this.h.toFixed(0);
if (alpha != 1) { return `oklch(${l}% ${c}% ${h} / ${alpha})`; }
else { return `oklch(${l}% ${c}% ${h})`; }
}
with(maps: Oklch.With): Oklch {
function call(comp: Oklch.With1, x: number) {
switch (typeof comp) {
@ -163,7 +170,23 @@ export class Oklch {
});
}
css(alpha: number = 1): string {
const l = (this.l * 100).toFixed(0);
const c = (this.c * 250).toFixed(0);
const h = this.h.toFixed(0);
if (alpha != 1) { return `oklch(${l}% ${c}% ${h} / ${alpha})`; }
else { return `oklch(${l}% ${c}% ${h})`; }
}
rgb(): Rgb { return toRgbViaCanvas(this); }
static validate(x: unknown): Oklch | undefined {
if (typeof x == 'object' && x != null && 'l' in x && 'c' in x && 'h' in x) {
const { l, c, h } = x;
if (typeof l == 'number' && typeof c == 'number' && typeof h == 'number')
return oklch(l, c, h);
}
}
}
@ -196,9 +219,15 @@ export function makeColorInfo<A>(f: (l: Layer) => A): Record<Layer, A> {
return Object.fromEntries(allLayers.map(l => [l, f(l)])) as Record<Layer, A>;
}
export type BaseCol = 'outer' | 'belly' | 'fins';
export type OptionalBaseCol = 'eyes' | 'stripes';
export function colors(r: Rand = new Rand()): Scheme {
const outer = new Oklch(r, 'dark');
type KnownPalette =
Record<BaseCol, Oklch> & Partial<Record<OptionalBaseCol, Oklch>>;
export function colors(r: Rand = new Rand(), base?: KnownPalette): Scheme {
const outer = base?.outer ?? new Oklch(r, 'dark');
let outerCols: OuterCols =
{ outer, spines: mkSpines(r, outer), vitiligo1: mkVitiligo(r, outer) };
@ -210,18 +239,18 @@ export function colors(r: Rand = new Rand()): Scheme {
if (whichBody > 2/3) {
type = 'triad';
const [f, b] = r.triad(outer.h);
finCols = mkFins(r, f, outer); bellyCols = mkBelly(r, b);
finCols = mkFins(r, f, outer, base); bellyCols = mkBelly(r, b, base);
} else if (whichBody > 1/3) {
type = 'fin-belly';
const [f, b] = r.complementary(outer.h, 2);
finCols = mkFins(r, f!, outer); bellyCols = mkBelly(r, b!);
finCols = mkFins(r, f!, outer, base); bellyCols = mkBelly(r, b!, base);
} else {
type = 'fin-body';
finCols = mkFins(r, r.analogous1(outer.h), outer);
bellyCols = mkBelly(r, r.complementary1(outer.h));
finCols = mkFins(r, r.analogous1(outer.h), outer, base);
bellyCols = mkBelly(r, r.complementary1(outer.h), base);
}
let miscCols = mkMisc(r, outerCols, finCols, bellyCols);
let miscCols = mkMisc(r, outerCols, finCols, bellyCols, base);
return merge(outerCols, sockCols, finCols, bellyCols, miscCols, type);
}
@ -229,9 +258,9 @@ export function colors(r: Rand = new Rand()): Scheme {
function mkSpines(r: Rand, outer: Oklch): Oklch {
return outer.with({
l: x => x * 0.8,
c: x => x * 1.1,
h: x => r.float(x + 12, x - 12),
l: l => r.darkFor(l),
c: c => r.brightFor(outer.l, c),
h: h => r.float(h + 12, h - 12),
})
}
@ -258,8 +287,10 @@ function mkCuffs(r: Rand, sock: Oklch): Oklch {
});
}
function mkFins(r: Rand, h: Hue, outer: Oklch): FinCols {
const [fin1Hue, fin2Hue, fin3Hue] = r.analogous(h, 3);
function mkFins(r: Rand, h: Hue, outer: Oklch, base?: KnownPalette): FinCols {
const baseFin1 = base?.fins;
const [fin1Hue, fin2Hue, fin3Hue] = r.analogous(baseFin1?.h ?? h, 3);
const direction: 'lighter' | 'darker' = r.choice(['lighter', 'darker']);
function ll(l: Luma): Luma {
@ -269,17 +300,18 @@ function mkFins(r: Rand, h: Hue, outer: Oklch): FinCols {
return direction == 'lighter' ? r.dullFor(l, c) : r.brightFor(l, c);
}
const fins1 = new Oklch(ll(outer.l), cc(outer.l, outer.c), fin1Hue!);
const fins2 = new Oklch(ll(fins1.l), cc(fins1.l, fins1.c), fin2Hue!);
const fins3 = new Oklch(ll(fins2.l), cc(fins2.l, fins2.c), fin3Hue!);
const fins1 = baseFin1 ?? oklch(ll(outer.l), cc(outer.l, outer.c), fin1Hue!);
const fins2 = oklch(ll(fins1.l), cc(fins1.l, fins1.c), fin2Hue!);
const fins3 = oklch(ll(fins2.l), cc(fins2.l, fins2.c), fin3Hue!);
const lighter = fins1.l >= fins3.l ? fins1 : fins3;
const vitiligo4 = mkVitiligo(r, lighter);
return { fins1, fins2, fins3, vitiligo4 };
}
function mkBelly(r: Rand, h: Hue): BellyCols {
const [belly1Hue, belly2Hue] = r.analogous(h, 2);
const belly1 = new Oklch({
function mkBelly(r: Rand, h: Hue, base?: KnownPalette): BellyCols {
let baseBelly1 = base?.belly;
const [belly1Hue, belly2Hue] = r.analogous(baseBelly1?.h ?? h, 2);
const belly1 = baseBelly1 ?? new Oklch({
l: r.float(0.7, MAXL),
c: r.baseChroma(1),
h: belly1Hue!
@ -294,7 +326,8 @@ function mkBelly(r: Rand, h: Hue): BellyCols {
return { belly1, belly2, vitiligo2, vitiligo3 };
}
function mkMisc(r: Rand, o: OuterCols, f: FinCols, b: BellyCols): MiscCols {
function mkMisc(r: Rand, o: OuterCols, f: FinCols, b: BellyCols,
base?: KnownPalette): MiscCols {
const masks = new Oklch({
l: r.float(0.8, MAXL),
c: r.float(0.01, 0.06),
@ -302,7 +335,7 @@ function mkMisc(r: Rand, o: OuterCols, f: FinCols, b: BellyCols): MiscCols {
});
return {
masks,
eyes: new Oklch({
eyes: base?.eyes ?? new Oklch({
l: r.baseLuma('light'),
c: r.float(0.28, MAXC_LIGHT),
h: r.boolean() ? r.analogous1(o.outer.h) : r.complementary1(o.outer.h)
@ -365,6 +398,14 @@ export class Rgb {
}
return `#${h(this.r)}${h(this.g)}${h(this.b)}`
}
static validate(x: unknown): Rgb | undefined {
if (typeof x == 'object' && x != null && 'r' in x && 'g' in x && 'b' in x) {
const { r, g, b } = x;
if (typeof r == 'number' && typeof g == 'number' && typeof b == 'number')
return rgb(r, g, b);
}
}
}
export type Rgbs = Record<Layer, Rgb>;
@ -375,8 +416,8 @@ export function toRgbViaCanvas(col: Oklch): Rgb {
rgbBuf ??= new OffscreenCanvas(1, 1).getContext('2d')!;
rgbBuf.fillStyle = col.css();
rgbBuf.fillRect(0, 0, 1, 1);
const rgb = rgbBuf.getImageData(0, 0, 1, 1).data;
return new Rgb(rgb[0]!, rgb[1]!, rgb[2]!);
const pix = rgbBuf.getImageData(0, 0, 1, 1).data;
return rgb(pix[0]!, pix[1]!, pix[2]!);
}
export function toRgbs(col: Colors): Rgbs {
@ -390,3 +431,82 @@ export function toHex({r, g, b}: Rgb): string {
}
return `#${chan(r)}${chan(g)}${chan(b)}`;
}
export function oklch(l: number, c: number, h: number) {
return new Oklch(l, c, h);
}
export function rgb(r: number, g: number, b: number) {
return new Rgb(r, g, b);
}
export const KNOWN: Record<string, KnownPalette> = {
niss: {
outer: oklch(0.83, 0.201, 151),
belly: oklch(0.87, 0.082, 99),
fins: oklch(0.68, 0.178, 16),
eyes: oklch(0.73, 0.135, 242),
},
kesi: {
outer: oklch(0.86, 0.147, 147),
belly: oklch(0.96, 0.04, 108),
fins: oklch(0.94, 0.142, 102),
eyes: oklch(0.76, 0.115, 300),
},
60309: {
outer: oklch(0.84, 0.068, 212),
belly: oklch(0.56, 0.035, 233),
fins: oklch(0.55, 0.101, 268),
eyes: oklch(0.86, 0.146, 154),
},
'prickly pear': {
outer: oklch(0.64, 0.087, 316),
belly: oklch(0.88, 0.03, 88),
fins: oklch(0.6, 0.071, 142),
eyes: oklch(0.66, 0.091, 134),
},
'the goo': {
outer: oklch(0.92, 0.046, 354),
belly: oklch(0.83, 0.099, 354),
fins: oklch(0.74, 0.115, 354),
eyes: oklch(0.73, 0.149, 0),
},
lambda: {
outer: oklch(0.71, 0.154, 58),
belly: oklch(0.9, 0.05, 80),
fins: oklch(0.76, 0.16, 140),
eyes: oklch(0.82, 0.178, 141),
},
flussence: {
outer: oklch(0.77, 0.118, 133),
belly: oklch(0.71, 0.086, 253),
fins: oklch(0.58, 0.102, 254),
eyes: oklch(0.37, 0.107, 278),
},
serena: {
outer: oklch(0.69, 0.176, 349),
belly: oklch(0.92, 0.04, 350),
fins: oklch(0.74, 0.138, 319),
eyes: oklch(0.65, 0.206, 4),
},
pippin: {
outer: oklch(0.74, 0.08, 61),
belly: oklch(0.82, 0.062, 70),
fins: oklch(0.52, 0.09, 45),
eyes: oklch(0.74, 0.167, 136),
},
su: {
outer: oklch(0.29, 0.012, 219),
belly: oklch(0.89, 0.01, 256),
fins: oklch(0.53, 0.093, 20),
// eyes: oklch(0.53, 0.109, 254),
},
trans: {
outer: oklch(0.83, 0.065, 228),
belly: oklch(0.95, 0.021, 137),
fins: oklch(0.86, 0.069, 352),
// eyes: oklch(0.57, 0.158, 273),
},
};

View file

@ -0,0 +1,128 @@
import { Colors as Oklchs, Rgbs } from './color.js';
import * as Color from './color.js';
export class HistoryItem {
name: string;
oklch: Oklchs;
rgb: Rgbs;
constructor(name: string, oklch: Oklchs, rgb: Rgbs) {
this.oklch = oklch;
this.rgb = rgb;
this.name = name;
}
asHtml(): HTMLButtonElement {
const { lines: bg, 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()}" />
<path fill="${fins.css()}" d="M 60,0 h -57.73 v 100 z">
<title>fin colour: ${fins.css()}</title>
</path>
<path fill="${belly.css()}" d="M 70,0 h 40 l -57.73,100 h -40 z">
<title>belly colour: ${belly.css()}</title>
</path>
<path fill="${outer.css()}" d="M 120,0 v 100 h -57.73 z">
<title>outer body colour: ${outer.css()}</title>
</path>
<desc>
sample of the palette for ${this.name}.
fin colour: ${fins.css()}.
belly colour: ${belly.css()}.
outer body colour: ${outer.css()}.
</desc>
</svg>
<span class=history-name>${this.name}</span>
`;
let button = document.createElement('button');
button.className = 'history-item';
button.dataset.name = this.name;
button.innerHTML = content;
return button;
}
}
export class History {
items: string[];
constructor(items: string[] = []) { this.items = items; }
add(name: string): void { this.items.push(name); }
*iterNames(maxLength?: number | null): 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;
const name = this.items[i]!;
if (!name || seen.has(name)) continue;
seen.add(name); done++;
yield name;
}
}
*iterItems(maxLength?: number | null): 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);
yield new HistoryItem(name, oklch, rgbs);
}
}
static validate(x: unknown): History | undefined {
if (!Array.isArray(x)) return;
if (!x.every(i => typeof i === 'string')) return;
return new History(x);
}
toJSON() { return this.items; }
save(persist = true) {
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 {
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;
}
addSave(name: string, persist = true): void {
this.add(name);
this.save(persist);
}
prune(maxLength?: number | null) {
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

@ -1,6 +1,5 @@
import * as Color from './color.js';
type State = Color.Rand.State;
import { History } from './history.js';
async function loadBitmap(url: string): Promise<ImageBitmap> {
@ -177,46 +176,55 @@ function message(msg: string, error = false) {
}
function urlState(): State | undefined {
const str = document.location.hash;
if (str?.match(/^#\d+$/)) return parseInt(str.substring(1));
function urlState(): string | undefined {
let hash = document.location.hash?.substring(1);
if (hash != '' && hash !== undefined) return decodeURI(hash);
}
function updateUrl(state: State): void {
history.replaceState({}, '', `#${state}`);
function updateUrl(seed: string): void {
history.replaceState({}, '', `#${encodeURI(seed)}`);
}
type ApplyStateOpts =
{ side: Side, state: State, firstLoad: boolean, buf: Buffer, done: Done };
type ApplyStateOpts = {
seed: string,
side?: Side,
firstLoad?: boolean,
buf?: Buffer,
history?: History,
done?: Done,
};
async function
applyState(data: LayerData, opts: Partial<ApplyStateOpts>): Promise<State> {
let { side, state, firstLoad, buf, done } = opts;
applyState(data: LayerData, opts: ApplyStateOpts): Promise<string> {
let { side, seed, firstLoad, buf, history, done } = opts;
side ??= 'front';
firstLoad ??= false;
buf ??= new OffscreenCanvas(WIDTH, HEIGHT).getContext('2d')!;
done ??= () => {};
let r = new Color.Rand(state);
const initState = r.state;
let rand = new Color.Rand(seed);
const oklch = Color.colors(r);
const oklch = Color.colors(rand, Color.KNOWN[seed]);
const rgb = Color.toRgbs(oklch);
const newState = r.state;
const newSeed = rand.alphaNum();
await recolorLayers(data, rgb);
updateBg(oklch);
updateSvgs(oklch, rgb);
updateLabel(initState);
updateUrl(initState);
updateLabel(seed);
updateUrl(seed);
if (firstLoad) {
await instantUpdateImage(side, await ensureComposed(buf, data));
done();
} else {
await animateUpdateImage(buf, side, data, done ?? (() => {}));
await animateUpdateImage(buf, side, data, done);
}
return newState;
if (history) history.addSave(seed);
return newSeed;
}
type CanvasId = 'main' | 'aux';
@ -233,8 +241,16 @@ instantUpdateImage(side: Side, data: ComposedData) {
type Done = () => void;
const noAnim = matchMedia('(prefers-reduced-motion: reduce)');
async function
animateUpdateImage(buf: Buffer, side: Side, data: LayerData, done: Done) {
if (noAnim.matches) {
instantUpdateImage(side, await ensureComposed(buf, data));
done();
return;
}
const duration = 200;
const main = getCanvasCtx('main');
@ -258,6 +274,12 @@ animateUpdateImage(buf: Buffer, side: Side, data: LayerData, done: Done) {
async function
animateSwapImage(buf: Buffer, newSide: Side, data: ComposedData, done: Done) {
if (noAnim.matches) {
instantUpdateImage(newSide, data);
done();
return;
}
const duration = 400;
const main = getCanvasCtx('main');
@ -284,9 +306,9 @@ function updateBg(cols: Color.Colors) {
document.documentElement.style.setProperty('--hue', `${cols.outer.h}`);
}
function updateLabel(st: State) {
const stateLabel = document.getElementById('state');
if (stateLabel) stateLabel.innerHTML = `${st}`;
function updateLabel(seed: string) {
const stateLabel = document.getElementById('current-name');
if (stateLabel) stateLabel.innerHTML = seed;
}
function updateSvgs(oklch: Color.Colors, rgb: Color.Rgbs) {
@ -327,21 +349,56 @@ function updateSvgs(oklch: Color.Colors, rgb: Color.Rgbs) {
}
function showHistory(history: History, data: LayerData,
opts: Omit<ApplyStateOpts, 'seed' | 'history'>) {
const list = document.getElementById('history-items');
if (!list) return;
list.innerHTML = '';
let { side, firstLoad, buf, done } = opts;
for (const item of history.iterItems()) {
const elem = item.asHtml();
let allOpts = { side, firstLoad, buf, done, seed: item.name, history };
elem.addEventListener('click', () => {
applyState(data, allOpts);
});
list.appendChild(elem);
}
setTimeout(() => {
document.documentElement.dataset.state = 'history';
const elem = document.createElement('div');
elem.id = 'history-close-target';
elem.addEventListener('click', closeHistory);
document.body.appendChild(elem);
});
}
function closeHistory() {
document.getElementById('history-items')?.
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';
}
async function setup() {
message('loading layers…');
let data = await loadData().catch(e => { message(e, true); throw e });
let history = History.loadOrClear();
let buf = new OffscreenCanvas(WIDTH, HEIGHT).getContext('2d')!;
let state = urlState();
let seed = urlState() ?? new Color.Rand().alphaNum();
let side: Side = 'front';
state = await applyState(data, { state, buf, firstLoad: true });
seed = await applyState(data, { seed, buf, history, firstLoad: true });
const reroll = document.getElementById('reroll')!;
const swap = document.getElementById('swap')!;
addListeners();
// these ones don't need to be toggled
document.getElementById('hideui')?.addEventListener('click', () => {
document.documentElement.dataset.state = 'fullquox';
@ -349,6 +406,24 @@ async function setup() {
document.getElementById('showui')?.addEventListener('click', () => {
document.documentElement.dataset.state = 'ready';
});
document.getElementById('history-button')?.addEventListener('click', () => {
// does this need the add/remove listeners dance
// actually does anything any more?
showHistory(history, data, { side, buf });
});
document.getElementById('close-history')?.addEventListener('click', closeHistory);
document.getElementById('current-name')?.addEventListener('focusout', async e => {
const space = String.raw`(\n|\s|<br>|&nbsp;)`;
const re = new RegExp(`^${space}+|${space}+$`, 'msgu');
let elem = e.target as HTMLElement;
let str = elem.innerText.replaceAll(re, '');
if (!str) str = new Color.Rand().alphaNum();
elem.innerText = str;
// todo allow images cos it's funny
seed = await applyState(data, { side, seed: str, buf, history });
});
document.documentElement.dataset.state = 'ready';
@ -359,15 +434,16 @@ async function setup() {
function updateFromUrl() {
run(async k => {
const newState = urlState();
if (newState) {
state = await applyState(data, { side, state: newState, buf, done: k });
const newSeed = urlState();
if (newSeed) {
const opts = { history, side, seed: newSeed, buf, done: k };
seed = await applyState(data, opts);
}
});
}
function runReroll() {
run(async k => {
state = await applyState(data, { side, state, buf, done: k });
seed = await applyState(data, { side, seed, buf, history, done: k });
});
}
function runSwap() {

View file

@ -1,45 +1,123 @@
// https://stackoverflow.com/a/424445 thanks my dude
// https://stackoverflow.com/questions/521295#47593316
export type State = number;
export type State = [number, number, number, number];
const M = 0x80000000;
const A = 1103515245;
const C = 12345;
export class Rand {
state: number;
constructor(state?: State) {
this.state = typeof state == 'number' && !isNaN(state) ?
state : Math.floor(Math.random() * (M - 1));
}
#next(): number { return this.state = (A * this.state + C) % M; }
#float(x?: number, y?: number): number {
const [lo, hi] =
x === undefined ? [0, 1] :
y === undefined ? [0, x] :
[Math.min(x, y), Math.max(x, y)];
return lo + this.#next() / (M - 1) * (hi - lo);
}
export interface Randy {
state: State;
int(): number; // whole int32 range
int(x: number): number; // [0, x)
int(x: number, y: number): number; // [x, y)
float(): number; // [0, 1)
float(x: number): number; // [0, x)
float(x: number, y: number): number; // [x, y)
choice<A>(array: A[]): A;
boolean(): boolean;
}
export function fromHex(s: string): State {
const a = s.substring(0, 8);
const b = s.substring(8, 16);
const c = s.substring(16, 24);
const d = s.substring(24, 32);
return [h(a), h(b), h(c), h(d)];
function h(x: string) { return parseInt(x, 16); }
}
export function toHex([a, b, c, d]: State): string {
return `${h(a)}${h(b)}${h(c)}${h(d)}`;
function h(x: number) { return x.toString(16); }
}
const UINT_MAX = 4294967296;
export class Rand implements Randy {
#a: number; #b: number; #c: number; #d: number;
constructor();
constructor([a, b, c, d]: State);
constructor(str: string);
constructor(st?: State | string) {
const [a, b, c, d] =
st === '' ? s4() :
typeof st === 'string' ? Rand.stateFrom(st) :
st ?? s4();
this.#a = a; this.#b = b; this.#c = c; this.#d = d;
for (let i = 0; i < 20; ++i) this.#next();
function s() { return (Math.random() * 2**32) >>> 0; }
function s4() { return [s(), s(), s(), s()] as const; }
}
get state(): State { return [this.#a, this.#b, this.#c, this.#d]; }
get stateString() { return toHex(this.state); }
static stateFrom(str: string): State {
let h1 = 1779033703, h2 = 3144134277,
h3 = 1013904242, h4 = 2773480762;
for (let i = 0, k; i < str.length; i++) {
k = str.charCodeAt(i);
h1 = h2 ^ Math.imul(h1 ^ k, 597399067);
h2 = h3 ^ Math.imul(h2 ^ k, 2869860233);
h3 = h4 ^ Math.imul(h3 ^ k, 951274213);
h4 = h1 ^ Math.imul(h4 ^ k, 2716044179);
}
h1 = Math.imul(h3 ^ (h1 >>> 18), 597399067);
h2 = Math.imul(h4 ^ (h2 >>> 22), 2869860233);
h3 = Math.imul(h1 ^ (h3 >>> 17), 951274213);
h4 = Math.imul(h2 ^ (h4 >>> 19), 2716044179);
h1 ^= (h2 ^ h3 ^ h4), h2 ^= h1, h3 ^= h1, h4 ^= h1;
return [h1>>>0, h2>>>0, h3>>>0, h4>>>0];
}
int(): number; // whole int32 range
int(to: number): number; // [0, x)
int(from: number, to: number): number; // [x, y)
int(x?: number, y?: number): number {
return x === undefined ? this.#next() : Math.floor(this.#float(x, y));
if (x === undefined) return this.#next();
return Math.floor(y === undefined ? this.float(x) : this.float(x, y));
}
float(): number; // [0, 1)
float(x: number): number; // [0, x)
float(x: number, y: number): number; // [x, y)
float(x?: number, y?: number): number { return this.#float(x, y); }
float(x?: number, y?: number): number {
const in01 = this.#next() / UINT_MAX;
if (x === undefined) return in01;
const [lo, hi] = y === undefined? [0, x] : [x, y];
return lo + in01 * (hi - lo);
}
choice<A>(array: A[]): A {
return array[this.int(0, array.length)]!;
return array[this.int(array.length)]!;
}
boolean(): boolean { return this.float() > 0.5; }
alphaNum(len?: number): string {
let res = "";
// [todo] is there a better way to make a string in js
if (len === undefined || len <= 0) len = 16; // idk
for (let i = 0; i < len; ++i)
res += this.choice(Array.from("abcdefghijklmnopqrstuvwxyz0123456789"));
return res;
}
#next(): number {
this.#a |= 0; this.#b |= 0; this.#c |= 0; this.#d |= 0;
let t = (this.#a + this.#b | 0) + this.#d | 0;
this.#d = this.#d + 1 | 0;
this.#a = this.#b ^ this.#b >>> 9;
this.#b = this.#c + (this.#c << 3) | 0;
this.#c = (this.#c << 21 | this.#c >>> 11);
this.#c = this.#c + t | 0;
return (t >>> 0);
}
}

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<svg xmlns="http://www.w3.org/2000/svg"
width="65" height="55" viewBox="-2.5 -2.5 65 55"
width="40" height="34" viewBox="-2.5 -2.5 65 55"
stroke="#123" stroke-width="2">
<linearGradient id="g-bg" x2="0" y2="100%">

Before

Width:  |  Height:  |  Size: 876 B

After

Width:  |  Height:  |  Size: 876 B

BIN
rainbow-quox/style/3px-tile.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
rainbow-quox/style/bright-squares.png (Stored with Git LFS)

Binary file not shown.

BIN
rainbow-quox/style/groovepaper.dark.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
rainbow-quox/style/groovepaper.png (Stored with Git LFS) Normal file

Binary file not shown.

View file

@ -1,3 +1,4 @@
@use 'sass:math';
@import url(../../fonts/muller/muller.css);
@keyframes swap1 {
@ -19,26 +20,59 @@
}
@mixin transy {
transition: transform 0.25s cubic-bezier(.47,.74,.61,1.2);
// https://oakreef.ie/transy :)
$transy-default: 250ms;
@mixin transy($prop: transform, $duration: $transy-default) {
transition: $prop $duration cubic-bezier(.47,.74,.61,1.2);
}
@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.15 var(--hue) / 0.45));
filter: drop-shadow(6px 6px 0 oklch(0.1 0.125 var(--hue) / 0.45));
}
}
@mixin box {
$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));
@mixin box($font-scale: 1, $button: false) {
@include shadow;
font: 700 20pt var(--font); // respecify font family for <button>
color: oklch(0.95 0.075 var(--c-hue));
background: oklch(0.5 0.2 var(--hue));
border: 3px solid oklch(0.2 0.05 var(--hue));
// respecify font family for <button>
font: 700 (20pt * $font-scale) var(--font);
padding: 0.2rem 0.5rem;
@if $button {
background: $box-texture, $button-bg;
color: $button-fg;
} @else {
background: $box-texture, $box-bg;
color: $box-fg;
}
background-blend-mode: hard-light;
border: 3px solid oklch(0.2 0.05 var(--hue));
}
@mixin image-button {
@include box($button: true);
padding: 5px;
border: 2px solid $button-fg;
> * { display: block; }
}
@mixin button-arrow {
&::before {
content: '';
display: inline-block;
height: 1.25ex; aspect-ratio: 2/3;
background:
conic-gradient(from -124deg at 100% 50%,
currentcolor, currentcolor 68deg, transparent 68deg);
}
}
@layer base, layering, come-in;
@ -52,19 +86,20 @@
--font: Muller, sans-serif;
font-family: var(--font);
@function bg($lcs) {
@function bg($lcs, $img) {
$lg: to bottom in oklch;
@each $l, $c in $lcs { $lg: $lg, oklch($l $c var(--hue)); }
@return url(bright-squares.png), linear-gradient($lg);
@return url(#{$img}), linear-gradient($lg);
}
background: bg([0.9 0.08, 0.7 0.1]);
background: bg([0.9 0.08, 0.7 0.1], 'groovepaper.png');
background-blend-mode: screen;
color-scheme: light dark;
@media (prefers-color-scheme: dark) {
background: bg([0.3 0.08, 0.2 0.09]);
background: bg([0.3 0.08, 0.2 0.09], 'groovepaper.dark.png');
background-blend-mode: multiply;
}
background-blend-mode: screen;
}
body {
@ -78,6 +113,8 @@
justify-content: space-between;
}
button { cursor: pointer; }
#pic-holder, #main, #aux {
width: 1000px;
// margin: auto;
@ -101,34 +138,133 @@
#hideui { position: absolute; bottom: 0; left: 5em; }
#buttons button {
@include box;
width: calc(6em + 100px);
@include box($button: true);
@include button-arrow;
&::before { margin-right: 0.5ex; }
width: calc(6em + 1.5ex + 100px);
text-align: left;
$rotate: rotate(-60deg);
transform: $rotate;
transform-origin: center right;
:root:not([data-running]) &:active,
[data-running=swap] &#swap,
[data-running=reroll] &#reroll {
transform: $rotate translateX(-1.5em);
@media not (prefers-reduced-motion: reduce) {
:root:not([data-running]) &:active,
[data-running=swap] &#swap,
[data-running=reroll] &#reroll {
transform: $rotate translateX(-1.5em);
}
}
}
#back, #showui { position: fixed; top: 22px; left: 22px; }
#showui { @include image-button; }
#back, #showui {
@include shadow;
position: fixed; top: 22px; left: 22px;
}
#showui { transform: translateX(-200%); }
#palette-holder {
margin: -60px auto 0;
padding-top: 25px;
overflow-y: hidden;
}
#palette, #showui { @include shadow; }
#palette { @include shadow; }
#state-message {
@include box;
position: fixed; top: 1em; right: -1em;
padding-right: 2em;
@mixin history-flex {
display: flex;
flex-flow: column;
align-items: end;
gap: 3px;
}
@mixin history-box($font-scale: 1, $button: false) {
@include box($font-scale, $button);
border-right: none;
width: 100%;
padding-right: 1em;
}
#history {
$width: 24em;
width: $width;
position: fixed; top: 3rem; right: -1rem;
@include history-flex;
}
#current {
@include history-box;
position: absolute;
top: 0; left: 0;
display: grid;
grid-template: "a c" "b c" / auto min-content;
gap: 0 15px;
align-items: start;
}
#current-hello {
grid-area: a;
font-size: 75%;
}
#current-name {
grid-area: b;
max-width: 16em;
word-break: break-word;
&::spelling-error { text-decoration: none; }
}
#history-button {
@include image-button;
grid-area: c;
}
:is(#current-name, .history-item) img { max-width: 50%; }
#close-history {
@include box(0.8, $button: true);
margin: 0; height: 1lh; width: 1lh;
position: absolute; top: 0; left: calc(-1lh);
img {
position: absolute;
inset: 0; width: 100%; height: 100%;
object-position: center;
}
}
#history-items {
$bottom-fade-size: 3lh;
$top-fade-size: math.div($bottom-fade-size, 2);
@include history-flex;
position: absolute;
top: -$top-fade-size; left: 0;
width: 100%;
pointer-events: none;
height: 65vh;
overflow: hidden auto;
padding: $top-fade-size 0 $bottom-fade-size;
mask: linear-gradient(to bottom,
transparent, black $top-fade-size,
black calc(100% - $bottom-fade-size), transparent);
}
.history-item {
@include history-box(0.8, $button: true);
@include button-arrow;
display: flex;
gap: 5px;
align-items: center;
}
.history-colors {
height: 0.8em;
flex: none;
}
.history-name {
width: 100%;
padding-right: 2rem;
text-align: left;
white-space: nowrap;
overflow: hidden;
}
.history-item, #close-history { transform: translateX(25em); }
#history-close-target {
position: absolute;
inset: 0 0 0 0;
}
}
@ -136,22 +272,54 @@
@layer layering {
#main { z-index: 0; }
#aux { z-index: 1; }
#buttons, #state-message, #palette-holder, #back, #showui { z-index: 2; }
#buttons, #palette-holder, #back, #showui { z-index: 2; }
#history-close-target { z-index: 3; }
#history { z-index: 4; }
}
@layer come-in {
#buttons button, #state-message, #palette, #back, #showui {
@include transy;
@media not (prefers-reduced-motion: reduce) {
#buttons button, #current, #palette, #back, #showui, .history-item {
@include transy;
}
#current { @include transy(all); }
#close-history { @include transy; }
}
[data-state=fullquox], [data-state=loading] {
#buttons button { transform: none; }
#state-message { transform: translateX(100%); }
#current { transform: translateX(100%); }
#palette { transform: translateY(125%); }
#back { transform: translateX(-200%); }
}
[data-state=fullquox] { #showui { transform: none; } }
[data-state=fullquox] {
#showui { transform: none; }
}
[data-state=history] {
#history-items { pointer-events: auto; }
#close-history { pointer-events: auto; transform: none; }
#current { opacity: 0; }
@for $i from 0 through 6 {
.history-item:nth-child(#{$i}) {
transform: translateX(3% * $i); }
}
.history-item:nth-child(n+7) { transform: translateX(3% * 7); }
}
@media not (prefers-reduced-motion: reduce) {
@for $i from 2 through 6 {
.history-item:nth-child(#{$i}) {
transition-delay: ($i - 1) * 40ms;
}
}
.history-item:nth-child(n+7) {
transition-delay: 240ms;
}
}
}