* Align Oo avatar eye spacing with evoo-logo overlap ratio Derive small-eye placement from evoo-logo.svg center spacing (~0.94 of r_big + r_small) for the shared rig, EvooMascot brand lock, and face crops. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * Format brand-lock spacing test for Biome Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * Align mobile Expo packages for install --check in CI Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * ci: retrigger after flaky chart-links E2E Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
309 lines
11 KiB
TypeScript
309 lines
11 KiB
TypeScript
/**
|
||
* The Oo avatar value: one string that describes how a bot looks.
|
||
*
|
||
* It travels everywhere a bot's `color` already travels (bot rows, group members,
|
||
* mentions, inbox), so every surface can draw the full look without new fields.
|
||
*
|
||
* Formats, newest first:
|
||
* - `oo1:c=A97EFE;sp=cat;ew=round` — the full look; only non-default fields are written.
|
||
* - `#8B5CF6::shape_3` — legacy colour + classic shape index.
|
||
* - `#8B5CF6` or a palette id such as `violet` — legacy colour; shape follows the identity.
|
||
* - `data:image/...` — an uploaded picture, not an Oo look.
|
||
*/
|
||
|
||
export const OO_SPECIES = [
|
||
"classic",
|
||
"cat",
|
||
"dog",
|
||
"bunny",
|
||
"bear",
|
||
"robot",
|
||
"ghost",
|
||
"frog",
|
||
"penguin",
|
||
] as const;
|
||
export const OO_ARMS = ["glass", "stub", "paw", "robot", "none"] as const;
|
||
export const OO_LASHES = ["none", "soft", "full", "flick"] as const;
|
||
export const OO_LINERS = ["none", "wing"] as const;
|
||
export const OO_EYESHADOWS = ["none", "rose", "lilac", "gold", "mint"] as const;
|
||
export const OO_IRISES = ["classic", "brown", "blue", "green", "violet"] as const;
|
||
export const OO_SPARKLES = ["dot", "star", "heart"] as const;
|
||
export const OO_BROWS = ["auto", "fine", "bushy"] as const;
|
||
export const OO_CHEEKS = ["none", "pink", "peach", "freckles"] as const;
|
||
export const OO_EYEWEAR = ["none", "round", "nerd", "sun", "heart", "monocle", "reading"] as const;
|
||
export const OO_HEADWEAR = [
|
||
"none",
|
||
"chef",
|
||
"crown",
|
||
"headphones",
|
||
"bow",
|
||
"beanie",
|
||
"flower",
|
||
"party",
|
||
] as const;
|
||
export const OO_NECKWEAR = ["none", "bowtie", "scarf", "bell"] as const;
|
||
|
||
/** Classic silhouettes, in the order of the legacy `::shape_N` index. */
|
||
export const OO_CLASSIC_SHAPE_COUNT = 8;
|
||
export const OO_ENERGY_RANGE = { min: 0.5, max: 1.6 } as const;
|
||
|
||
export type OoSpecies = (typeof OO_SPECIES)[number];
|
||
export type OoArms = (typeof OO_ARMS)[number];
|
||
export type OoLashes = (typeof OO_LASHES)[number];
|
||
export type OoLiner = (typeof OO_LINERS)[number];
|
||
export type OoEyeshadow = (typeof OO_EYESHADOWS)[number];
|
||
export type OoIris = (typeof OO_IRISES)[number];
|
||
export type OoSparkle = (typeof OO_SPARKLES)[number];
|
||
export type OoBrows = (typeof OO_BROWS)[number];
|
||
export type OoCheeks = (typeof OO_CHEEKS)[number];
|
||
export type OoEyewear = (typeof OO_EYEWEAR)[number];
|
||
export type OoHeadwear = (typeof OO_HEADWEAR)[number];
|
||
export type OoNeckwear = (typeof OO_NECKWEAR)[number];
|
||
|
||
export interface OoAvatar {
|
||
species: OoSpecies;
|
||
/** `#RRGGBB` for `oo1:` values; legacy values keep whatever colour they stored. */
|
||
color: string;
|
||
/** Classic silhouette index; `null` lets the identity pick one. */
|
||
shape: number | null;
|
||
arms: OoArms;
|
||
lashes: OoLashes;
|
||
liner: OoLiner;
|
||
eyeshadow: OoEyeshadow;
|
||
iris: OoIris;
|
||
sparkle: OoSparkle;
|
||
brows: OoBrows;
|
||
cheeks: OoCheeks;
|
||
eyewear: OoEyewear;
|
||
head: OoHeadwear;
|
||
neck: OoNeckwear;
|
||
/** Tempo and bounce of every animation. */
|
||
energy: number;
|
||
/** 0–1: how often playful extras (ear twitch, tongue, tail wag) happen. */
|
||
playful: number;
|
||
}
|
||
|
||
export const DEFAULT_OO_AVATAR: OoAvatar = {
|
||
species: "classic",
|
||
color: "#8B5CF6",
|
||
shape: null,
|
||
arms: "glass",
|
||
lashes: "none",
|
||
liner: "none",
|
||
eyeshadow: "none",
|
||
iris: "classic",
|
||
sparkle: "dot",
|
||
brows: "auto",
|
||
cheeks: "none",
|
||
eyewear: "none",
|
||
head: "none",
|
||
neck: "none",
|
||
energy: 1,
|
||
playful: 0.5,
|
||
};
|
||
|
||
export const OO_AVATAR_PREFIX = "oo1:";
|
||
/** Upper bound for an encoded `oo1:` value; every field written stays far below it. */
|
||
export const OO_AVATAR_MAX_LENGTH = 256;
|
||
|
||
type EnumField = Exclude<keyof OoAvatar, "color" | "shape" | "energy" | "playful">;
|
||
|
||
const ENUM_FIELDS: readonly { field: EnumField; key: string; values: readonly string[] }[] = [
|
||
{ field: "species", key: "sp", values: OO_SPECIES },
|
||
{ field: "arms", key: "ar", values: OO_ARMS },
|
||
{ field: "lashes", key: "la", values: OO_LASHES },
|
||
{ field: "liner", key: "li", values: OO_LINERS },
|
||
{ field: "eyeshadow", key: "es", values: OO_EYESHADOWS },
|
||
{ field: "iris", key: "ir", values: OO_IRISES },
|
||
{ field: "sparkle", key: "sk", values: OO_SPARKLES },
|
||
{ field: "brows", key: "br", values: OO_BROWS },
|
||
{ field: "cheeks", key: "ck", values: OO_CHEEKS },
|
||
{ field: "eyewear", key: "ew", values: OO_EYEWEAR },
|
||
{ field: "head", key: "hd", values: OO_HEADWEAR },
|
||
{ field: "neck", key: "nk", values: OO_NECKWEAR },
|
||
];
|
||
|
||
const HEX = /^#?([0-9a-f]{6})$/i;
|
||
const SHORT_HEX = /^#([0-9a-f]{3})$/i;
|
||
|
||
function normalizeHex(value: string): string | null {
|
||
const short = SHORT_HEX.exec(value);
|
||
if (short?.[1]) {
|
||
const [r, g, b] = short[1];
|
||
return `#${r}${r}${g}${g}${b}${b}`.toUpperCase();
|
||
}
|
||
const full = HEX.exec(value);
|
||
return full?.[1] ? `#${full[1].toUpperCase()}` : null;
|
||
}
|
||
|
||
function clamp(value: number, min: number, max: number): number {
|
||
return Math.min(max, Math.max(min, value));
|
||
}
|
||
|
||
function roundTo(value: number, step: number): number {
|
||
return Math.round(value / step) * step;
|
||
}
|
||
|
||
/** Writes a look as an `oo1:` value. Colours that are not hex fall back to the default. */
|
||
export function encodeOoAvatar(avatar: OoAvatar): string {
|
||
const color = normalizeHex(avatar.color) ?? DEFAULT_OO_AVATAR.color;
|
||
const parts = [`c=${color.slice(1)}`];
|
||
for (const { field, key } of ENUM_FIELDS) {
|
||
if (avatar[field] !== DEFAULT_OO_AVATAR[field]) parts.push(`${key}=${avatar[field]}`);
|
||
}
|
||
if (avatar.shape !== null) {
|
||
parts.push(`sh=${clamp(Math.trunc(avatar.shape), 0, OO_CLASSIC_SHAPE_COUNT - 1)}`);
|
||
}
|
||
const energy = roundTo(clamp(avatar.energy, OO_ENERGY_RANGE.min, OO_ENERGY_RANGE.max), 0.05);
|
||
if (energy !== DEFAULT_OO_AVATAR.energy) parts.push(`en=${energy.toFixed(2)}`);
|
||
const playful = roundTo(clamp(avatar.playful, 0, 1), 0.05);
|
||
if (playful !== DEFAULT_OO_AVATAR.playful) parts.push(`pl=${playful.toFixed(2)}`);
|
||
return OO_AVATAR_PREFIX + parts.join(";");
|
||
}
|
||
|
||
/** How an `oo1:` value is written, for models that compose a look as text. */
|
||
export function ooLookFormat(): string {
|
||
const fields = ENUM_FIELDS.map(
|
||
({ field, key, values }) => `${key} ${field} (${values.join("|")})`,
|
||
);
|
||
return `oo1: followed by key=value pairs joined by ";", e.g. oo1:c=F4A7B9;sp=cat;ew=round;hd=bow. c is the body colour as hex (required). Other keys: ${fields.join(", ")}. Omitted keys keep their defaults.`;
|
||
}
|
||
|
||
function decodeV1(body: string): OoAvatar | null {
|
||
const avatar: OoAvatar = { ...DEFAULT_OO_AVATAR };
|
||
let hasColor = false;
|
||
for (const pair of body.split(";")) {
|
||
if (!pair) continue;
|
||
const eq = pair.indexOf("=");
|
||
if (eq <= 0) return null;
|
||
const key = pair.slice(0, eq);
|
||
const value = pair.slice(eq + 1);
|
||
if (key === "c") {
|
||
const color = normalizeHex(value);
|
||
if (!color) return null;
|
||
avatar.color = color;
|
||
hasColor = true;
|
||
continue;
|
||
}
|
||
if (key === "sh") {
|
||
if (!/^\d$/.test(value) || Number(value) >= OO_CLASSIC_SHAPE_COUNT) return null;
|
||
avatar.shape = Number(value);
|
||
continue;
|
||
}
|
||
if (key === "en" || key === "pl") {
|
||
if (!/^\d(?:\.\d{1,2})?$/.test(value)) return null;
|
||
const number = Number(value);
|
||
if (key === "en") {
|
||
if (number < OO_ENERGY_RANGE.min || number > OO_ENERGY_RANGE.max) return null;
|
||
avatar.energy = number;
|
||
} else {
|
||
if (number > 1) return null;
|
||
avatar.playful = number;
|
||
}
|
||
continue;
|
||
}
|
||
const spec = ENUM_FIELDS.find((entry) => entry.key === key);
|
||
// Unknown keys come from newer clients; keep the rest of the look.
|
||
if (!spec) continue;
|
||
if (!spec.values.includes(value)) return null;
|
||
(avatar as unknown as Record<string, string>)[spec.field] = value;
|
||
}
|
||
return hasColor ? avatar : null;
|
||
}
|
||
|
||
/**
|
||
* Reads any stored avatar value as a look. Returns `null` for uploaded pictures and
|
||
* for malformed `oo1:` values.
|
||
*/
|
||
export function decodeOoAvatar(value: string): OoAvatar | null {
|
||
if (value.startsWith("data:")) return null;
|
||
if (value.startsWith(OO_AVATAR_PREFIX)) {
|
||
if (value.length > OO_AVATAR_MAX_LENGTH) return null;
|
||
return decodeV1(value.slice(OO_AVATAR_PREFIX.length));
|
||
}
|
||
const [color = "", shapePart] = value.split("::shape_");
|
||
let shape: number | null = null;
|
||
if (shapePart !== undefined) {
|
||
shape = /^\d+$/.test(shapePart) ? Number(shapePart) % OO_CLASSIC_SHAPE_COUNT : 0;
|
||
if (!Number.isSafeInteger(shape)) shape = 0;
|
||
}
|
||
return { ...DEFAULT_OO_AVATAR, color: normalizeHex(color) ?? color, shape };
|
||
}
|
||
|
||
/** A look written by a model, as the canonical `oo1:` value, or `null` when it is not one. */
|
||
export function normalizeOoLook(value: unknown): string | null {
|
||
if (typeof value !== "string") return null;
|
||
const raw = value.trim();
|
||
const look = raw.startsWith(OO_AVATAR_PREFIX) ? decodeOoAvatar(raw) : null;
|
||
return look ? encodeOoAvatar(look) : null;
|
||
}
|
||
|
||
/** True when a stored avatar value is acceptable to persist. */
|
||
export function isValidAvatarValue(value: string): boolean {
|
||
if (!value.startsWith(OO_AVATAR_PREFIX)) return true;
|
||
return decodeOoAvatar(value) !== null;
|
||
}
|
||
|
||
/**
|
||
* The colour part of an avatar value, for places that need a plain CSS colour
|
||
* (cursors, rings, chips). Empty for pictures and unreadable values.
|
||
*/
|
||
export function ooAvatarColor(value: string): string {
|
||
return decodeOoAvatar(value)?.color ?? "";
|
||
}
|
||
|
||
/**
|
||
* Oo eye anchors in the 200×200 avatar rig. Every species draws the eyes here, so the
|
||
* pair never moves when the body changes. The small eye is the big one divided by φ.
|
||
*/
|
||
export const OO_GOLDEN_RATIO = (1 + Math.sqrt(5)) / 2;
|
||
|
||
/** Sclera center distance ÷ sum of radii in `apps/web/public/evoo-logo.svg` (slight overlap). */
|
||
export const OO_EYE_CENTER_SPACING_RATIO = 55 / (34 + 24.5);
|
||
|
||
/** Small-eye vertical drop as a fraction of the big sclera radius (same logo). */
|
||
export const OO_EYE_SMALL_VERTICAL_OFFSET_RATIO = (60 - 52) / 34;
|
||
|
||
/** Place the small eye from the big anchor using the logo spacing ratio. */
|
||
export function ooEyeCentersFromLogoRatio(
|
||
big: Readonly<{ cx: number; cy: number; r: number }>,
|
||
smallR: number,
|
||
): { cx: number; cy: number } {
|
||
const centerDistance = OO_EYE_CENTER_SPACING_RATIO * (big.r + smallR);
|
||
return {
|
||
cx: big.cx + centerDistance,
|
||
cy: big.cy + OO_EYE_SMALL_VERTICAL_OFFSET_RATIO * big.r,
|
||
};
|
||
}
|
||
|
||
const OO_RIG_EYE_BIG = { cx: 80, cy: 108, r: 21 } as const;
|
||
const OO_RIG_EYE_SMALL_R = OO_RIG_EYE_BIG.r / OO_GOLDEN_RATIO;
|
||
const OO_RIG_EYE_SMALL_CENTER = ooEyeCentersFromLogoRatio(OO_RIG_EYE_BIG, OO_RIG_EYE_SMALL_R);
|
||
|
||
export const OO_RIG_EYES = {
|
||
big: OO_RIG_EYE_BIG,
|
||
small: { ...OO_RIG_EYE_SMALL_CENTER, r: OO_RIG_EYE_SMALL_R },
|
||
} as const;
|
||
|
||
const OO_RIG_FACE_PAD = { side: 12, above: 25, below: 30 } as const;
|
||
|
||
/** Face crop for eyewear / eye-style pickers; keeps both sclerae in frame at every size. */
|
||
export const OO_RIG_FACE_VIEWBOX = (() => {
|
||
const { big, small } = OO_RIG_EYES;
|
||
const x = Math.floor(Math.min(big.cx - big.r, small.cx - small.r) - OO_RIG_FACE_PAD.side);
|
||
const y = Math.floor(big.cy - big.r - OO_RIG_FACE_PAD.above);
|
||
const right = Math.ceil(Math.max(big.cx + big.r, small.cx + small.r) + OO_RIG_FACE_PAD.side);
|
||
const bottom = Math.ceil(Math.max(big.cy + big.r, small.cy + small.r) + OO_RIG_FACE_PAD.below);
|
||
return `${x} ${y} ${right - x} ${bottom - y}`;
|
||
})();
|
||
|
||
/** Minimum big÷small ratio for eyes, lenses and closed-eye arcs (Oo trademark). */
|
||
export const OO_EYE_ASYMMETRY_MIN_RATIO = 1.45;
|
||
|
||
/** Lenses scale with the eye they sit on, so glasses keep the Oo asymmetry. */
|
||
export const OO_LENS_SCALE = 1.24;
|
||
|
||
export function ooLensRadius(eye: keyof typeof OO_RIG_EYES): number {
|
||
return OO_RIG_EYES[eye].r * OO_LENS_SCALE;
|
||
}
|