microquack / apps /web /src /customize.ts
Onur Solmaz
GPT-5.6 Sol
feat(web): refine customization and scene behavior
50d7c01
Raw History Blame Contribute Delete
3.67 kB
/**
* Duck customization profiles: seven colors and a name, packed into a
* compact base64url string
* ([version=1][slot count=7][7 x rgb][name utf-8, <=24 bytes]).
* The encoded form lives in localStorage and, for signed-in users, in
* their private Hugging Face storage β€” never in URLs.
*/
export type DuckProfile = {
name: string;
/** sRGB 0..255 per channel; one entry per SLOT_LABELS item. */
colors: [number, number, number][];
};
const VERSION = 1;
const SLOT_COUNT = 7;
const HEADER_BYTES = 2;
const NAME_MAX_BYTES = 24;
const STORAGE_KEY = "mq-duck";
export const SLOT_LABELS = ["Head", "Shell", "Face", "Beak", "Eye", "Feet", "Soles"];
function toBase64Url(bytes: Uint8Array): string {
let bin = "";
for (const b of bytes) bin += String.fromCharCode(b);
return btoa(bin).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
}
function fromBase64Url(text: string): Uint8Array | null {
try {
const bin = atob(text.replaceAll("-", "+").replaceAll("_", "/"));
return Uint8Array.from(bin, (c) => c.charCodeAt(0));
} catch {
return null;
}
}
export function encodeProfile(profile: DuckProfile): string {
const name = new TextEncoder().encode(profile.name).slice(0, NAME_MAX_BYTES);
const bytes = new Uint8Array(HEADER_BYTES + SLOT_COUNT * 3 + name.length);
bytes[0] = VERSION;
bytes[1] = SLOT_COUNT;
for (let i = 0; i < SLOT_COUNT; i++) {
const c = profile.colors[i] ?? [255, 255, 255];
bytes[HEADER_BYTES + i * 3] = c[0];
bytes[HEADER_BYTES + i * 3 + 1] = c[1];
bytes[HEADER_BYTES + i * 3 + 2] = c[2];
}
bytes.set(name, HEADER_BYTES + SLOT_COUNT * 3);
return toBase64Url(bytes);
}
function isCurrentProfile(bytes: Uint8Array | null): bytes is Uint8Array {
return (
bytes !== null &&
bytes.length >= HEADER_BYTES + SLOT_COUNT * 3 &&
bytes[0] === VERSION &&
bytes[1] === SLOT_COUNT
);
}
export function decodeProfile(encoded: string): DuckProfile | null {
const bytes = fromBase64Url(encoded);
if (!isCurrentProfile(bytes)) return null;
const colors: [number, number, number][] = [];
for (let i = 0; i < SLOT_COUNT; i++) {
const offset = HEADER_BYTES + i * 3;
colors.push([bytes[offset] ?? 0, bytes[offset + 1] ?? 0, bytes[offset + 2] ?? 0]);
}
const name = new TextDecoder().decode(bytes.slice(HEADER_BYTES + SLOT_COUNT * 3));
return { name, colors };
}
export function profileFromStorage(): DuckProfile | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? decodeProfile(raw) : null;
} catch {
return null;
}
}
/** Persist locally; the duck lives in this browser (or the HF account). */
export function saveProfile(profile: DuckProfile): void {
try {
localStorage.setItem(STORAGE_KEY, encodeProfile(profile));
} catch {
// private mode: the duck lasts for this visit only
}
}
// ── color space: pickers speak sRGB hex, materials speak linear ─────────
export function srgbToLinear(c: [number, number, number]): [number, number, number] {
return [(c[0] / 255) ** 2.2, (c[1] / 255) ** 2.2, (c[2] / 255) ** 2.2];
}
export function linearToSrgb(c: [number, number, number]): [number, number, number] {
const conv = (v: number): number => Math.round(255 * Math.min(1, Math.max(0, v)) ** (1 / 2.2));
return [conv(c[0]), conv(c[1]), conv(c[2])];
}
export function srgbToHex(c: [number, number, number]): string {
return `#${c.map((v) => v.toString(16).padStart(2, "0")).join("")}`;
}
export function hexToSrgb(hex: string): [number, number, number] {
const n = parseInt(hex.replace("#", ""), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}