fix avator

This commit is contained in:
王性驊 2026-09-02 13:40:18 +08:00
parent 5674f1e8eb
commit afe8149988
27 changed files with 489 additions and 194 deletions

View File

@ -22,6 +22,17 @@ type BotSettingsRecord = MobileBot & {
description?: string; description?: string;
}; };
const AVATAR_SHAPE_LABELS: Record<AvatarShape, string> = {
circle: "Circle",
oval: "Oval",
"rounded-square": "Rounded square",
pill: "Pill",
triangle: "Triangle",
hexagon: "Hexagon",
cloud: "Cloud",
teardrop: "Teardrop",
};
export default function BotSettingsScreen() { export default function BotSettingsScreen() {
const router = useRouter(); const router = useRouter();
const { botId } = useLocalSearchParams<{ botId: string }>(); const { botId } = useLocalSearchParams<{ botId: string }>();
@ -57,12 +68,22 @@ export default function BotSettingsScreen() {
avatarImageArtifactId?: string | null; avatarImageArtifactId?: string | null;
}) { }) {
if (!botId || !bot || pending) return; if (!botId || !bot || pending) return;
const previous = bot;
setBot({
...bot,
...(patch.color !== undefined ? { color: patch.color } : {}),
...(patch.avatarShape !== undefined ? { avatarShape: patch.avatarShape } : {}),
...(patch.avatarImageArtifactId !== undefined
? { hasAvatarImage: patch.avatarImageArtifactId !== null }
: {}),
});
setPending(true); setPending(true);
setError(null); setError(null);
try { try {
const next = await rpc<BotSettingsRecord>("bots/update", { botId, ...patch }); const next = await rpc<BotSettingsRecord>("bots/update", { botId, ...patch });
setBot(next); setBot(next);
} catch (err) { } catch (err) {
setBot(previous);
setError(err instanceof Error ? err.message : "Could not update avatar"); setError(err instanceof Error ? err.message : "Could not update avatar");
} finally { } finally {
setPending(false); setPending(false);
@ -76,6 +97,7 @@ export default function BotSettingsScreen() {
if (!file) return; if (!file) return;
setPending(true); setPending(true);
setError(null); setError(null);
const previous = bot;
try { try {
const artifact = await rpc<{ id: string }>("artifacts/create", { const artifact = await rpc<{ id: string }>("artifacts/create", {
botId, botId,
@ -83,12 +105,14 @@ export default function BotSettingsScreen() {
mimeType: file.mimeType, mimeType: file.mimeType,
contentBase64: file.contentBase64, contentBase64: file.contentBase64,
}); });
if (bot) setBot({ ...bot, hasAvatarImage: true });
const next = await rpc<BotSettingsRecord>("bots/update", { const next = await rpc<BotSettingsRecord>("bots/update", {
botId, botId,
avatarImageArtifactId: artifact.id, avatarImageArtifactId: artifact.id,
}); });
setBot(next); setBot(next);
} catch (err) { } catch (err) {
if (previous) setBot(previous);
setError(err instanceof Error ? err.message : "Could not update avatar"); setError(err instanceof Error ? err.message : "Could not update avatar");
} finally { } finally {
setPending(false); setPending(false);
@ -172,7 +196,7 @@ export default function BotSettingsScreen() {
} }
style={{ style={{
width: 52, width: 52,
height: 52, height: 68,
borderRadius: 12, borderRadius: 12,
borderWidth: 1, borderWidth: 1,
borderColor: selected ? "#5A5A62" : "#26262A", borderColor: selected ? "#5A5A62" : "#26262A",
@ -186,8 +210,18 @@ export default function BotSettingsScreen() {
identity={bot.id} identity={bot.id}
size={28} size={28}
shape={shape} shape={shape}
variant="robot" variant="organic"
/> />
<Text
numberOfLines={1}
style={{
marginTop: 3,
color: selected ? "#ECECEE" : "#85858A",
fontSize: 9,
}}
>
{AVATAR_SHAPE_LABELS[shape]}
</Text>
</Pressable> </Pressable>
); );
})} })}

View File

@ -4,9 +4,9 @@ test("organic avatar path stays still when reduced motion is enabled", async ({
await page.emulateMedia({ reducedMotion: "reduce" }); await page.emulateMedia({ reducedMotion: "reduce" });
await page.goto("/e2e/fixtures/avatar-motion.html"); await page.goto("/e2e/fixtures/avatar-motion.html");
const avatar = page.locator(".rakazo-organic-avatar"); const avatar = page.locator(".rakazo-organic-avatar").first();
await expect(avatar).toBeVisible(); await expect(avatar).toBeVisible();
await expect(avatar.locator("animate")).toHaveCount(0); await expect(page.locator(".rakazo-organic-avatar animate")).toHaveCount(0);
const body = avatar.locator(".rakazo-organic-avatar-body-working"); const body = avatar.locator(".rakazo-organic-avatar-body-working");
const snapshot = () => const snapshot = () =>
@ -22,3 +22,18 @@ test("organic avatar path stays still when reduced motion is enabled", async ({
expect(first.animationName).toBe("none"); expect(first.animationName).toBe("none");
expect(second).toEqual(first); expect(second).toEqual(first);
}); });
test("working faces keep their gaze in the upper-left", async ({ page }) => {
await page.goto("/e2e/fixtures/avatar-motion.html");
const workingEyes = page.locator('[data-gaze="upper-left"]');
await expect(workingEyes).toHaveCount(8);
await expect(workingEyes.first()).toHaveCSS("animation-name", "rakazo-organic-eyes-thinking");
const transform = await workingEyes.first().evaluate((eyes: SVGGElement) => {
const matrix = new DOMMatrixReadOnly(getComputedStyle(eyes).transform);
return { x: matrix.m41, y: matrix.m42 };
});
expect(transform.x).toBeLessThan(0);
expect(transform.y).toBeLessThan(0);
});

View File

@ -64,6 +64,13 @@ test("bot creation, editing, and deletion persist", async ({ page }, testInfo) =
const settings = page.getByTestId("bot-settings"); const settings = page.getByTestId("bot-settings");
const editor = settings.getByTestId("bot-avatar-editor"); const editor = settings.getByTestId("bot-avatar-editor");
await expect(editor).toBeVisible(); await expect(editor).toBeVisible();
await expect(editor.getByRole("button", { name: "Cloud" })).toBeVisible();
await expect(editor.getByRole("button", { name: "Circle" })).toBeVisible();
await expect(editor.getByRole("button", { name: "Rounded square" })).toBeVisible();
await editor.getByRole("button", { name: "Cloud" }).click();
await expect(page.locator("aside [data-shape='cloud']").first()).toBeVisible();
await editor.getByRole("button", { name: "Rounded square" }).click();
await expect(page.locator("aside [data-shape='rounded-square']").first()).toBeVisible();
await editor.getByRole("button", { name: "Hexagon" }).click(); await editor.getByRole("button", { name: "Hexagon" }).click();
await expect(page.locator("aside [data-shape='hexagon']").first()).toBeVisible(); await expect(page.locator("aside [data-shape='hexagon']").first()).toBeVisible();
await captureScreenshot(page, testInfo, "27a-bot-avatar"); await captureScreenshot(page, testInfo, "27a-bot-avatar");

View File

@ -1,12 +1,29 @@
import { AVATAR_SHAPES } from "@rakazo/contracts";
import { BotAvatar } from "@rakazo/ui-web"; import { BotAvatar } from "@rakazo/ui-web";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<main
style={{
minHeight: "100vh",
display: "grid",
gridTemplateColumns: "repeat(4, 120px)",
alignItems: "center",
justifyContent: "center",
gap: 28,
background: "#101012",
}}
>
{AVATAR_SHAPES.map((shape) => (
<BotAvatar <BotAvatar
key={shape}
color="#D9508A" color="#D9508A"
identity="reduced-motion" identity={`motion-${shape}`}
size={120} size={120}
shape={shape}
status="running" status="running"
variant="organic" variant="organic"
/>, />
))}
</main>,
); );

View File

@ -23,3 +23,51 @@ test("renders the sign-in screen in Traditional Chinese with Huninn", async ({
await captureScreenshot(page, testInfo, "traditional-chinese-sign-in"); await captureScreenshot(page, testInfo, "traditional-chinese-sign-in");
}); });
test("keeps the Traditional Chinese welcome brand aligned on narrow screens", async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 320, height: 640 });
await page.addInitScript(() => {
localStorage.setItem("rakazo.uiLocale", "zh-TW");
});
await page.route("**/api/auth/get-session", (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: "null" }),
);
await page.goto("/");
const mark = page.getByTestId("welcome-mark");
const title = page.getByTestId("welcome-title");
await expect(mark).toBeVisible();
await expect(title).toHaveText("BangSo Bot");
await expect(title).toHaveCSS("font-family", /Huninn/);
await expect(page.getByText("好幫手機器人", { exact: true })).toBeVisible();
await expect
.poll(() =>
page.evaluate(() => ({
clientWidth: document.documentElement.clientWidth,
scrollWidth: document.documentElement.scrollWidth,
})),
)
.toEqual({ clientWidth: 320, scrollWidth: 320 });
const markBox = await mark.boundingBox();
expect(markBox?.width).toBe(markBox?.height);
await captureScreenshot(page, testInfo, "traditional-chinese-welcome-narrow");
});
test("keeps the welcome wordmark shorter than its mark", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 720 });
await page.route("**/api/auth/get-session", (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: "null" }),
);
await page.goto("/");
const markBox = await page.getByTestId("welcome-mark").boundingBox();
const wordmarkBox = await page.getByTestId("welcome-wordmark").boundingBox();
expect(markBox).not.toBeNull();
expect(wordmarkBox).not.toBeNull();
expect(wordmarkBox?.height).toBeLessThanOrEqual(markBox?.height ?? 0);
});

View File

@ -9,7 +9,7 @@ import {
} from "@rakazo/contracts"; } from "@rakazo/contracts";
import { botAvatarImageSrc, inferAttachmentMimeType, nextGeneratedAvatarFace } from "@rakazo/core"; import { botAvatarImageSrc, inferAttachmentMimeType, nextGeneratedAvatarFace } from "@rakazo/core";
import { BotAvatar } from "@rakazo/ui-web"; import { BotAvatar } from "@rakazo/ui-web";
import { useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { rpc } from "../lib/rpc"; import { rpc } from "../lib/rpc";
export function BotFace({ export function BotFace({
@ -69,6 +69,11 @@ export function BotAvatarEditor({
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
const [pending, setPending] = useState(false); const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [preview, setPreview] = useState(bot);
useEffect(() => {
setPreview(bot);
}, [bot]);
async function apply(patch: { async function apply(patch: {
color?: string; color?: string;
@ -76,11 +81,20 @@ export function BotAvatarEditor({
avatarImageArtifactId?: string | null; avatarImageArtifactId?: string | null;
}) { }) {
if (pending) return; if (pending) return;
const previous = preview;
setPreview({
...preview,
...patch,
...(patch.avatarImageArtifactId !== undefined
? { hasAvatarImage: patch.avatarImageArtifactId !== null }
: {}),
});
setPending(true); setPending(true);
setError(null); setError(null);
try { try {
await onChange(patch); await onChange(patch);
} catch { } catch {
setPreview(previous);
setError(t`Couldn't update avatar`); setError(t`Couldn't update avatar`);
} finally { } finally {
setPending(false); setPending(false);
@ -99,6 +113,7 @@ export function BotAvatarEditor({
} }
setPending(true); setPending(true);
setError(null); setError(null);
const previous = preview;
try { try {
const artifact = await rpc.artifacts.create({ const artifact = await rpc.artifacts.create({
botId: bot.id, botId: bot.id,
@ -106,8 +121,10 @@ export function BotAvatarEditor({
mimeType, mimeType,
contentBase64: await readFileAsBase64(file), contentBase64: await readFileAsBase64(file),
}); });
setPreview({ ...preview, hasAvatarImage: true });
await onChange({ avatarImageArtifactId: artifact.id }); await onChange({ avatarImageArtifactId: artifact.id });
} catch { } catch {
setPreview(previous);
setError(t`Couldn't update avatar`); setError(t`Couldn't update avatar`);
} finally { } finally {
setPending(false); setPending(false);
@ -116,16 +133,17 @@ export function BotAvatarEditor({
} }
const generated = nextGeneratedAvatarFace({ const generated = nextGeneratedAvatarFace({
shape: bot.avatarShape, shape: preview.avatarShape,
color: bot.color, color: preview.color,
}); });
return ( return (
<div data-testid="bot-avatar-editor" className="flex flex-col items-center"> <div data-testid="bot-avatar-editor" className="flex flex-col items-center">
<BotFace bot={bot} size={64} /> <BotFace bot={preview} size={72} variant="organic" />
<div className="mt-4 grid grid-cols-4 gap-2"> <fieldset className="mt-5 grid grid-cols-4 gap-2 border-0 p-0">
<legend className="sr-only">{t`Avatar shape`}</legend>
{AVATAR_SHAPES.map((shape) => { {AVATAR_SHAPES.map((shape) => {
const selected = (bot.avatarShape ?? "circle") === shape && !bot.hasAvatarImage; const selected = (preview.avatarShape ?? "circle") === shape && !preview.hasAvatarImage;
return ( return (
<button <button
key={shape} key={shape}
@ -134,26 +152,33 @@ export function BotAvatarEditor({
aria-pressed={selected} aria-pressed={selected}
disabled={pending} disabled={pending}
onClick={() => void apply({ avatarShape: shape, avatarImageArtifactId: null })} onClick={() => void apply({ avatarShape: shape, avatarImageArtifactId: null })}
className={`flex h-12 w-12 items-center justify-center rounded-[12px] border disabled:opacity-50 ${ className={`relative flex h-[68px] w-[60px] flex-col items-center justify-center gap-1 rounded-[14px] border text-[10.5px] transition-[border-color,background-color,transform] disabled:opacity-50 ${
selected selected
? "border-[#5A5A62] bg-[#1A1A1D]" ? "border-[#777780] bg-[#202024] text-[#ECECEE]"
: "border-[#26262A] hover:border-[#3A3A40]" : "border-[#26262A] text-[#85858A] hover:border-[#4A4A52] hover:bg-[#17171A]"
}`} }`}
> >
<BotAvatar <BotAvatar
color={bot.color} color={preview.color}
identity={bot.id} identity={preview.id}
size={28} size={38}
shape={shape} shape={shape}
variant="robot" variant="organic"
/> />
<span>{shapeLabels[shape]}</span>
{selected ? (
<span
aria-hidden="true"
className="absolute right-1.5 top-1.5 h-1.5 w-1.5 rounded-full bg-[#ECECEE]"
/>
) : null}
</button> </button>
); );
})} })}
</div> </fieldset>
<div className="mt-3 flex flex-wrap justify-center gap-2"> <div className="mt-3 flex flex-wrap justify-center gap-2">
{BOT_COLORS.map((color) => { {BOT_COLORS.map((color) => {
const selected = bot.color.toLowerCase() === color.toLowerCase(); const selected = preview.color.toLowerCase() === color.toLowerCase();
return ( return (
<button <button
key={color} key={color}
@ -195,7 +220,7 @@ export function BotAvatarEditor({
</button> </button>
<button <button
type="button" type="button"
disabled={pending || (!bot.avatarShape && !bot.hasAvatarImage)} disabled={pending || (!preview.avatarShape && !preview.hasAvatarImage)}
onClick={() => void apply({ avatarShape: null, avatarImageArtifactId: null })} onClick={() => void apply({ avatarShape: null, avatarImageArtifactId: null })}
className="disabled:opacity-50" className="disabled:opacity-50"
> >

View File

@ -19,7 +19,7 @@ describe("collaboration transcript markers", () => {
expect(html).toContain('class="flex justify-start"'); expect(html).toContain('class="flex justify-start"');
expect(html).toContain('class="inline-flex max-w-full'); expect(html).toContain('class="inline-flex max-w-full');
expect(html).toContain('class="truncate"'); expect(html).toContain('class="truncate"');
expect(html).toContain("rakazo-bot-avatar"); expect(html).toContain("rakazo-organic-avatar");
expect(html).toContain("Message from Research"); expect(html).toContain("Message from Research");
expect(html).not.toContain("{peer}"); expect(html).not.toContain("{peer}");
}); });
@ -33,7 +33,10 @@ describe("collaboration transcript markers", () => {
); );
expect(html).toContain('role="status"'); expect(html).toContain('role="status"');
expect(html).toContain('data-testid="active-bot-glyph"');
expect(html).toContain('data-working="true"'); expect(html).toContain('data-working="true"');
expect(html).toContain("rakazo-bot-avatar-ring"); expect(html).toContain("rakazo-organic-avatar-body-working");
expect(html).toContain("Research is working");
expect(html).toContain("bui-thought-spark");
}); });
}); });

View File

@ -1,5 +1,5 @@
import { BotAvatar, GroupAvatar, type GroupAvatarMember } from "@rakazo/ui-web"; import { BotAvatar, GroupAvatar, type GroupAvatarMember } from "@rakazo/ui-web";
import { LoadingState } from "./primitives"; import { Shimmer } from "./primitives";
/** Lightweight peer event shown without exposing the exchanged message body. */ /** Lightweight peer event shown without exposing the exchanged message body. */
export function CollaborationMarker({ export function CollaborationMarker({
@ -35,8 +35,22 @@ export function CollaborationMarker({
export function ActiveBotGlyph({ bots, label }: { bots: GroupAvatarMember[]; label: string }) { export function ActiveBotGlyph({ bots, label }: { bots: GroupAvatarMember[]; label: string }) {
return ( return (
<div className="flex min-h-10 items-center px-1"> <div
<LoadingState indicator={<GroupAvatar members={bots} size={28} />} label={label} /> role="status"
data-testid="active-bot-glyph"
className="flex min-h-12 items-center gap-2.5 px-1"
>
<span className="bui-thinking-avatar relative grid h-10 w-10 shrink-0 place-items-center">
<GroupAvatar members={bots} size={30} />
<span aria-hidden="true" className="bui-thought-dot bui-thought-dot-one" />
<span aria-hidden="true" className="bui-thought-dot bui-thought-dot-two" />
<span aria-hidden="true" className="bui-thought-spark">
</span>
</span>
<span className="text-[13px] font-medium">
<Shimmer>{label}</Shimmer>
</span>
</div> </div>
); );
} }

View File

@ -59,3 +59,83 @@
transform: translateY(0); transform: translateY(0);
} }
} }
.bui-thinking-avatar {
animation: bui-thinking-bob 1.8s cubic-bezier(0.45, 0, 0.55, 1) infinite;
}
.bui-thought-dot {
position: absolute;
border-radius: 999px;
background: var(--bui-ink-2);
box-shadow: 0 0 8px color-mix(in srgb, var(--bui-accent) 55%, transparent);
opacity: 0.55;
}
.bui-thought-dot-one {
top: 2px;
right: 3px;
width: 4px;
height: 4px;
animation: bui-thought-pop 1.8s ease-in-out -0.3s infinite;
}
.bui-thought-dot-two {
top: -3px;
right: -2px;
width: 6px;
height: 6px;
animation: bui-thought-pop 1.8s ease-in-out -0.7s infinite;
}
.bui-thought-spark {
position: absolute;
top: -13px;
right: -10px;
color: var(--bui-ink);
font-size: 12px;
line-height: 1;
animation: bui-thought-spark 1.8s ease-in-out infinite;
}
@keyframes bui-thinking-bob {
0%,
100% {
transform: translateY(1px) rotate(-1deg);
}
50% {
transform: translateY(-2px) rotate(1deg);
}
}
@keyframes bui-thought-pop {
0%,
100% {
opacity: 0.25;
transform: translate(-2px, 3px) scale(0.65);
}
50% {
opacity: 0.9;
transform: translate(1px, -1px) scale(1);
}
}
@keyframes bui-thought-spark {
0%,
100% {
opacity: 0.35;
transform: rotate(-12deg) scale(0.72);
}
50% {
opacity: 1;
transform: rotate(10deg) scale(1.08);
}
}
@media (prefers-reduced-motion: reduce) {
.bui-thinking-avatar,
.bui-thought-dot,
.bui-thought-spark {
animation: none;
}
}

View File

@ -6,26 +6,35 @@ import { WindowChrome } from "./WindowChrome";
export function WelcomePage() { export function WelcomePage() {
const navigate = useNavigate(); const navigate = useNavigate();
return ( return (
<div className="flex min-h-full flex-col bg-[#08080A]"> <div className="flex min-h-full min-h-[100svh] flex-col overflow-hidden bg-[#08080A]">
<div className="app-drag flex gap-2 px-5 py-[18px]"> <div className="app-drag flex shrink-0 gap-2 px-5 py-[18px]">
<WindowChrome /> <WindowChrome />
</div> </div>
<div className="flex flex-1 flex-col items-center justify-center gap-11 pb-[90px]"> <main className="flex min-h-0 flex-1 flex-col items-center justify-center gap-[clamp(1.75rem,5vh,2.75rem)] px-5 pt-4 pb-[clamp(2rem,10vh,5.625rem)]">
<div className="flex items-center gap-[26px]"> <div
<div className="flex h-[88px] w-[88px] items-center justify-center gap-[13px] rounded-full bg-[#F2F2F0]"> className="flex max-w-full items-center justify-center gap-[clamp(0.875rem,2vw,1.625rem)] max-[360px]:flex-col"
<span className="h-6 w-[11px] rounded-full bg-[#101012]" /> data-testid="welcome-brand"
<span className="h-6 w-[11px] rounded-full bg-[#101012]" /> >
<div
className="flex aspect-square h-[clamp(4.5rem,9vw,6.25rem)] shrink-0 items-center justify-center gap-[clamp(0.625rem,1.2vw,0.8125rem)] rounded-full bg-[#F2F2F0]"
data-testid="welcome-mark"
>
<span className="h-[clamp(1.125rem,2.5vw,1.5rem)] w-[clamp(0.5rem,1.1vw,0.6875rem)] rounded-full bg-[#101012]" />
<span className="h-[clamp(1.125rem,2.5vw,1.5rem)] w-[clamp(0.5rem,1.1vw,0.6875rem)] rounded-full bg-[#101012]" />
</div> </div>
<div className="text-center"> <div className="min-w-0 text-center" data-testid="welcome-wordmark">
<div className="text-[64px] leading-none tracking-[-0.03em] text-white"> <div
className="text-[clamp(2.25rem,5.5vw,3.75rem)] leading-[0.95] tracking-[-0.03em] whitespace-nowrap text-white"
data-testid="welcome-title"
>
{PRODUCT_NAME} {PRODUCT_NAME}
</div> </div>
<div className="mt-3 text-[22px] tracking-[0.08em] text-[#A8A8AD]"> <div className="mt-[clamp(0.5rem,1.5vw,0.75rem)] text-[clamp(1rem,2vw,1.375rem)] leading-tight tracking-[0.08em] text-[#A8A8AD]">
{PRODUCT_TAGLINE} {PRODUCT_TAGLINE}
</div> </div>
</div> </div>
</div> </div>
<p className="max-w-[600px] text-center text-[27px] leading-[1.4] text-[#E4E4E6]"> <p className="m-0 max-w-[600px] text-center text-[clamp(1.25rem,2.6vw,1.6875rem)] leading-[1.4] text-[#E4E4E6]">
<Trans> <Trans>
Your team of always-on agents Your team of always-on agents
<br /> <br />
@ -35,11 +44,11 @@ export function WelcomePage() {
<button <button
type="button" type="button"
onClick={() => navigate("/sign-in")} onClick={() => navigate("/sign-in")}
className="app-no-drag rounded-full bg-[#1B1B1F] px-[34px] py-[15px] text-[19px] text-[#F2F2F3] transition hover:scale-[1.04] hover:bg-[#26262B]" className="app-no-drag min-h-12 rounded-full bg-[#1B1B1F] px-[34px] py-3 text-[18px] text-[#F2F2F3] transition hover:scale-[1.04] hover:bg-[#26262B]"
> >
<Trans>Sign in&nbsp;&nbsp;</Trans> <Trans>Sign in&nbsp;&nbsp;</Trans>
</button> </button>
</div> </main>
</div> </div>
); );
} }

View File

@ -32,6 +32,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
chromium \ chromium \
fonts-liberation \ fonts-liberation \
fonts-dejavu-core \ fonts-dejavu-core \
fonts-noto-cjk \
fonts-noto-color-emoji \
xclip \
dbus-x11 \ dbus-x11 \
xdg-utils \ xdg-utils \
&& rm -rf /var/lib/apt/lists/* \ && rm -rf /var/lib/apt/lists/* \
@ -43,6 +46,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& ln -sfn /bin/true /usr/bin/xmessage && ln -sfn /bin/true /usr/bin/xmessage
ENV DISPLAY=:1 ENV DISPLAY=:1
ENV HOME=/home/rakazo ENV HOME=/home/rakazo
ENV LANG=C.UTF-8
ENV LC_ALL=C.UTF-8
WORKDIR /home/rakazo WORKDIR /home/rakazo
# --chmod normalizes modes: a restrictive host umask (e.g. 077 on hardened hosts) would # --chmod normalizes modes: a restrictive host umask (e.g. 077 on hardened hosts) would
# otherwise leave these files unreadable to USER 1000 at runtime. # otherwise leave these files unreadable to USER 1000 at runtime.
@ -50,6 +55,8 @@ COPY --chmod=755 start.sh /usr/local/bin/rakazo-computer
COPY --chmod=755 control.py /usr/local/bin/rakazo-computer-control COPY --chmod=755 control.py /usr/local/bin/rakazo-computer-control
COPY --from=capture-builder /build/librakazo-xcapture.so /usr/local/lib/librakazo-xcapture.so COPY --from=capture-builder /build/librakazo-xcapture.so /usr/local/lib/librakazo-xcapture.so
COPY --chmod=755 rakazo-browser /usr/local/bin/rakazo-browser COPY --chmod=755 rakazo-browser /usr/local/bin/rakazo-browser
COPY --chmod=755 paste-text.sh /usr/local/bin/rakazo-paste
COPY --chmod=755 terminal.sh /usr/local/bin/rakazo-terminal
COPY --chmod=644 rakazo-browser.desktop /usr/share/applications/rakazo-browser.desktop COPY --chmod=644 rakazo-browser.desktop /usr/share/applications/rakazo-browser.desktop
COPY --chmod=644 fluxbox.init /etc/rakazo/fluxbox/init COPY --chmod=644 fluxbox.init /etc/rakazo/fluxbox/init
COPY --chmod=644 fluxbox.apps /etc/rakazo/fluxbox/apps COPY --chmod=644 fluxbox.apps /etc/rakazo/fluxbox/apps
@ -58,7 +65,7 @@ COPY --chmod=644 embed.html /usr/share/novnc/embed.html
COPY --chmod=644 clipboard-bridge.js /usr/share/novnc/clipboard-bridge.js COPY --chmod=644 clipboard-bridge.js /usr/share/novnc/clipboard-bridge.js
# Strip CR so shebangs work even if the build context came from a CRLF checkout. # Strip CR so shebangs work even if the build context came from a CRLF checkout.
RUN ldconfig \ RUN ldconfig \
&& sed -i 's/\r$//' /usr/local/bin/rakazo-computer /usr/local/bin/rakazo-computer-control /usr/local/bin/rakazo-browser \ && sed -i 's/\r$//' /usr/local/bin/rakazo-computer /usr/local/bin/rakazo-computer-control /usr/local/bin/rakazo-browser /usr/local/bin/rakazo-paste /usr/local/bin/rakazo-terminal \
/etc/rakazo/fluxbox/init /etc/rakazo/fluxbox/apps /etc/rakazo/fluxbox/menu /etc/rakazo/fluxbox/init /etc/rakazo/fluxbox/apps /etc/rakazo/fluxbox/menu
EXPOSE 7070 6080 6081 6082 6083 6084 6085 6086 6087 6088 6089 6090 6091 6092 6093 6094 6095 EXPOSE 7070 6080 6081 6082 6083 6084 6085 6086 6087 6088 6089 6090 6091 6092 6093 6094 6095
USER 1000:1000 USER 1000:1000

View File

@ -19,6 +19,7 @@ MAX_ARG_LEN = 16_384
KNOWN_LAUNCH = frozenset( KNOWN_LAUNCH = frozenset(
{ {
"rakazo-browser", "rakazo-browser",
"rakazo-terminal",
"xterm", "xterm",
} }
) )
@ -159,6 +160,8 @@ def allowed_control_argv(argv, display):
if argv[0] != "env" or argv[1] != f"DISPLAY={display}": if argv[0] != "env" or argv[1] != f"DISPLAY={display}":
return False return False
command = argv[2] command = argv[2]
if command == "rakazo-paste":
return len(argv) == 4
if command == "xdotool": if command == "xdotool":
return allowed_xdotool_argv(argv) return allowed_xdotool_argv(argv)
if command == "xdg-open": if command == "xdg-open":

View File

@ -1,4 +1,4 @@
[begin] (BangSo Bot) [begin] (BangSo Bot)
[exec] (Browser) {rakazo-browser} [exec] (Browser) {rakazo-browser}
[exec] (Terminal) {xterm -bg #111113 -fg #E8E8EA -cr #E8E8EA -title Terminal} [exec] (Terminal) {rakazo-terminal}
[end] [end]

View File

@ -0,0 +1,22 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ "$#" -ne 1 ]]; then
echo "usage: rakazo-paste TEXT" >&2
exit 2
fi
printf '%s' "$1" | xclip -selection clipboard -in
window_id="$(xdotool getactivewindow 2>/dev/null || true)"
window_class="$(xprop -id "$window_id" WM_CLASS 2>/dev/null || true)"
case "$window_class" in
*XTerm* | *xterm*)
printf '%s' "$1" | xclip -selection primary -in -loops 5
sleep 0.1
paste_key="shift+Insert"
;;
*) paste_key="ctrl+v" ;;
esac
xdotool key --clearmodifiers "$paste_key"

View File

@ -86,7 +86,7 @@ done
if [[ "$browser_up" -ne 1 ]]; then if [[ "$browser_up" -ne 1 ]]; then
echo "browser failed to start" >&2 echo "browser failed to start" >&2
cat /tmp/rakazo/browser.log >&2 || true cat /tmp/rakazo/browser.log >&2 || true
xterm -geometry 100x28+48+48 -bg "#111113" -fg "#E8E8EA" -cr "#E8E8EA" -title "Terminal" >/tmp/rakazo/xterm.log 2>&1 & rakazo-terminal >/tmp/rakazo/xterm.log 2>&1 &
fi fi
x11vnc -display :1 -forever -shared -viewonly -nopw -listen 127.0.0.1 -rfbport 5900 -xkb -ncache 0 >/tmp/rakazo/x11vnc.log 2>&1 & x11vnc -display :1 -forever -shared -viewonly -nopw -listen 127.0.0.1 -rfbport 5900 -xkb -ncache 0 >/tmp/rakazo/x11vnc.log 2>&1 &

View File

@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -euo pipefail
exec xterm \
-u8 \
-fa "DejaVu Sans Mono" \
-fs 14 \
-geometry 100x28+48+48 \
-bg "#111113" \
-fg "#E8E8EA" \
-cr "#E8E8EA" \
-title "Terminal" \
"$@"

View File

@ -127,6 +127,11 @@ describe("graphical computer spec", () => {
expect(dockerfile).toMatch(/chromium/); expect(dockerfile).toMatch(/chromium/);
expect(dockerfile).toMatch(/rakazo-browser\.desktop/); expect(dockerfile).toMatch(/rakazo-browser\.desktop/);
expect(dockerfile).toMatch(/control.py/); expect(dockerfile).toMatch(/control.py/);
expect(dockerfile).toMatch(/fonts-noto-cjk/);
expect(dockerfile).toMatch(/fonts-noto-color-emoji/);
expect(dockerfile).toMatch(/LANG=C\.UTF-8/);
expect(dockerfile).toMatch(/paste-text\.sh/);
expect(dockerfile).toMatch(/terminal\.sh/);
expect(dockerfile).toMatch(/USER 1000:1000/); expect(dockerfile).toMatch(/USER 1000:1000/);
expect(start).toMatch(/rakazo-computer-control/); expect(start).toMatch(/rakazo-computer-control/);
expect(start).toMatch(/rakazo-browser/); expect(start).toMatch(/rakazo-browser/);
@ -403,8 +408,10 @@ describe("graphical computer spec", () => {
"assert allow(['env', 'DISPLAY=:1', 'xdotool', 'mousemove', '--', '10', '20', 'click', '1'], ':1')", "assert allow(['env', 'DISPLAY=:1', 'xdotool', 'mousemove', '--', '10', '20', 'click', '1'], ':1')",
"assert allow(['env', 'DISPLAY=:1', 'xdotool', 'click', '--repeat', '3', '4'], ':1')", "assert allow(['env', 'DISPLAY=:1', 'xdotool', 'click', '--repeat', '3', '4'], ':1')",
"assert allow(['env', 'DISPLAY=:1', 'xdotool', 'type', '--clearmodifiers', '--', 'hi'], ':1')", "assert allow(['env', 'DISPLAY=:1', 'xdotool', 'type', '--clearmodifiers', '--', 'hi'], ':1')",
"assert allow(['env', 'DISPLAY=:1', 'rakazo-paste', '繁體中文🙂'], ':1')",
"assert allow(['env', 'DISPLAY=:2', 'xdg-open', 'https://example.com'], ':2')", "assert allow(['env', 'DISPLAY=:2', 'xdg-open', 'https://example.com'], ':2')",
"assert allow(['env', 'DISPLAY=:1', 'rakazo-browser'], ':1')", "assert allow(['env', 'DISPLAY=:1', 'rakazo-browser'], ':1')",
"assert allow(['env', 'DISPLAY=:1', 'rakazo-terminal'], ':1')",
"assert allow(['env', 'DISPLAY=:2', 'rakazo-browser', 'https://example.com'], ':2')", "assert allow(['env', 'DISPLAY=:2', 'rakazo-browser', 'https://example.com'], ':2')",
"assert allow(['env', 'DISPLAY=:1', 'xterm'], ':1')", "assert allow(['env', 'DISPLAY=:1', 'xterm'], ':1')",
"assert long_lived(['env', 'DISPLAY=:1', 'rakazo-browser'])", "assert long_lived(['env', 'DISPLAY=:1', 'rakazo-browser'])",
@ -421,6 +428,7 @@ describe("graphical computer spec", () => {
"assert not allow(['env', 'DISPLAY=:2', 'xdotool', 'key', '--clearmodifiers', 'a'], ':1')", "assert not allow(['env', 'DISPLAY=:2', 'xdotool', 'key', '--clearmodifiers', 'a'], ':1')",
"assert not allow(['env', 'DISPLAY=wayland-0', 'xdotool', 'key', '--clearmodifiers', 'a'], ':1')", "assert not allow(['env', 'DISPLAY=wayland-0', 'xdotool', 'key', '--clearmodifiers', 'a'], ':1')",
"assert not allow(['env', 'DISPLAY=:1', 'xdg-open', 'a', 'b'], ':1')", "assert not allow(['env', 'DISPLAY=:1', 'xdg-open', 'a', 'b'], ':1')",
"assert not allow(['env', 'DISPLAY=:1', 'rakazo-paste', 'one', 'two'], ':1')",
"known = set(module.KNOWN_LAUNCH)", "known = set(module.KNOWN_LAUNCH)",
"module.KNOWN_LAUNCH = frozenset({'sleep'})", "module.KNOWN_LAUNCH = frozenset({'sleep'})",
"spawned = []", "spawned = []",
@ -479,5 +487,9 @@ describe("graphical computer spec", () => {
"click", "click",
"1", "1",
]); ]);
expect(xdotoolCommand({ kind: "clipboard", text: "繁體中文🙂" })).toEqual([
"rakazo-paste",
"繁體中文🙂",
]);
}); });
}); });

View File

@ -88,6 +88,8 @@ export function containerCreateOptions(input: ComputerCreateInput) {
"PATH=/home/rakazo/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "PATH=/home/rakazo/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"NPM_CONFIG_PREFIX=/home/rakazo/.local", "NPM_CONFIG_PREFIX=/home/rakazo/.local",
"PIP_USER=1", "PIP_USER=1",
"LANG=C.UTF-8",
"LC_ALL=C.UTF-8",
...(input.controlToken ? [`RAKAZO_COMPUTER_CONTROL_TOKEN=${input.controlToken}`] : []), ...(input.controlToken ? [`RAKAZO_COMPUTER_CONTROL_TOKEN=${input.controlToken}`] : []),
], ],
Labels: { Labels: {
@ -215,7 +217,7 @@ export function xdotoolCommand(input: SandboxInput): string[] {
if (input.type === "up") return ["xdotool", "mouseup", btn]; if (input.type === "up") return ["xdotool", "mouseup", btn];
return ["xdotool", "mousemove", "--", String(input.x), String(input.y), "click", btn]; return ["xdotool", "mousemove", "--", String(input.x), String(input.y), "click", btn];
} }
return ["xdotool", "type", "--clearmodifiers", "--", input.text]; return ["rakazo-paste", input.text];
} }
function mapKey(key: string) { function mapKey(key: string) {

View File

@ -13,7 +13,11 @@ import {
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { assertComputerHomeWritable, assertOpenedDirectoryBeneathRoot } from "./home-ownership.js"; import {
assertComputerHomeWritable,
assertOpenedDirectoryBeneathRoot,
computerHomeValidationIdentity,
} from "./home-ownership.js";
const roots: string[] = []; const roots: string[] = [];
const DIRECTORY_OPEN_FLAGS = constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW; const DIRECTORY_OPEN_FLAGS = constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW;
@ -23,6 +27,17 @@ afterEach(async () => {
}); });
describe("computer home ownership", () => { describe("computer home ownership", () => {
it("uses the root supervisor identity for remapped desktop bind mounts", () => {
expect(computerHomeValidationIdentity(0, 0, 0, 0, 1000, 1000)).toEqual({ uid: 0, gid: 0 });
});
it("keeps the computer identity for native uid-preserving bind mounts", () => {
expect(computerHomeValidationIdentity(0, 0, 1000, 1000, 1000, 1000)).toEqual({
uid: 1000,
gid: 1000,
});
});
it("rejects a missing home instead of creating it as root", async () => { it("rejects a missing home instead of creating it as root", async () => {
const parent = await mkdtemp(path.join(tmpdir(), "rakazo-home-missing-")); const parent = await mkdtemp(path.join(tmpdir(), "rakazo-home-missing-"));
roots.push(parent); roots.push(parent);

View File

@ -2,6 +2,24 @@ import { constants, type Stats } from "node:fs";
import { type FileHandle, lstat, open, opendir, readlink } from "node:fs/promises"; import { type FileHandle, lstat, open, opendir, readlink } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
export function computerHomeValidationIdentity(
supervisorUid: number | undefined,
supervisorGid: number | undefined,
dataOwnerUid: number | undefined,
dataOwnerGid: number | undefined,
computerUid: number,
computerGid: number,
): { uid: number; gid: number } {
// Desktop VM bind mounts (including OrbStack) present host files as owned by
// the current container user. A root supervisor therefore sees uid 0 while
// the uid-1000 computer sees the same files as uid 1000. Validate the modes
// against the supervisor's mapped identity in that case.
if (supervisorUid === 0 && dataOwnerUid === 0) {
return { uid: supervisorUid, gid: dataOwnerGid ?? supervisorGid ?? 0 };
}
return { uid: computerUid, gid: computerGid };
}
const DIRECTORY_OPEN_FLAGS = constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW; const DIRECTORY_OPEN_FLAGS = constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW;
function hasPermissions(stat: Stats, uid: number, gid: number, required: number): boolean { function hasPermissions(stat: Stats, uid: number, gid: number, required: number): boolean {

View File

@ -230,6 +230,12 @@ describe("sandbox supervisor input containment", () => {
}); });
}); });
it("sends Unicode text through the sandbox clipboard helper", () => {
expect(containerActionStep({ kind: "clipboard", text: "中文輸入🙂" })).toEqual({
argv: ["env", "DISPLAY=:1", "rakazo-paste", "中文輸入🙂"],
});
});
it("routes Docker browser aliases through the safe wrapper on every display", () => { it("routes Docker browser aliases through the safe wrapper on every display", () => {
for (const application of DOCKER_BROWSER_ALIASES) { for (const application of DOCKER_BROWSER_ALIASES) {
expect( expect(
@ -239,7 +245,7 @@ describe("sandbox supervisor input containment", () => {
}); });
} }
expect(containerActionStep({ kind: "launch", application: "xterm" }, ":3")).toEqual({ expect(containerActionStep({ kind: "launch", application: "xterm" }, ":3")).toEqual({
argv: ["env", "DISPLAY=:3", "xterm"], argv: ["env", "DISPLAY=:3", "rakazo-terminal"],
}); });
expect(containerActionStep({ kind: "open", path: "https://example.com" }, ":3")).toEqual({ expect(containerActionStep({ kind: "open", path: "https://example.com" }, ":3")).toEqual({
argv: ["env", "DISPLAY=:3", "xdg-open", "https://example.com"], argv: ["env", "DISPLAY=:3", "xdg-open", "https://example.com"],
@ -255,7 +261,7 @@ describe("sandbox supervisor input containment", () => {
}); });
} }
expect(containerActionStep({ kind: "launch", application: "XTerm" }, ":3")).toEqual({ expect(containerActionStep({ kind: "launch", application: "XTerm" }, ":3")).toEqual({
argv: ["env", "DISPLAY=:3", "XTerm"], argv: ["env", "DISPLAY=:3", "rakazo-terminal"],
}); });
}); });

View File

@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { mkdir } from "node:fs/promises"; import { lstat, mkdir } from "node:fs/promises";
import http from "node:http"; import http from "node:http";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
@ -29,7 +29,7 @@ import {
screenUrlFor, screenUrlFor,
xdotoolCommand, xdotoolCommand,
} from "./computer-spec.js"; } from "./computer-spec.js";
import { assertComputerHomeWritable } from "./home-ownership.js"; import { assertComputerHomeWritable, computerHomeValidationIdentity } from "./home-ownership.js";
import { import {
assertRequestIdentity, assertRequestIdentity,
attemptComputerControl, attemptComputerControl,
@ -156,14 +156,27 @@ app.post("/computers", async (c) => {
// Before replacing or creating a container, validate its home without // Before replacing or creating a container, validate its home without
// privileged filesystem mutations that could escape via concurrent renames. // privileged filesystem mutations that could escape via concurrent renames.
// Match hostComputerUser(): missing/root host identity falls back to 1000:1000. // Match hostComputerUser(): missing/root host identity falls back to 1000:1000.
const effectiveUid = let effectiveUid =
runtimeInfo || hostUid === undefined || hostGid === undefined || hostUid === 0 runtimeInfo || hostUid === undefined || hostGid === undefined || hostUid === 0
? COMPUTER_UID ? COMPUTER_UID
: hostUid; : hostUid;
const effectiveGid = let effectiveGid =
runtimeInfo || hostUid === undefined || hostGid === undefined || hostUid === 0 runtimeInfo || hostUid === undefined || hostGid === undefined || hostUid === 0
? COMPUTER_GID ? COMPUTER_GID
: hostGid; : hostGid;
if (runtimeInfo) {
const dataStat = await lstat(dataDir);
const identity = computerHomeValidationIdentity(
hostUid,
hostGid,
dataStat.uid,
dataStat.gid,
effectiveUid,
effectiveGid,
);
effectiveUid = identity.uid;
effectiveGid = identity.gid;
}
await assertComputerHomeWritable(serviceHomePath, effectiveUid, effectiveGid); await assertComputerHomeWritable(serviceHomePath, effectiveUid, effectiveGid);
if (existing) { if (existing) {
await existing.remove({ force: true }).catch(() => undefined); await existing.remove({ force: true }).catch(() => undefined);
@ -621,6 +634,8 @@ async function ensureComputerImage() {
"control.py", "control.py",
"xcapture.c", "xcapture.c",
"rakazo-browser", "rakazo-browser",
"paste-text.sh",
"terminal.sh",
"rakazo-browser.desktop", "rakazo-browser.desktop",
"embed.html", "embed.html",
"clipboard-bridge.js", "clipboard-bridge.js",

View File

@ -40,6 +40,8 @@ export const DOCKER_BROWSER_ALIASES = new Set([
"rakazo-browser", "rakazo-browser",
]); ]);
export const DOCKER_TERMINAL_ALIASES = new Set(["terminal", "xterm", "rakazo-terminal"]);
export function assertRequestIdentity( export function assertRequestIdentity(
botId: string | undefined, botId: string | undefined,
spaceId: string | undefined, spaceId: string | undefined,
@ -320,8 +322,11 @@ export function containerActionStep(
: workspaceTarget(normalizeWorkspaceRelative(action.path)); : workspaceTarget(normalizeWorkspaceRelative(action.path));
argv = ["env", `DISPLAY=${display}`, "xdg-open", target]; argv = ["env", `DISPLAY=${display}`, "xdg-open", target];
} else { } else {
const application = DOCKER_BROWSER_ALIASES.has(action.application.toLowerCase()) const normalizedApplication = action.application.toLowerCase();
const application = DOCKER_BROWSER_ALIASES.has(normalizedApplication)
? "rakazo-browser" ? "rakazo-browser"
: DOCKER_TERMINAL_ALIASES.has(normalizedApplication)
? "rakazo-terminal"
: action.application; : action.application;
argv = ["env", `DISPLAY=${display}`, application, ...(action.uri ? [action.uri] : [])]; argv = ["env", `DISPLAY=${display}`, application, ...(action.uri ? [action.uri] : [])];
} }

View File

@ -203,13 +203,13 @@ export function avatarShapePath(shape: AvatarShape): string {
case "pill": case "pill":
return "M-48-26H48A26 26 0 0 1 48 26H-48A26 26 0 0 1-48-26Z"; return "M-48-26H48A26 26 0 0 1 48 26H-48A26 26 0 0 1-48-26Z";
case "triangle": case "triangle":
return "M0-52L52 46H-52Z"; return "M0-52C4-52 7-49 10-43L51 36C56 46 50 52 39 50H-39C-50 52-56 46-51 36L-10-43C-7-49-4-52 0-52Z";
case "hexagon": case "hexagon":
return "M-26-48L26-48L52 0L26 48L-26 48L-52 0Z"; return "M-23-49C-30-49-34-45-38-38L-52-9C-55-3-55 3-52 9L-38 38C-34 45-30 49-23 49H23C30 49 34 45 38 38L52 9C55 3 55-3 52-9L38-38C34-45 30-49 23-49Z";
case "cloud": case "cloud":
return "M-38 20C-52 20-56 4-44-6C-50-24-28-36-16-26C-8-42 20-42 28-24C46-30 62-8 50 10C62 14 58 28 42 28H-38Z"; return "M-37 39C-51 39-58 29-56 17C-55 8-49 1-40-3C-45-20-33-36-16-38C-8-51 14-54 27-41C34-34 38-26 38-18C52-16 60-5 59 8C68 16 63 33 50 37C45 40 40 41 34 40C25 48 10 50 0 44C-10 50-26 48-34 40C-35 40-36 40-37 39Z";
case "teardrop": case "teardrop":
return "M0-50C28-50 50-26 50 2C50 24 22 42 8 54L0 62L-8 54C-22 42-50 24-50 2C-50-26-28-50 0-50Z"; return "M0-52C28-52 50-28 50 0C50 22 23 39 9 50C4 54-4 54-9 50C-23 39-50 22-50 0C-50-28-28-52 0-52Z";
} }
} }
@ -229,7 +229,7 @@ export function avatarShapeClipCss(shape: AvatarShape): string | undefined {
case "hexagon": case "hexagon":
return "polygon(25% 6%, 75% 6%, 98% 50%, 75% 94%, 25% 94%, 2% 50%)"; return "polygon(25% 6%, 75% 6%, 98% 50%, 75% 94%, 25% 94%, 2% 50%)";
case "cloud": case "cloud":
return "polygon(16% 68%, 8% 52%, 16% 38%, 28% 28%, 40% 22%, 54% 18%, 70% 24%, 82% 36%, 90% 50%, 84% 64%, 70% 72%, 28% 72%)"; return "polygon(12% 82%, 4% 66%, 9% 48%, 20% 39%, 25% 23%, 40% 11%, 56% 16%, 67% 30%, 82% 31%, 95% 45%, 96% 63%, 87% 78%, 70% 84%, 54% 88%, 39% 86%, 27% 82%)";
case "teardrop": case "teardrop":
return "polygon(50% 4%, 90% 32%, 82% 78%, 50% 98%, 18% 78%, 10% 32%)"; return "polygon(50% 4%, 90% 32%, 82% 78%, 50% 98%, 18% 78%, 10% 32%)";
} }

View File

@ -86,6 +86,22 @@ describe("BotAvatar", () => {
} }
}); });
it("keeps cute working motion when a user selects a fixed shape", () => {
const html = renderToString(
<BotAvatar
color="#3EC5A8"
identity="cloud-bot"
status="running"
shape="cloud"
variant="organic"
/>,
);
expect(html).toContain('data-shape="cloud"');
expect(html).toMatch(/data-motion-family="\d"/);
expect(html).toContain("rakazo-organic-avatar-body-working");
});
it("clips a selected robot shape onto the visor face", () => { it("clips a selected robot shape onto the visor face", () => {
const html = renderToString( const html = renderToString(
<BotAvatar color="#3EC5A8" identity="chief" shape="hexagon" variant="robot" />, <BotAvatar color="#3EC5A8" identity="chief" shape="hexagon" variant="robot" />,
@ -131,6 +147,7 @@ describe("BotAvatar", () => {
); );
expect(idle).toContain("rakazo-organic-avatar-eyes-idle"); expect(idle).toContain("rakazo-organic-avatar-eyes-idle");
expect(idle).toContain("rakazo-organic-avatar-eyes-working"); expect(idle).toContain("rakazo-organic-avatar-eyes-working");
expect(working).toContain('data-gaze="upper-left"');
expect(idle).toContain("rakazo-organic-avatar-body-idle"); expect(idle).toContain("rakazo-organic-avatar-body-idle");
expect(idle).toContain("rakazo-organic-avatar-body-working"); expect(idle).toContain("rakazo-organic-avatar-body-working");
expect(readFileSync(new URL("./styles.css", import.meta.url), "utf8")).not.toMatch( expect(readFileSync(new URL("./styles.css", import.meta.url), "utf8")).not.toMatch(

View File

@ -262,6 +262,7 @@ function OrganicAvatar({
data-working={isWorking} data-working={isWorking}
data-shape={shape ?? undefined} data-shape={shape ?? undefined}
data-shape-family={shape ? undefined : seed % 10} data-shape-family={shape ? undefined : seed % 10}
data-motion-family={seed % 10}
data-eye-pattern={seed % 4} data-eye-pattern={seed % 4}
style={{ style={{
width: size, width: size,
@ -300,6 +301,7 @@ function OrganicAvatar({
<g <g
key={mode} key={mode}
className={`rakazo-organic-avatar-eyes rakazo-organic-avatar-eyes-${mode}`} className={`rakazo-organic-avatar-eyes rakazo-organic-avatar-eyes-${mode}`}
data-gaze={mode === "working" ? "upper-left" : undefined}
fill="#101014" fill="#101014"
> >
<rect x="-14" y="-12" width="7" height="24" rx="3.5" /> <rect x="-14" y="-12" width="7" height="24" rx="3.5" />

View File

@ -96,50 +96,39 @@
animation: rakazo-eyes-idle-3 6.8s ease-in-out -2.3s infinite; animation: rakazo-eyes-idle-3 6.8s ease-in-out -2.3s infinite;
} }
.rakazo-organic-avatar[data-eye-pattern="0"] .rakazo-organic-avatar-eyes-working { .rakazo-organic-avatar[data-eye-pattern] .rakazo-organic-avatar-eyes-working {
animation: rakazo-organic-eyes-loop-0 6.6s ease-in-out infinite; transform-origin: center;
animation: rakazo-organic-eyes-thinking 3.2s ease-in-out infinite;
} }
.rakazo-organic-avatar[data-eye-pattern="1"] .rakazo-organic-avatar-eyes-working { .rakazo-organic-avatar[data-motion-family="0"] .rakazo-organic-avatar-body-working {
animation: rakazo-organic-eyes-loop-1 7.2s ease-in-out -1.4s infinite;
}
.rakazo-organic-avatar[data-eye-pattern="2"] .rakazo-organic-avatar-eyes-working {
animation: rakazo-organic-eyes-loop-2 6.1s ease-in-out -3.2s infinite;
}
.rakazo-organic-avatar[data-eye-pattern="3"] .rakazo-organic-avatar-eyes-working {
animation: rakazo-organic-eyes-loop-3 7.8s ease-in-out -2.1s infinite;
}
.rakazo-organic-avatar[data-shape-family="0"] .rakazo-organic-avatar-body-working {
animation: rakazo-organic-float 1.8s ease-in-out infinite; animation: rakazo-organic-float 1.8s ease-in-out infinite;
} }
.rakazo-organic-avatar[data-shape-family="1"] .rakazo-organic-avatar-body-working { .rakazo-organic-avatar[data-motion-family="1"] .rakazo-organic-avatar-body-working {
animation: rakazo-organic-stretch 1.35s ease-in-out infinite; animation: rakazo-organic-stretch 1.35s ease-in-out infinite;
} }
.rakazo-organic-avatar[data-shape-family="2"] .rakazo-organic-avatar-body-working, .rakazo-organic-avatar[data-motion-family="2"] .rakazo-organic-avatar-body-working,
.rakazo-organic-avatar[data-shape-family="8"] .rakazo-organic-avatar-body-working { .rakazo-organic-avatar[data-motion-family="8"] .rakazo-organic-avatar-body-working {
animation: rakazo-organic-sway 1.6s ease-in-out infinite; animation: rakazo-organic-sway 1.6s ease-in-out infinite;
} }
.rakazo-organic-avatar[data-shape-family="3"] .rakazo-organic-avatar-body-working, .rakazo-organic-avatar[data-motion-family="3"] .rakazo-organic-avatar-body-working,
.rakazo-organic-avatar[data-shape-family="4"] .rakazo-organic-avatar-body-working { .rakazo-organic-avatar[data-motion-family="4"] .rakazo-organic-avatar-body-working {
animation: rakazo-organic-turn 2.4s ease-in-out infinite; animation: rakazo-organic-turn 2.4s ease-in-out infinite;
} }
.rakazo-organic-avatar[data-shape-family="5"] .rakazo-organic-avatar-body-working, .rakazo-organic-avatar[data-motion-family="5"] .rakazo-organic-avatar-body-working,
.rakazo-organic-avatar[data-shape-family="9"] .rakazo-organic-avatar-body-working { .rakazo-organic-avatar[data-motion-family="9"] .rakazo-organic-avatar-body-working {
animation: rakazo-organic-squash 1.35s ease-in-out infinite; animation: rakazo-organic-squash 1.35s ease-in-out infinite;
} }
.rakazo-organic-avatar[data-shape-family="6"] .rakazo-organic-avatar-body-working { .rakazo-organic-avatar[data-motion-family="6"] .rakazo-organic-avatar-body-working {
animation: rakazo-organic-pulse 1.1s ease-in-out infinite; animation: rakazo-organic-pulse 1.1s ease-in-out infinite;
} }
.rakazo-organic-avatar[data-shape-family="7"] .rakazo-organic-avatar-body-working { .rakazo-organic-avatar[data-motion-family="7"] .rakazo-organic-avatar-body-working {
transform-origin: center 20%; transform-origin: center 20%;
animation: rakazo-organic-ring 1.35s ease-in-out infinite; animation: rakazo-organic-ring 1.35s ease-in-out infinite;
} }
@ -227,106 +216,24 @@
} }
} }
@keyframes rakazo-organic-eyes-loop-0 { @keyframes rakazo-organic-eyes-thinking {
0%, 0%,
42%,
100% { 100% {
transform: translate(-9%, 1%) scaleY(0.94); transform: translate(-12%, -11%) rotate(-2deg) scaleY(1);
} }
18% { 52% {
transform: translate(9%, -2%) scaleY(1.04); transform: translate(-14%, -13%) rotate(-3deg) scale(1.04, 1.06);
} }
34% { 62%,
transform: translate(0, -8%) scale(1.08); 80% {
transform: translate(-10%, -9%) rotate(-1deg) scaleY(1);
} }
48% { 84% {
transform: scale(1.12, 0.82); transform: translate(-10%, -9%) rotate(-1deg) scaleY(0.08);
}
51% {
transform: scale(1.05, 0.08);
}
54% {
transform: scale(1.12, 0.9);
}
72% {
transform: translate(-5%, 7%) scaleY(0.9);
}
86% {
transform: translate(7%, 3%);
}
}
@keyframes rakazo-organic-eyes-loop-1 {
0%,
100% {
transform: translate(0, -8%) scale(1.06);
}
16% {
transform: translate(8%, 2%);
}
31% {
transform: translate(0, 8%) scaleY(0.9);
}
46% {
transform: translate(-8%, 2%);
}
61% {
transform: scale(0.88, 1.1);
}
76% {
transform: translateY(-7%) scaleY(1.08);
} }
88% { 88% {
transform: translateY(6%) scale(1.08, 0.86); transform: translate(-12%, -11%) rotate(-2deg) scaleY(1);
}
}
@keyframes rakazo-organic-eyes-loop-2 {
0%,
100% {
transform: scale(0.9, 1.08);
}
20% {
transform: scale(1.14, 0.84);
}
24% {
transform: scale(1.08, 0.08);
}
28% {
transform: scale(1.02);
}
45% {
transform: translate(-9%, -3%) scaleY(0.94);
}
62% {
transform: translate(9%, 2%) scaleY(1.05);
}
80% {
transform: translate(0, -8%) scale(1.06);
}
}
@keyframes rakazo-organic-eyes-loop-3 {
0%,
100% {
transform: translateY(7%) scaleY(0.9);
}
14% {
transform: translateY(-9%) scaleY(1.08);
}
30% {
transform: translate(8%, -2%);
}
47% {
transform: translate(-8%, 3%) scaleY(0.94);
}
63% {
transform: scale(1.12, 0.82);
}
78% {
transform: translate(0, -7%) scale(0.9, 1.08);
}
91% {
transform: translate(0, 5%) scale(1.06, 0.9);
} }
} }
@ -443,20 +350,19 @@
} }
@keyframes rakazo-eyes-working { @keyframes rakazo-eyes-working {
0% { 0%,
transform: translate(-4px, 0) scale(0.92, 0.95); 55%,
}
25% {
transform: translate(0, -2px) scale(1.08, 1.08);
}
50% {
transform: translate(4px, 0) scale(0.92, 0.95);
}
75% {
transform: translate(0, 2px) scale(1, 0.9);
}
100% { 100% {
transform: translate(-4px, 0) scale(0.92, 0.95); transform: translate(-3px, -2px) scale(1, 1.04);
}
68% {
transform: translate(-4px, -3px) scale(1.04, 1.08);
}
82% {
transform: translate(-3px, -2px) scaleY(0.08);
}
86% {
transform: translate(-3px, -2px) scaleY(1.04);
} }
} }
@ -475,7 +381,7 @@
transition: none; transition: none;
} }
.rakazo-organic-avatar[data-shape-family] .rakazo-organic-avatar-body { .rakazo-organic-avatar[data-motion-family] .rakazo-organic-avatar-body {
animation: none; animation: none;
d: var(--rakazo-organic-path); d: var(--rakazo-organic-path);
transition: none; transition: none;