feat(avatar): add the oo1 avatar value for customizable Oo looks (#135)

A bot's look is stored in its `color` string, which today only holds a
colour, an optional classic shape index or an uploaded picture. The
customizable avatars (species, eye styling, accessories, temperament)
need a richer value that still travels everywhere `color` already goes:
bot rows, group members, mentions and the inbox.

- `@rakazo/core` gains `oo-avatar`: an `oo1:` key=value codec that writes
  only non-default fields, reads every legacy value unchanged, rejects
  malformed values, and exposes `ooAvatarColor` for plain CSS consumers.
- The rig's eye anchors keep the Oo trademark (big eye = small eye × φ),
  and lenses scale with their eye so glasses stay asymmetric; the shared
  asymmetry ratio now comes from core.
- The API rejects malformed `oo1:` values on bot create and update.
- `parseBotAvatar` reads through the codec, so an `oo1:` value already
  renders with its colour and shape before the new renderer lands.
- The agent cursor and the mobile avatar used the raw value as a CSS
  colour and broke on `#hex::shape_N`; they now use the decoded colour.


Claude-Session: https://claude.ai/code/session_01EKuME5CgL1ddGGN7LGvtBj

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
cynarAI 2026-10-04 18:56:34 +02:00 committed by GitHub
parent ad054a7586
commit d6dd2bef5e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 410 additions and 20 deletions

View File

@ -112,6 +112,7 @@ import {
expandSkillReferencesInPrompt,
hasMixedOneShotSchedule,
isOneShotRoutineCrons,
isValidAvatarValue,
nextCronDateAcrossStrict,
} from "@rakazo/core";
import type { PrismaClient, ThreadEvents } from "@rakazo/db";
@ -1199,6 +1200,7 @@ export function createRouter(deps: RouterDeps) {
}),
create: authed.bots.create.handler(async ({ context, input }) => {
assertBuiltinToolNames(input.allowedBuiltinTools);
assertAvatarValue(input.color);
try {
return await repos.createBot(context.actor, input);
} catch (error) {
@ -1353,6 +1355,7 @@ export function createRouter(deps: RouterDeps) {
update: authed.bots.update.handler(async ({ context, input }) => {
const existing = await repos.getBot(context.actor, input.botId);
assertBuiltinToolNames(input.allowedBuiltinTools);
assertAvatarValue(input.color);
if (input.sectionId) {
const section = await deps.prisma.botSection.findFirst({
where: {
@ -5830,6 +5833,12 @@ function assertBuiltinToolNames(names: readonly string[] | undefined) {
}
}
function assertAvatarValue(value: string | undefined) {
if (value !== undefined && !isValidAvatarValue(value)) {
throw new ORPCError("BAD_REQUEST", { message: "Invalid avatar" });
}
}
function duplicateBotName(name: string) {
return `${name.slice(0, 75)} copy`;
}

View File

@ -1,5 +1,11 @@
import type { AvatarStyle } from "@rakazo/contracts";
import { ACTIVE_RUN_STATUSES, avatarIdentitySeed, organicAvatarPath } from "@rakazo/core";
import {
ACTIVE_RUN_STATUSES,
avatarIdentitySeed,
DEFAULT_OO_AVATAR,
ooAvatarColor,
organicAvatarPath,
} from "@rakazo/core";
import { memo, useEffect } from "react";
import { View } from "react-native";
import Animated, {
@ -38,6 +44,7 @@ export const BotAvatar = memo(function BotAvatar({
const { t } = useI18n();
const isWorking = ACTIVE_RUN_STATUSES.some((activeStatus) => activeStatus === status);
const { avatarStyle } = useAvatarStyle();
const fill = ooAvatarColor(color) || DEFAULT_OO_AVATAR.color;
const visorW = Math.round(size * 0.68);
const visorH = Math.round(size * 0.44);
const eyeW = Math.max(3, Math.round(size * 0.11));
@ -46,14 +53,14 @@ export const BotAvatar = memo(function BotAvatar({
return (
<View style={{ width: size, height: size }}>
{(variant ?? avatarStyle) === "organic" ? (
<OrganicAvatar color={color} identity={identity} size={size} isWorking={isWorking} />
<OrganicAvatar color={fill} identity={identity} size={size} isWorking={isWorking} />
) : (
<View
style={{
width: size,
height: size,
borderRadius: size / 2,
backgroundColor: color,
backgroundColor: fill,
alignItems: "center",
justifyContent: "center",
}}

View File

@ -1,4 +1,4 @@
import { screenPointInViewer } from "@rakazo/core";
import { ooAvatarColor, screenPointInViewer } from "@rakazo/core";
import { Keyboard } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import {
@ -36,6 +36,7 @@ export function AgentCursor({
const cursorRef = useRef<HTMLDivElement>(null);
const rippleRef = useRef<HTMLSpanElement>(null);
const [typing, setTyping] = useState(false);
const tint = ooAvatarColor(color) || undefined;
useEffect(() => {
const layer = layerRef.current;
@ -135,7 +136,7 @@ export function AgentCursor({
ref={cursorRef}
data-testid="agent-cursor"
className="absolute top-0 left-0 opacity-0 transition-opacity duration-300 will-change-transform"
style={{ color }}
style={{ color: tint }}
>
<span
ref={rippleRef}
@ -155,7 +156,7 @@ export function AgentCursor({
/>
</svg>
<span className="absolute top-5 left-3.5 flex max-w-40 items-center gap-1 rounded-full bg-popover px-2 py-0.5 text-ui-label font-medium whitespace-nowrap text-popover-foreground shadow-sm ring-1 ring-border">
<span className="size-1.5 shrink-0 rounded-full bg-current" style={{ color }} />
<span className="size-1.5 shrink-0 rounded-full bg-current" style={{ color: tint }} />
<span className="truncate">{name}</span>
{typing ? <Keyboard strokeWidth={1.8} className="size-3 shrink-0" /> : null}
</span>

View File

@ -32,6 +32,7 @@ export * from "./messaging-prompts.js";
export * from "./model-oauth.js";
export * from "./model-probe.js";
export * from "./model-providers.js";
export * from "./oo-avatar.js";
export * from "./quiet-hours.js";
export * from "./response-bytes.js";
export * from "./run-state.js";

View File

@ -0,0 +1,118 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_OO_AVATAR,
decodeOoAvatar,
encodeOoAvatar,
isValidAvatarValue,
OO_AVATAR_MAX_LENGTH,
OO_EYE_ASYMMETRY_MIN_RATIO,
OO_RIG_EYES,
type OoAvatar,
ooAvatarColor,
ooLensRadius,
} from "./oo-avatar.js";
const styled: OoAvatar = {
species: "cat",
color: "#FF781C",
shape: null,
arms: "paw",
lashes: "full",
liner: "wing",
eyeshadow: "rose",
iris: "green",
sparkle: "heart",
brows: "fine",
cheeks: "freckles",
eyewear: "round",
head: "chef",
neck: "bell",
energy: 1.3,
playful: 0.8,
};
describe("oo avatar value", () => {
it("round-trips a fully styled look", () => {
const value = encodeOoAvatar(styled);
expect(value).toBe(
"oo1:c=FF781C;sp=cat;ar=paw;la=full;li=wing;es=rose;ir=green;sk=heart;br=fine;ck=freckles;ew=round;hd=chef;nk=bell;en=1.30;pl=0.80",
);
expect(decodeOoAvatar(value)).toEqual(styled);
});
it("writes only the colour for the default look", () => {
expect(encodeOoAvatar({ ...DEFAULT_OO_AVATAR, color: "#a97efe" })).toBe("oo1:c=A97EFE");
expect(encodeOoAvatar({ ...DEFAULT_OO_AVATAR, shape: 3 })).toBe("oo1:c=8B5CF6;sh=3");
});
it("clamps temperament and falls back to the default colour when encoding", () => {
const value = encodeOoAvatar({ ...DEFAULT_OO_AVATAR, color: "violet", energy: 9, playful: -1 });
expect(value).toBe("oo1:c=8B5CF6;en=1.60;pl=0.00");
expect(decodeOoAvatar(value)).toMatchObject({ energy: 1.6, playful: 0 });
});
it("reads legacy colour and shape values unchanged", () => {
expect(decodeOoAvatar("#8b5cf6::shape_3")).toEqual({
...DEFAULT_OO_AVATAR,
color: "#8B5CF6",
shape: 3,
});
expect(decodeOoAvatar("#10B981::shape_11")?.shape).toBe(3);
expect(decodeOoAvatar("#10B981::shape_x")?.shape).toBe(0);
expect(decodeOoAvatar("#fff")).toMatchObject({ color: "#FFFFFF", shape: null });
expect(decodeOoAvatar("violet")).toMatchObject({ color: "violet", shape: null });
});
it("treats uploaded pictures as no look", () => {
expect(decodeOoAvatar("data:image/webp;base64,AAAA")).toBeNull();
expect(ooAvatarColor("data:image/webp;base64,AAAA")).toBe("");
});
it("rejects malformed oo1 values", () => {
for (const value of [
"oo1:",
"oo1:sp=cat",
"oo1:c=XYZXYZ",
"oo1:c=FF781C;sp=dragon",
"oo1:c=FF781C;sh=8",
"oo1:c=FF781C;en=3",
"oo1:c=FF781C;pl=1.5",
"oo1:c=FF781C;broken",
`oo1:c=FF781C;${"x=1;".repeat(OO_AVATAR_MAX_LENGTH)}`,
]) {
expect(decodeOoAvatar(value), value).toBeNull();
expect(isValidAvatarValue(value), value).toBe(false);
}
});
it("ignores keys from newer clients", () => {
expect(decodeOoAvatar("oo1:c=FF781C;sp=dog;zz=new")).toMatchObject({
species: "dog",
color: "#FF781C",
});
});
it("accepts every legacy value for persistence", () => {
expect(isValidAvatarValue("#8B5CF6::shape_0")).toBe(true);
expect(isValidAvatarValue("data:image/png;base64,AAAA")).toBe(true);
});
it("exposes a plain colour for CSS consumers", () => {
expect(ooAvatarColor("oo1:c=FF781C;sp=cat")).toBe("#FF781C");
expect(ooAvatarColor("#8B5CF6::shape_2")).toBe("#8B5CF6");
});
});
describe("oo rig brand lock", () => {
it("keeps the big eye φ times the small eye", () => {
const ratio = OO_RIG_EYES.big.r / OO_RIG_EYES.small.r;
expect(ratio).toBeGreaterThanOrEqual(OO_EYE_ASYMMETRY_MIN_RATIO);
expect(ratio).toBeCloseTo(1.618, 3);
});
it("keeps glasses as asymmetric as the eyes", () => {
const ratio = ooLensRadius("big") / ooLensRadius("small");
expect(ratio).toBeGreaterThanOrEqual(OO_EYE_ASYMMETRY_MIN_RATIO);
expect(ooLensRadius("big")).toBeGreaterThan(OO_RIG_EYES.big.r);
});
});

View File

@ -0,0 +1,257 @@
/**
* 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(";");
}
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 };
}
/** 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;
export const OO_RIG_EYES = {
big: { cx: 80, cy: 108, r: 21 },
small: { cx: 126, cy: 111, r: 21 / OO_GOLDEN_RATIO },
} as const;
/** 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;
}

View File

@ -1,4 +1,4 @@
import { ACTIVE_RUN_STATUSES } from "@rakazo/core";
import { ACTIVE_RUN_STATUSES, decodeOoAvatar } from "@rakazo/core";
import { memo, useId, useMemo } from "react";
import { EvooMascot } from "./evoo-brand.js";
import { resolveEvooPose } from "./evoo-character/pose-map.js";
@ -251,18 +251,13 @@ export function parseBotAvatar(
if (rawColor.startsWith("data:image/")) {
return { color: "#F97316", isImage: true, imageUrl: rawColor };
}
if (rawColor.includes("::shape_")) {
const parts = rawColor.split("::shape_");
const rawShapeIdx = parts[1] ?? "0";
const parsedShapeIdx = /^\d+$/.test(rawShapeIdx) ? Number(rawShapeIdx) : 0;
const shapeIdx = Number.isSafeInteger(parsedShapeIdx) ? parsedShapeIdx : 0;
return {
color: parts[0] || "#F97316",
shapeIndex: shapeIdx % SHIPPED_SHAPE_KEYS.length,
isImage: false,
};
}
return { color: rawColor, isImage: false };
const look = decodeOoAvatar(rawColor);
if (!look) return { color: "#F97316", isImage: false };
return {
color: look.color || "#F97316",
shapeIndex: look.shape ?? undefined,
isImage: false,
};
}
export interface BotAvatarProps {

View File

@ -1,3 +1,5 @@
import { OO_EYE_ASYMMETRY_MIN_RATIO } from "@rakazo/core";
/**
* evOo brand lock — non-negotiable (Ingo).
*
@ -25,7 +27,7 @@ export const EVOO_PUPIL_BIG_R = 14.2;
export const EVOO_PUPIL_SMALL_R = EVOO_PUPIL_BIG_R * 0.618034;
/** Minimum big÷small ratio — catches accidental symmetrization in tests/CI. */
export const EVOO_EYE_ASYMMETRY_MIN_RATIO = 1.45;
export const EVOO_EYE_ASYMMETRY_MIN_RATIO = OO_EYE_ASYMMETRY_MIN_RATIO;
export const EVOO_BRAND_LOCK_RULES = [
"Two eyes only: one big O, one small o — never equal radii.",