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>
411 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|