evOo-local/apps/web/src/components/OoAtelier.tsx
cynarAI 63578bb645
feat(avatar): dress up any Oo in the Atelier on web and mobile (#143)
The Atelier replaces Avatar Studio: pick a species, colour, eyes,
glasses, hats, neckwear, arms and temperament, with every option drawn
as your own Oo wearing it. Onboarding's "Design your Oo" now offers
species looks per vibe and opens the Atelier for the rest; mobile bot
settings carry the same editor instead of a bare colour row, so picking
a colour there no longer throws away the rest of the look.


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

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-05 03:20:05 +02:00

617 lines
21 KiB
TypeScript

import { Trans, useLingui } from "@lingui/react/macro";
import type { OoAvatar, OoMoment } from "@rakazo/core";
import {
DEFAULT_OO_AVATAR,
encodeOoAvatar,
OO_ARMS,
OO_BROWS,
OO_CHEEKS,
OO_CLASSIC_SHAPE_COUNT,
OO_ENERGY_RANGE,
OO_EYESHADOWS,
OO_EYEWEAR,
OO_HEADWEAR,
OO_IRISES,
OO_LASHES,
OO_LINERS,
OO_NECKWEAR,
OO_PALETTE_HEXES,
OO_SPARKLES,
OO_SPECIES,
ooLookForEditing,
ooRandomAvatar,
} from "@rakazo/core";
import {
BotAvatar,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
OoFigure,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@rakazo/ui-web";
import { Check, Dices, Pencil, Upload, X } from "lucide-react";
import type { ClipboardEvent, DragEvent, ReactNode } from "react";
import { useRef, useState } from "react";
type Field = keyof OoAvatar;
/** Option rows of the Atelier; every value is drawn as the Oo wearing it. */
function useOptionLabels() {
const { t } = useLingui();
const none = t`None`;
return {
species: {
classic: t`Classic`,
cat: t`Cat`,
dog: t`Dog`,
bunny: t`Bunny`,
bear: t`Bear`,
robot: t`Robot`,
ghost: t`Ghost`,
frog: t`Frog`,
penguin: t`Penguin`,
},
lashes: { none, soft: t`Soft`, full: t`Full`, flick: t`Flick` },
liner: { none, wing: t`Winged` },
eyeshadow: { none, rose: t`Rose`, lilac: t`Lilac`, gold: t`Gold`, mint: t`Mint` },
iris: {
classic: t`Classic`,
brown: t`Brown`,
blue: t`Blue`,
green: t`Green`,
violet: t`Violet`,
},
sparkle: { dot: t`Dot`, star: t`Star`, heart: t`Heart` },
brows: { auto: t`When needed`, fine: t`Fine`, bushy: t`Bushy` },
cheeks: { none, pink: t`Pink`, peach: t`Peach`, freckles: t`Freckles` },
eyewear: {
none,
round: t`Round`,
nerd: t`Nerd`,
sun: t`Sunglasses`,
heart: t`Heart`,
monocle: t`Monocle`,
reading: t`Reading glasses`,
},
head: {
none,
chef: t`Chef's hat`,
crown: t`Crown`,
headphones: t`Headphones`,
bow: t`Bow`,
beanie: t`Beanie`,
flower: t`Flower`,
party: t`Party hat`,
},
neck: { none, bowtie: t`Bow tie`, scarf: t`Scarf`, bell: t`Bell` },
arms: { glass: t`Glass`, stub: t`Stub`, paw: t`Paw`, robot: t`Robot`, none },
} as const;
}
function OptionGroup<T extends string | number>({
title,
values,
selected,
label,
render,
onSelect,
}: {
title: string;
values: readonly T[];
selected: T;
label: (value: T) => string;
render: (value: T) => ReactNode;
onSelect: (value: T) => void;
}) {
return (
<fieldset>
<legend className="mb-1.5 text-ui-label font-medium text-muted-foreground">{title}</legend>
<div className="flex flex-wrap gap-1.5">
{values.map((value) => {
const active = value === selected;
return (
<button
key={value}
type="button"
aria-label={label(value)}
title={label(value)}
aria-pressed={active}
onClick={() => onSelect(value)}
className={`grid size-12 place-items-center overflow-hidden rounded-xl transition-transform hover:scale-105 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
active ? "bg-foreground/10 ring-2 ring-primary" : "bg-muted/60 hover:bg-muted"
}`}
>
{render(value)}
</button>
);
})}
</div>
</fieldset>
);
}
export interface OoAtelierDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
value: string;
identity: string;
status?: string;
onChange: (value: string) => void;
/** Bots may wear an uploaded picture instead of an Oo. */
allowUpload?: boolean;
}
export function OoAtelierDialog({
open,
onOpenChange,
value,
identity,
status,
onChange,
allowUpload = false,
}: OoAtelierDialogProps) {
const { t } = useLingui();
const labels = useOptionLabels();
const [draft, setDraft] = useState<OoAvatar | null>(null);
const [preview, setPreview] = useState<OoMoment | undefined>(undefined);
const [dragOver, setDragOver] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const isPicture = value.startsWith("data:image/");
const look = draft ?? ooLookForEditing(value, identity);
const encoded = encodeOoAvatar(look);
function commit(next: OoAvatar) {
setDraft(null);
onChange(encodeOoAvatar(next));
}
function set<K extends Field>(field: K, next: OoAvatar[K]) {
commit({ ...look, [field]: next });
}
function slide(field: "energy" | "playful", next: number) {
setDraft({ ...look, [field]: next });
setPreview(field === "energy" ? "working" : "greeting");
}
function settle() {
if (draft) commit(draft);
setPreview(undefined);
}
function thumb(next: Partial<OoAvatar>, focus: "body" | "face" = "body") {
return (
<OoFigure
avatar={{ ...look, ...next }}
color={next.color ?? look.color}
fallbackShape={next.shape ?? look.shape ?? 0}
moment="idle"
size={44}
focus={focus}
still
/>
);
}
function processImageFile(file: File) {
if (!file.type.startsWith("image/")) return;
const reader = new FileReader();
reader.onload = (event) => {
const src = event.target?.result as string;
if (!src) return;
const img = new Image();
img.onload = () => {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const targetSize = 256;
canvas.width = targetSize;
canvas.height = targetSize;
if (!ctx) return;
ctx.beginPath();
ctx.arc(targetSize / 2, targetSize / 2, targetSize / 2, 0, Math.PI * 2);
ctx.clip();
const minDim = Math.min(img.width, img.height);
const sx = (img.width - minDim) / 2;
const sy = (img.height - minDim) / 2;
ctx.drawImage(img, sx, sy, minDim, minDim, 0, 0, targetSize, targetSize);
onChange(canvas.toDataURL("image/webp", 0.9));
onOpenChange(false);
};
img.src = src;
};
reader.readAsDataURL(file);
}
function handleDrop(event: DragEvent<HTMLButtonElement>) {
event.preventDefault();
setDragOver(false);
const file = event.dataTransfer.files?.[0];
if (file) processImageFile(file);
}
function handlePaste(event: ClipboardEvent<HTMLButtonElement>) {
const items = event.clipboardData?.items;
if (!items) return;
for (let index = 0; index < items.length; index += 1) {
const item = items[index];
if (!item?.type.startsWith("image/")) continue;
const file = item.getAsFile();
if (file) {
processImageFile(file);
break;
}
}
}
const sliderClass = "w-full accent-primary";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
className="max-h-[92vh] w-[420px] max-w-full gap-3 overflow-y-auto rounded-3xl p-5 sm:max-w-[420px]"
data-testid="oo-atelier"
>
<DialogHeader className="flex-row items-center justify-between space-y-0">
<DialogTitle className="text-ui-body font-semibold tracking-tight">
<Trans>Atelier</Trans>
</DialogTitle>
<DialogDescription className="sr-only">
<Trans>Choose how your Oo looks and moves</Trans>
</DialogDescription>
<button
type="button"
onClick={() => onOpenChange(false)}
className="flex size-7 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label={t`Close`}
>
<X className="size-icon-md" />
</button>
</DialogHeader>
<div className="flex justify-center py-1" data-testid="oo-atelier-preview">
<BotAvatar
color={isPicture && !draft ? value : encoded}
identity={identity}
status={status}
size={128}
moment={preview}
interactive
/>
</div>
<Tabs defaultValue="body">
<TabsList className="w-full">
<TabsTrigger value="body">
<Trans>Body</Trans>
</TabsTrigger>
<TabsTrigger value="eyes">
<Trans>Eyes</Trans>
</TabsTrigger>
<TabsTrigger value="extras">
<Trans>Extras</Trans>
</TabsTrigger>
{allowUpload ? (
<TabsTrigger value="upload">
<Trans>Upload</Trans>
</TabsTrigger>
) : null}
</TabsList>
<TabsContent value="body" className="space-y-4 pt-2" data-testid="oo-atelier-body">
<OptionGroup
title={t`Species`}
values={OO_SPECIES}
selected={look.species}
label={(species) => labels.species[species]}
render={(species) =>
thumb({ species, arms: species === "robot" ? "robot" : look.arms })
}
onSelect={(species) =>
commit({
...look,
species,
arms: species === "robot" ? "robot" : look.arms === "robot" ? "glass" : look.arms,
})
}
/>
{look.species === "classic" ? (
<OptionGroup
title={t`Shape`}
values={Array.from({ length: OO_CLASSIC_SHAPE_COUNT }, (_, index) => index)}
selected={look.shape ?? -1}
label={(shape) => t`Shape ${shape + 1}`}
render={(shape) => thumb({ shape })}
onSelect={(shape) => set("shape", shape)}
/>
) : null}
<fieldset>
<legend className="mb-1.5 text-ui-label font-medium text-muted-foreground">
<Trans>Color</Trans>
</legend>
<div className="flex flex-wrap items-center gap-2">
{OO_PALETTE_HEXES.map((color) => {
const selected = !isPicture && look.color.toLowerCase() === color.toLowerCase();
return (
<button
key={color}
type="button"
onClick={() => set("color", color)}
aria-label={t`Color ${color}`}
aria-pressed={selected}
className={`size-7 rounded-full border transition-transform hover:scale-110 active:scale-95 focus-visible:ring-2 focus-visible:ring-ring ${
selected
? "scale-105 border-transparent ring-2 ring-foreground ring-offset-2 ring-offset-popover"
: "border-border"
}`}
style={{ backgroundColor: color }}
/>
);
})}
<input
type="color"
aria-label={t`Custom color`}
value={look.color.length === 7 ? look.color : DEFAULT_OO_AVATAR.color}
onChange={(event) => setDraft({ ...look, color: event.target.value })}
onBlur={settle}
className="size-7 cursor-pointer rounded-full border border-border bg-transparent p-0"
/>
</div>
</fieldset>
<div className="grid grid-cols-2 gap-4">
<label className="text-ui-label font-medium text-muted-foreground">
<Trans>Energy</Trans>
<input
type="range"
min={OO_ENERGY_RANGE.min}
max={OO_ENERGY_RANGE.max}
step={0.05}
value={look.energy}
onChange={(event) => slide("energy", Number(event.target.value))}
onPointerUp={settle}
onKeyUp={settle}
onBlur={settle}
className={sliderClass}
/>
</label>
<label className="text-ui-label font-medium text-muted-foreground">
<Trans>Playful</Trans>
<input
type="range"
min={0}
max={1}
step={0.05}
value={look.playful}
onChange={(event) => slide("playful", Number(event.target.value))}
onPointerUp={settle}
onKeyUp={settle}
onBlur={settle}
className={sliderClass}
/>
</label>
</div>
</TabsContent>
<TabsContent value="eyes" className="space-y-4 pt-2" data-testid="oo-atelier-eyes">
<OptionGroup
title={t`Lashes`}
values={OO_LASHES}
selected={look.lashes}
label={(lashes) => labels.lashes[lashes]}
render={(lashes) => thumb({ lashes }, "face")}
onSelect={(lashes) => set("lashes", lashes)}
/>
<OptionGroup
title={t`Eyeliner`}
values={OO_LINERS}
selected={look.liner}
label={(liner) => labels.liner[liner]}
render={(liner) => thumb({ liner }, "face")}
onSelect={(liner) => set("liner", liner)}
/>
<OptionGroup
title={t`Eyeshadow`}
values={OO_EYESHADOWS}
selected={look.eyeshadow}
label={(eyeshadow) => labels.eyeshadow[eyeshadow]}
render={(eyeshadow) => thumb({ eyeshadow }, "face")}
onSelect={(eyeshadow) => set("eyeshadow", eyeshadow)}
/>
<OptionGroup
title={t`Iris`}
values={OO_IRISES}
selected={look.iris}
label={(iris) => labels.iris[iris]}
render={(iris) => thumb({ iris }, "face")}
onSelect={(iris) => set("iris", iris)}
/>
<OptionGroup
title={t`Sparkle`}
values={OO_SPARKLES}
selected={look.sparkle}
label={(sparkle) => labels.sparkle[sparkle]}
render={(sparkle) => thumb({ sparkle }, "face")}
onSelect={(sparkle) => set("sparkle", sparkle)}
/>
<OptionGroup
title={t`Brows`}
values={OO_BROWS}
selected={look.brows}
label={(brows) => labels.brows[brows]}
render={(brows) => thumb({ brows }, "face")}
onSelect={(brows) => set("brows", brows)}
/>
<OptionGroup
title={t`Cheeks`}
values={OO_CHEEKS}
selected={look.cheeks}
label={(cheeks) => labels.cheeks[cheeks]}
render={(cheeks) => thumb({ cheeks }, "face")}
onSelect={(cheeks) => set("cheeks", cheeks)}
/>
</TabsContent>
<TabsContent value="extras" className="space-y-4 pt-2" data-testid="oo-atelier-extras">
<OptionGroup
title={t`Glasses`}
values={OO_EYEWEAR}
selected={look.eyewear}
label={(eyewear) => labels.eyewear[eyewear]}
render={(eyewear) => thumb({ eyewear }, "face")}
onSelect={(eyewear) => set("eyewear", eyewear)}
/>
<OptionGroup
title={t`Hat`}
values={OO_HEADWEAR}
selected={look.head}
label={(head) => labels.head[head]}
render={(head) => thumb({ head })}
onSelect={(head) => set("head", head)}
/>
<OptionGroup
title={t`Neck`}
values={OO_NECKWEAR}
selected={look.neck}
label={(neck) => labels.neck[neck]}
render={(neck) => thumb({ neck })}
onSelect={(neck) => set("neck", neck)}
/>
<OptionGroup
title={t`Arms`}
values={OO_ARMS}
selected={look.arms}
label={(arms) => labels.arms[arms]}
render={(arms) => thumb({ arms })}
onSelect={(arms) => set("arms", arms)}
/>
</TabsContent>
{allowUpload ? (
<TabsContent value="upload" className="pt-2">
<button
type="button"
aria-label={t`Image upload area`}
onDragOver={(event) => {
event.preventDefault();
setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
onPaste={handlePaste}
onClick={() => fileInputRef.current?.click()}
className={`flex w-full flex-col items-center justify-center rounded-xl border border-dashed p-6 text-center transition-colors ${
dragOver
? "border-primary bg-primary/10"
: "border-border bg-muted hover:border-foreground/30"
}`}
>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) processImageFile(file);
}}
/>
<div className="mb-2 grid size-10 place-items-center rounded-full bg-secondary text-muted-foreground">
<Upload strokeWidth={1.8} className="size-icon-lg" />
</div>
<p className="text-ui-body-sm font-medium text-muted-foreground">
<Trans>Drag, drop, or paste an image</Trans>
</p>
<span className="mt-3 rounded-lg bg-secondary px-3 py-1.5 text-ui-label font-medium text-foreground">
<Trans>Choose file</Trans>
</span>
</button>
</TabsContent>
) : null}
</Tabs>
<DialogFooter className="flex-row items-center justify-between border-border sm:justify-between">
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => commit(ooRandomAvatar())}
aria-label={t`Surprise me`}
title={t`Surprise me`}
className="flex size-8 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<Dices className="size-icon-md" />
</button>
<button
type="button"
onClick={() => commit({ ...DEFAULT_OO_AVATAR, color: look.color })}
className="px-2 py-1 text-ui-label font-medium text-muted-foreground transition-colors hover:text-foreground"
>
<Trans>Reset</Trans>
</button>
</div>
<button
type="button"
onClick={() => onOpenChange(false)}
className="flex items-center gap-1.5 rounded-xl bg-secondary px-4 py-1.5 text-ui-body-sm font-medium text-foreground transition-colors hover:bg-accent"
>
<Check className="size-icon-md" />
<Trans>Done</Trans>
</button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export interface OoAtelierProps {
value: string;
identity?: string;
status?: string;
size?: number;
onChange: (value: string) => void;
disabled?: boolean;
}
/** The bot's avatar; tapping it opens the Atelier. */
export function OoAtelier({
value,
identity = "",
status,
size = 72,
onChange,
disabled = false,
}: OoAtelierProps) {
const { t } = useLingui();
const [open, setOpen] = useState(false);
return (
<>
<button
type="button"
disabled={disabled}
onClick={() => setOpen(true)}
className="group relative cursor-pointer rounded-2xl outline-none transition-transform hover:scale-[1.03] focus-visible:ring-2 focus-visible:ring-ring"
aria-label={t`Customize bot avatar`}
data-testid="oo-atelier-trigger"
>
<BotAvatar color={value} identity={identity} size={size} status={status} />
<div className="absolute -right-1 -bottom-1 flex size-6 items-center justify-center rounded-full border-2 border-background bg-secondary text-foreground shadow-md transition-transform group-hover:scale-110">
<Pencil strokeWidth={2.2} className="size-icon-sm" />
</div>
</button>
<OoAtelierDialog
open={open}
onOpenChange={setOpen}
value={value}
identity={identity}
status={status}
onChange={onChange}
allowUpload
/>
</>
);
}