fix avator
This commit is contained in:
parent
5674f1e8eb
commit
afe8149988
|
|
@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
<BotAvatar
|
<main
|
||||||
color="#D9508A"
|
style={{
|
||||||
identity="reduced-motion"
|
minHeight: "100vh",
|
||||||
size={120}
|
display: "grid",
|
||||||
status="running"
|
gridTemplateColumns: "repeat(4, 120px)",
|
||||||
variant="organic"
|
alignItems: "center",
|
||||||
/>,
|
justifyContent: "center",
|
||||||
|
gap: 28,
|
||||||
|
background: "#101012",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{AVATAR_SHAPES.map((shape) => (
|
||||||
|
<BotAvatar
|
||||||
|
key={shape}
|
||||||
|
color="#D9508A"
|
||||||
|
identity={`motion-${shape}`}
|
||||||
|
size={120}
|
||||||
|
shape={shape}
|
||||||
|
status="running"
|
||||||
|
variant="organic"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</main>,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 →</Trans>
|
<Trans>Sign in →</Trans>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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":
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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 &
|
||||||
|
|
|
||||||
|
|
@ -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" \
|
||||||
|
"$@"
|
||||||
|
|
@ -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",
|
||||||
|
"繁體中文🙂",
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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"],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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,9 +322,12 @@ 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"
|
||||||
: action.application;
|
: DOCKER_TERMINAL_ALIASES.has(normalizedApplication)
|
||||||
|
? "rakazo-terminal"
|
||||||
|
: action.application;
|
||||||
argv = ["env", `DISPLAY=${display}`, application, ...(action.uri ? [action.uri] : [])];
|
argv = ["env", `DISPLAY=${display}`, application, ...(action.uri ? [action.uri] : [])];
|
||||||
}
|
}
|
||||||
return { argv };
|
return { argv };
|
||||||
|
|
|
||||||
|
|
@ -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%)";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue