evOo-local/apps/mobile/components/oo-atelier.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

411 lines
12 KiB
TypeScript

import type { OoAvatar } from "@rakazo/core";
import {
encodeOoAvatar,
OO_ARMS,
OO_BROWS,
OO_CHEEKS,
OO_CLASSIC_SHAPE_COUNT,
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 { useState } from "react";
import { Pressable, ScrollView, Text, View } from "react-native";
import { useI18n } from "../lib/i18n";
import { useMobileTokens } from "../lib/native";
import { OoFigure } from "./oo-figure";
const THUMB = 52;
/** Temperament in three taps; web offers the same range as sliders. */
const ENERGY_STEPS = [0.75, 1, 1.35] as const;
const PLAYFUL_STEPS = [0.2, 0.5, 0.85] as const;
function nearest(steps: readonly number[], value: number): number {
return steps.reduce((best, step) =>
Math.abs(step - value) < Math.abs(best - value) ? step : best,
);
}
function useLabels() {
const { t } = useI18n();
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 },
energy: [t("Calm"), t("Steady"), t("Lively")],
} as const;
}
function Row<T extends string | number>({
title,
values,
selected,
label,
look,
patch,
focus = "body",
onSelect,
}: {
title: string;
values: readonly T[];
selected: T;
label: (value: T) => string;
look: OoAvatar;
patch: (value: T) => Partial<OoAvatar>;
focus?: "body" | "face";
onSelect: (value: T) => void;
}) {
const tokens = useMobileTokens();
return (
<View style={{ marginTop: 14 }}>
<Text style={{ color: tokens.mutedForeground, fontSize: 13 }}>{title}</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ gap: 8, marginTop: 6 }}
accessibilityRole="radiogroup"
accessibilityLabel={title}
>
{values.map((value) => {
const next = { ...look, ...patch(value) };
const active = value === selected;
return (
<Pressable
key={value}
accessibilityRole="radio"
accessibilityLabel={label(value)}
accessibilityState={{ checked: active }}
onPress={() => onSelect(value)}
style={{
width: THUMB + 8,
height: THUMB + 8,
borderRadius: 14,
alignItems: "center",
justifyContent: "center",
overflow: "hidden",
backgroundColor: tokens.muted,
borderWidth: 2,
borderColor: active ? tokens.foreground : "transparent",
}}
>
<OoFigure
avatar={next}
color={next.color}
fallbackShape={next.shape ?? 0}
moment="idle"
size={THUMB}
focus={focus}
still
/>
</Pressable>
);
})}
</ScrollView>
</View>
);
}
/** The Oo look editor: species and colour first, everything else one tap further. */
export function OoAtelier({
value,
identity,
onChange,
}: {
value: string;
identity: string;
onChange: (value: string) => void;
}) {
const { t } = useI18n();
const tokens = useMobileTokens();
const labels = useLabels();
const [moreOpen, setMoreOpen] = useState(false);
const look = ooLookForEditing(value, identity);
const set = <K extends keyof OoAvatar>(field: K, next: OoAvatar[K]) =>
onChange(encodeOoAvatar({ ...look, [field]: next }));
const chip = (active: boolean) => ({
minHeight: 36,
paddingHorizontal: 14,
borderRadius: 18,
justifyContent: "center" as const,
backgroundColor: active ? tokens.foreground : tokens.muted,
});
const chipText = (active: boolean) => ({
color: active ? tokens.background : tokens.foreground,
fontSize: 14,
});
return (
<View>
<Row
title={t("Species")}
values={OO_SPECIES}
selected={look.species}
label={(species) => labels.species[species]}
look={look}
patch={(species) => ({ species, arms: species === "robot" ? "robot" : look.arms })}
onSelect={(species) =>
onChange(
encodeOoAvatar({
...look,
species,
arms: species === "robot" ? "robot" : look.arms === "robot" ? "glass" : look.arms,
}),
)
}
/>
{look.species === "classic" ? (
<Row
title={t("Shape")}
values={Array.from({ length: OO_CLASSIC_SHAPE_COUNT }, (_, index) => index)}
selected={look.shape ?? -1}
label={(shape) => t("Shape {number}", { number: shape + 1 })}
look={look}
patch={(shape) => ({ shape })}
onSelect={(shape) => set("shape", shape)}
/>
) : null}
<Text style={{ color: tokens.mutedForeground, marginTop: 14, fontSize: 13 }}>
{t("Color")}
</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ gap: 10, marginTop: 6 }}
accessibilityRole="radiogroup"
>
{OO_PALETTE_HEXES.map((option, index) => {
const checked = look.color.toLowerCase() === option.toLowerCase();
return (
<Pressable
key={option}
accessibilityRole="radio"
accessibilityLabel={t("Color {number}", { number: index + 1 })}
accessibilityState={{ checked }}
onPress={() => set("color", option)}
style={{
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: option,
borderWidth: 3,
borderColor: checked ? tokens.foreground : tokens.border,
}}
/>
);
})}
</ScrollView>
<View style={{ flexDirection: "row", gap: 8, marginTop: 14 }}>
<Pressable
accessibilityRole="button"
accessibilityState={{ expanded: moreOpen }}
onPress={() => setMoreOpen((open) => !open)}
style={chip(moreOpen)}
>
<Text style={chipText(moreOpen)}>{t("Customize")}</Text>
</Pressable>
<Pressable
accessibilityRole="button"
onPress={() => onChange(encodeOoAvatar(ooRandomAvatar()))}
style={chip(false)}
>
<Text style={chipText(false)}>{t("Surprise me")}</Text>
</Pressable>
</View>
{moreOpen ? (
<View>
<Row
title={t("Lashes")}
values={OO_LASHES}
selected={look.lashes}
label={(lashes) => labels.lashes[lashes]}
look={look}
patch={(lashes) => ({ lashes })}
focus="face"
onSelect={(lashes) => set("lashes", lashes)}
/>
<Row
title={t("Eyeliner")}
values={OO_LINERS}
selected={look.liner}
label={(liner) => labels.liner[liner]}
look={look}
patch={(liner) => ({ liner })}
focus="face"
onSelect={(liner) => set("liner", liner)}
/>
<Row
title={t("Eyeshadow")}
values={OO_EYESHADOWS}
selected={look.eyeshadow}
label={(eyeshadow) => labels.eyeshadow[eyeshadow]}
look={look}
patch={(eyeshadow) => ({ eyeshadow })}
focus="face"
onSelect={(eyeshadow) => set("eyeshadow", eyeshadow)}
/>
<Row
title={t("Iris")}
values={OO_IRISES}
selected={look.iris}
label={(iris) => labels.iris[iris]}
look={look}
patch={(iris) => ({ iris })}
focus="face"
onSelect={(iris) => set("iris", iris)}
/>
<Row
title={t("Sparkle")}
values={OO_SPARKLES}
selected={look.sparkle}
label={(sparkle) => labels.sparkle[sparkle]}
look={look}
patch={(sparkle) => ({ sparkle })}
focus="face"
onSelect={(sparkle) => set("sparkle", sparkle)}
/>
<Row
title={t("Brows")}
values={OO_BROWS}
selected={look.brows}
label={(brows) => labels.brows[brows]}
look={look}
patch={(brows) => ({ brows })}
focus="face"
onSelect={(brows) => set("brows", brows)}
/>
<Row
title={t("Cheeks")}
values={OO_CHEEKS}
selected={look.cheeks}
label={(cheeks) => labels.cheeks[cheeks]}
look={look}
patch={(cheeks) => ({ cheeks })}
focus="face"
onSelect={(cheeks) => set("cheeks", cheeks)}
/>
<Row
title={t("Glasses")}
values={OO_EYEWEAR}
selected={look.eyewear}
label={(eyewear) => labels.eyewear[eyewear]}
look={look}
patch={(eyewear) => ({ eyewear })}
focus="face"
onSelect={(eyewear) => set("eyewear", eyewear)}
/>
<Row
title={t("Hat")}
values={OO_HEADWEAR}
selected={look.head}
label={(head) => labels.head[head]}
look={look}
patch={(head) => ({ head })}
onSelect={(head) => set("head", head)}
/>
<Row
title={t("Neck")}
values={OO_NECKWEAR}
selected={look.neck}
label={(neck) => labels.neck[neck]}
look={look}
patch={(neck) => ({ neck })}
onSelect={(neck) => set("neck", neck)}
/>
<Row
title={t("Arms")}
values={OO_ARMS}
selected={look.arms}
label={(arms) => labels.arms[arms]}
look={look}
patch={(arms) => ({ arms })}
onSelect={(arms) => set("arms", arms)}
/>
{(
[
["energy", t("Energy"), ENERGY_STEPS],
["playful", t("Playful"), PLAYFUL_STEPS],
] as const
).map(([field, title, steps]) => {
const current = nearest(steps, look[field]);
return (
<View key={field} style={{ marginTop: 14 }}>
<Text style={{ color: tokens.mutedForeground, fontSize: 13 }}>{title}</Text>
<View
accessibilityRole="radiogroup"
accessibilityLabel={title}
style={{ flexDirection: "row", gap: 8, marginTop: 6 }}
>
{steps.map((step, index) => (
<Pressable
key={step}
accessibilityRole="radio"
accessibilityState={{ checked: step === current }}
onPress={() => set(field, step)}
style={chip(step === current)}
>
<Text style={chipText(step === current)}>{labels.energy[index]}</Text>
</Pressable>
))}
</View>
</View>
);
})}
</View>
) : null}
</View>
);
}