feat(web): WOW mobile-first onboarding look picker with swipe previews (#74)

* feat(web): WOW mobile-first onboarding look picker with swipe previews

Replace the form-style emoji step with category chips and a swipeable
look carousel. Each preset maps to existing color, shape, and pose
taxonomies; avatarValue still lands as color::shape on bot create.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* style(web): format onboarding look picker files

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
This commit is contained in:
cynarAI 2026-09-22 14:31:56 +02:00 committed by GitHub
parent 629346fe89
commit 1ed4868dcf
8 changed files with 673 additions and 272 deletions

View File

@ -1,9 +1,5 @@
import { expect, test } from "@playwright/test";
import {
captureScreenshot,
completeOnboarding,
signup,
} from "./helpers";
import { captureScreenshot, completeOnboarding, signup } from "./helpers";
/**
* E2E test for the new emoji-first onboarding flow:
@ -24,43 +20,29 @@ test("emoji-first onboarding: shape + color selection with live preview", async
await page.waitForURL(/\/(onboarding|app)/, { timeout: 20_000 });
// Step 1: Design your Oo
await expect(
page.getByRole("heading", { name: /Design your Oo/i }),
).toBeVisible();
await expect(page.getByRole("heading", { name: /Design your Oo/i })).toBeVisible();
if (testInfo) await captureScreenshot(page, testInfo, "01-design-your-oo");
// Live preview should show a BotAvatar
const preview = page.locator('[data-rakazo-surface="onboarding"]');
await expect(preview).toBeVisible();
await expect(page.getByTestId("onboarding-oo-designer")).toBeVisible();
await expect(page.getByTestId("onboarding-look-preview")).toBeVisible();
// Shape selection grid should have 8 options
const shapeButtons = page.locator('[aria-pressed]');
// Click the 4th shape (index 3)
const shapes = page.locator('[data-testid*="shape"] , button:has(svg)');
if (await shapes.count()) {
await shapes.nth(3).click();
}
await page.getByTestId("onboarding-look-category-work").click();
await page.getByRole("tab", { name: /Look 3 of/i }).click();
// Color selection: click the 3rd color swatch
const colorSwatches = page.locator("button[aria-label^=Color]");
if (await colorSwatches.count()) {
await colorSwatches.nth(2).click();
}
// Verify the preview updated (the BotAvatar should reflect the new color)
const botAvatar = page.locator('[data-rakazo-surface="onboarding"] svg');
const botAvatar = page.locator('[data-testid="onboarding-look-preview"] svg');
await expect(botAvatar).toBeVisible();
if (testInfo) await captureScreenshot(page, testInfo, "02-shape-color-preview");
// Click Continue → goes to preferences step
await page.getByRole("button", { name: /Continue/i }).first().click();
await page
.getByRole("button", { name: /Continue/i })
.first()
.click();
// Step 2: Tell us about yourself
await expect(
page.getByRole("heading", { name: /Tell us about yourself/i }),
).toBeVisible();
await expect(page.getByRole("heading", { name: /Tell us about yourself/i })).toBeVisible();
if (testInfo) await captureScreenshot(page, testInfo, "03-user-preferences");
@ -77,26 +59,27 @@ test("emoji-first onboarding: shape + color selection with live preview", async
}
// Select work style
const workStyleButtons = page.locator('button[aria-pressed]:visible');
if (await workStyleButtons.count() >= 2) {
const workStyleButtons = page.locator("button[aria-pressed]:visible");
if ((await workStyleButtons.count()) >= 2) {
await workStyleButtons.nth(0).click();
}
// Select communication style
const commButtons = page.locator('button[aria-pressed]:visible');
if (await commButtons.count() >= 2) {
const commButtons = page.locator("button[aria-pressed]:visible");
if ((await commButtons.count()) >= 2) {
await commButtons.nth(0).click();
}
if (testInfo) await captureScreenshot(page, testInfo, "04-preferences-filled");
// Click Continue → goes to language step
await page.getByRole("button", { name: /Continue/i }).first().click();
await page
.getByRole("button", { name: /Continue/i })
.first()
.click();
// Step 3: Language selection
await expect(
page.getByRole("heading", { name: /Choose your language/i }),
).toBeVisible();
await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible();
if (testInfo) await captureScreenshot(page, testInfo, "05-language-step");
@ -109,9 +92,10 @@ test("emoji-first onboarding: shape + color selection with live preview", async
const integrationsHeading = page.getByRole("heading", {
name: /Server integrations/i,
});
await integrationsHeading.or(chief).or(
page.getByText("Opening chat…"),
).waitFor({ timeout: 20_000 });
await integrationsHeading
.or(chief)
.or(page.getByText("Opening chat…"))
.waitFor({ timeout: 20_000 });
if (testInfo) await captureScreenshot(page, testInfo, "06-integrations-or-bot");
@ -126,30 +110,28 @@ test("emoji-first onboarding: shape + color selection with live preview", async
if (testInfo) await captureScreenshot(page, testInfo, "07-onboarding-complete");
});
test("onboarding: skip language defaults to English", async ({
page,
}, testInfo) => {
test("onboarding: skip language defaults to English", async ({ page }, testInfo) => {
const stamp = Date.now();
await signup(page, `lang-skip-${stamp}@rakazo.test`, "password12", "Skipper");
await page.waitForURL(/\/(onboarding|app)/, { timeout: 20_000 });
// Go through emoji step quickly (just click continue)
await expect(
page.getByRole("heading", { name: /Design your Oo/i }),
).toBeVisible();
await page.getByRole("button", { name: /Continue/i }).first().click();
await expect(page.getByRole("heading", { name: /Design your Oo/i })).toBeVisible();
await page
.getByRole("button", { name: /Continue/i })
.first()
.click();
// Preferences step — just continue
await expect(
page.getByRole("heading", { name: /Tell us about yourself/i }),
).toBeVisible();
await page.getByRole("button", { name: /Continue/i }).first().click();
await expect(page.getByRole("heading", { name: /Tell us about yourself/i })).toBeVisible();
await page
.getByRole("button", { name: /Continue/i })
.first()
.click();
// Language step — click Skip
await expect(
page.getByRole("heading", { name: /Choose your language/i }),
).toBeVisible();
await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible();
await page.getByRole("button", { name: /Skip/i }).click();
// Should land in bot chat

View File

@ -0,0 +1,66 @@
import type { Page } from "@playwright/test";
import { expect, test } from "@playwright/test";
import { captureScreenshot, signup } from "./helpers";
test.use({
hasTouch: true,
isMobile: true,
viewport: { width: 390, height: 844 },
});
async function swipeLook(page: Page, start: [number, number], end: [number, number]) {
await page.getByTestId("onboarding-look-carousel").evaluate(
(root, { start, end }) => {
function touch([clientX, clientY]: [number, number]) {
return new Touch({ clientX, clientY, identifier: 1, target: root });
}
const first = touch(start);
root.dispatchEvent(
new TouchEvent("touchstart", {
bubbles: true,
cancelable: true,
changedTouches: [first],
touches: [first],
}),
);
const last = touch(end);
root.dispatchEvent(
new TouchEvent("touchend", {
bubbles: true,
cancelable: true,
changedTouches: [last],
touches: [],
}),
);
},
{ start, end },
);
}
test("mobile onboarding: category pick and swipe look previews", async ({ page }, testInfo) => {
const stamp = Date.now();
await signup(page, `oo-swipe-${stamp}@rakazo.test`, "password12", "Swipe Tester");
await page.waitForURL(/\/(onboarding|app)/, { timeout: 20_000 });
await expect(page.getByTestId("onboarding-oo-designer")).toBeVisible();
await expect(page.getByRole("heading", { name: /Design your Oo/i })).toBeVisible();
await expect(page.getByTestId("onboarding-look-preview")).toBeVisible();
await page.getByTestId("onboarding-look-category-coder").click();
await expect(page.getByTestId("onboarding-look-category-coder")).toHaveAttribute(
"aria-selected",
"true",
);
const secondDot = page.getByRole("tab", { name: /Look 2 of/i });
await swipeLook(page, [280, 420], [120, 420]);
await expect(secondDot).toHaveAttribute("aria-selected", "true");
if (testInfo) await captureScreenshot(page, testInfo, "mobile-onboarding-oo-swipe");
await page
.getByRole("button", { name: /Continue/i })
.first()
.click();
await expect(page.getByRole("heading", { name: /Tell us about yourself/i })).toBeVisible();
});

View File

@ -0,0 +1,164 @@
import { Trans, useLingui } from "@lingui/react/macro";
import { Button } from "@rakazo/ui-web";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { type ReactNode, useCallback, useRef, useState } from "react";
import type { LookPreset } from "../../lib/onboarding-look-categories";
const SWIPE_THRESHOLD_PX = 48;
const SPRING_EASING = "cubic-bezier(0.34, 1.56, 0.64, 1)";
type LookPreviewCarouselProps = {
looks: LookPreset[];
activeIndex: number;
onIndexChange: (index: number) => void;
preview: ReactNode;
};
export function LookPreviewCarousel({
looks,
activeIndex,
onIndexChange,
preview,
}: LookPreviewCarouselProps) {
const { t } = useLingui();
const touchStartX = useRef<number | null>(null);
const [dragOffset, setDragOffset] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const clampIndex = useCallback(
(next: number) => Math.max(0, Math.min(looks.length - 1, next)),
[looks.length],
);
const goPrev = useCallback(() => {
onIndexChange(clampIndex(activeIndex - 1));
}, [activeIndex, clampIndex, onIndexChange]);
const goNext = useCallback(() => {
onIndexChange(clampIndex(activeIndex + 1));
}, [activeIndex, clampIndex, onIndexChange]);
const finishSwipe = useCallback(
(deltaX: number) => {
if (deltaX <= -SWIPE_THRESHOLD_PX) goNext();
else if (deltaX >= SWIPE_THRESHOLD_PX) goPrev();
setDragOffset(0);
setIsDragging(false);
touchStartX.current = null;
},
[goNext, goPrev],
);
return (
<div className="w-full" data-testid="onboarding-look-carousel">
<div
className="relative touch-pan-y"
onTouchStart={(event) => {
touchStartX.current = event.touches[0]?.clientX ?? null;
setIsDragging(true);
}}
onTouchMove={(event) => {
if (touchStartX.current === null) return;
const currentX = event.touches[0]?.clientX ?? touchStartX.current;
setDragOffset(currentX - touchStartX.current);
}}
onTouchEnd={() => {
if (touchStartX.current === null) return;
finishSwipe(dragOffset);
}}
onTouchCancel={() => finishSwipe(0)}
>
<div
className="mx-auto flex max-w-sm items-center justify-center"
style={{
transform: isDragging ? `translateX(${dragOffset * 0.35}px)` : undefined,
transition: isDragging ? "none" : `transform 420ms ${SPRING_EASING}`,
}}
>
<Button
type="button"
variant="ghost"
size="icon"
className="hidden shrink-0 md:inline-flex"
onClick={goPrev}
disabled={activeIndex === 0}
aria-label={t`Previous look`}
>
<ChevronLeft className="size-5" />
</Button>
<div
key={looks[activeIndex]?.id ?? activeIndex}
className="flex flex-1 flex-col items-center px-2"
data-testid="onboarding-look-preview"
style={{
animation: isDragging ? undefined : "onboarding-oo-pop 520ms ease-out",
}}
>
{preview}
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="hidden shrink-0 md:inline-flex"
onClick={goNext}
disabled={activeIndex >= looks.length - 1}
aria-label={t`Next look`}
>
<ChevronRight className="size-5" />
</Button>
</div>
</div>
<div className="mt-4 flex items-center justify-center gap-3 md:hidden">
<Button
type="button"
variant="outline"
size="icon-sm"
onClick={goPrev}
disabled={activeIndex === 0}
aria-label={t`Previous look`}
>
<ChevronLeft className="size-4" />
</Button>
<p className="text-xs text-muted-foreground">
<Trans>Swipe for more looks</Trans>
</p>
<Button
type="button"
variant="outline"
size="icon-sm"
onClick={goNext}
disabled={activeIndex >= looks.length - 1}
aria-label={t`Next look`}
>
<ChevronRight className="size-4" />
</Button>
</div>
<div
className="mt-3 flex justify-center gap-1.5"
role="tablist"
aria-label={t`Look variants`}
>
{looks.map((look, index) => (
<button
key={look.id}
type="button"
role="tab"
aria-selected={index === activeIndex}
aria-label={t`Look ${index + 1} of ${looks.length}`}
onClick={() => onIndexChange(index)}
className={`h-1.5 rounded-full transition-all duration-300 ${
index === activeIndex
? "w-6 bg-primary"
: "w-1.5 bg-muted-foreground/40 hover:bg-muted-foreground/70"
}`}
/>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,186 @@
import { Trans, useLingui } from "@lingui/react/macro";
import { BotAvatar, Button } from "@rakazo/ui-web";
import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import {
findLookCategory,
LOOK_CATEGORIES,
type LookCategoryId,
type LookPreset,
lookPresetToAvatarValue,
} from "../../lib/onboarding-look-categories";
import { LookPreviewCarousel } from "./LookPreviewCarousel";
export interface OnboardingEmojiConfig {
color: string;
shapeIndex: number;
accessory: string;
pose: string;
avatarValue: string;
}
type OnboardingOoDesignerProps = {
config: OnboardingEmojiConfig;
onChange: (config: OnboardingEmojiConfig) => void;
onContinue: () => void;
};
function categoryLabel(id: LookCategoryId): ReactNode {
switch (id) {
case "work":
return <Trans>Work</Trans>;
case "hobby":
return <Trans>Hobby</Trans>;
case "scene":
return <Trans>Scene</Trans>;
case "coder":
return <Trans>Coder</Trans>;
case "calm":
return <Trans>Calm helper</Trans>;
default: {
const never: never = id;
return never;
}
}
}
function presetFromConfig(config: OnboardingEmojiConfig): LookPreset {
return {
id: "custom",
color: config.color,
shapeIndex: config.shapeIndex,
pose: config.pose as LookPreset["pose"],
};
}
function configFromPreset(preset: LookPreset): OnboardingEmojiConfig {
return {
color: preset.color,
shapeIndex: preset.shapeIndex,
accessory: "none",
pose: preset.pose,
avatarValue: lookPresetToAvatarValue(preset),
};
}
export function OnboardingOoDesigner({ config, onChange, onContinue }: OnboardingOoDesignerProps) {
const { t } = useLingui();
const [categoryId, setCategoryId] = useState<LookCategoryId>("calm");
const category = useMemo(() => findLookCategory(categoryId), [categoryId]);
const [lookIndex, setLookIndex] = useState(0);
const syncCategorySelection = useCallback(
(nextCategoryId: LookCategoryId, index = 0) => {
const nextCategory = findLookCategory(nextCategoryId);
const preset = nextCategory.looks[index] ?? nextCategory.looks[0];
if (!preset) return;
setCategoryId(nextCategoryId);
setLookIndex(index);
onChange(configFromPreset(preset));
},
[onChange],
);
useEffect(() => {
const match = LOOK_CATEGORIES.find((entry) =>
entry.looks.some(
(look) =>
look.color.toLowerCase() === config.color.toLowerCase() &&
look.shapeIndex === config.shapeIndex &&
look.pose === config.pose,
),
);
if (match) {
const index = match.looks.findIndex(
(look) =>
look.color.toLowerCase() === config.color.toLowerCase() &&
look.shapeIndex === config.shapeIndex &&
look.pose === config.pose,
);
setCategoryId(match.id);
setLookIndex(index >= 0 ? index : 0);
}
}, []);
const activeLook = category.looks[lookIndex] ?? presetFromConfig(config);
const handleLookIndexChange = useCallback(
(index: number) => {
const preset = category.looks[index];
if (!preset) return;
setLookIndex(index);
onChange(configFromPreset(preset));
},
[category.looks, onChange],
);
function resetLook() {
syncCategorySelection("calm", 0);
}
return (
<div className="mx-auto max-w-xl" data-testid="onboarding-oo-designer">
<h2 className="text-xl font-semibold tracking-tight text-foreground">
<Trans>Design your Oo</Trans>
</h2>
<p className="mt-1 text-sm text-muted-foreground">
<Trans>Pick a vibe, swipe through looks, and watch your Oo come alive.</Trans>
</p>
<LookPreviewCarousel
looks={category.looks}
activeIndex={lookIndex}
onIndexChange={handleLookIndexChange}
preview={
<BotAvatar
color={lookPresetToAvatarValue(activeLook)}
size={168}
identity="onboarding-preview"
pose={activeLook.pose}
/>
}
/>
<div className="mt-8">
<p className="text-sm font-medium text-foreground">
<Trans>What feels like you?</Trans>
</p>
<div
className="mt-3 flex gap-2 overflow-x-auto pb-1 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
role="tablist"
aria-label={t`Look families`}
>
{LOOK_CATEGORIES.map((entry) => {
const selected = entry.id === categoryId;
return (
<button
key={entry.id}
type="button"
role="tab"
aria-selected={selected}
data-testid={`onboarding-look-category-${entry.id}`}
onClick={() => syncCategorySelection(entry.id, 0)}
className={`shrink-0 rounded-full border px-4 py-2 text-sm font-medium transition-all duration-300 ${
selected
? "border-primary bg-primary text-primary-foreground shadow-sm scale-[1.02]"
: "border-border bg-card text-muted-foreground hover:border-muted hover:bg-muted active:scale-95"
}`}
style={{ transitionTimingFunction: "cubic-bezier(0.34, 1.56, 0.64, 1)" }}
>
{categoryLabel(entry.id)}
</button>
);
})}
</div>
</div>
<div className="mt-8 flex items-center justify-between">
<Button variant="ghost" size="sm" onClick={resetLook}>
<Trans>Reset</Trans>
</Button>
<Button onClick={onContinue}>
<Trans>Continue</Trans>
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import {
LOOK_CATEGORIES,
buildAvatarValue,
findLookCategory,
lookPresetToAvatarValue,
} from "./onboarding-look-categories";
describe("onboarding-look-categories", () => {
it("defines five curated families with at least three looks each", () => {
expect(LOOK_CATEGORIES).toHaveLength(5);
for (const category of LOOK_CATEGORIES) {
expect(category.looks.length).toBeGreaterThanOrEqual(3);
}
});
it("builds avatar values in color::shape format", () => {
const look = findLookCategory("coder").looks[0]!;
expect(lookPresetToAvatarValue(look)).toBe(buildAvatarValue(look.color, look.shapeIndex));
expect(buildAvatarValue("#8B5CF6", 2)).toBe("#8B5CF6::shape_2");
});
it("falls back to the first category for unknown ids", () => {
expect(findLookCategory("work" as const).id).toBe("work");
});
});

View File

@ -0,0 +1,82 @@
import type { EvooPose } from "@rakazo/ui-web";
export type LookCategoryId = "work" | "hobby" | "scene" | "coder" | "calm";
export interface LookPreset {
id: string;
color: string;
shapeIndex: number;
pose: EvooPose;
}
export interface LookCategory {
id: LookCategoryId;
looks: LookPreset[];
}
/** Curated look families built from existing shape, color, and pose taxonomies. */
export const LOOK_CATEGORIES: LookCategory[] = [
{
id: "work",
looks: [
{ id: "work-violet-hex", color: "#8B5CF6", shapeIndex: 0, pose: "working" },
{ id: "work-blue-tablet", color: "#3B82F6", shapeIndex: 3, pose: "analyzing" },
{ id: "work-white-squircle", color: "#FFFFFF", shapeIndex: 2, pose: "listening" },
{ id: "work-green-wedge", color: "#10B981", shapeIndex: 1, pose: "reading" },
],
},
{
id: "hobby",
looks: [
{ id: "hobby-orange-blob", color: "#F97316", shapeIndex: 5, pose: "delighted" },
{ id: "hobby-yellow-pebble", color: "#EAB308", shapeIndex: 4, pose: "curious" },
{ id: "hobby-red-teardrop", color: "#EF4444", shapeIndex: 6, pose: "greeting" },
{ id: "hobby-cyan-cloud", color: "#06B6D4", shapeIndex: 7, pose: "speaking" },
],
},
{
id: "scene",
looks: [
{ id: "scene-red-wedge", color: "#EF4444", shapeIndex: 1, pose: "speaking" },
{ id: "scene-violet-teardrop", color: "#8B5CF6", shapeIndex: 6, pose: "delighted" },
{ id: "scene-cyan-hex", color: "#06B6D4", shapeIndex: 0, pose: "collaborating" },
{ id: "scene-orange-squircle", color: "#F97316", shapeIndex: 2, pose: "success" },
],
},
{
id: "coder",
looks: [
{ id: "coder-cyan-hex", color: "#06B6D4", shapeIndex: 0, pose: "working" },
{ id: "coder-green-squircle", color: "#10B981", shapeIndex: 2, pose: "analyzing" },
{ id: "coder-blue-tablet", color: "#3B82F6", shapeIndex: 3, pose: "thinking" },
{ id: "coder-violet-pebble", color: "#8B5CF6", shapeIndex: 4, pose: "collaborating" },
],
},
{
id: "calm",
looks: [
{ id: "calm-green-cloud", color: "#10B981", shapeIndex: 7, pose: "greeting" },
{ id: "calm-violet-blob", color: "#8B5CF6", shapeIndex: 5, pose: "listening" },
{ id: "calm-white-pebble", color: "#FFFFFF", shapeIndex: 4, pose: "idle" },
{ id: "calm-cyan-squircle", color: "#06B6D4", shapeIndex: 2, pose: "curious" },
],
},
];
export function findLookCategory(id: LookCategoryId): LookCategory {
const category = LOOK_CATEGORIES.find((entry) => entry.id === id);
if (!category) {
const fallback = LOOK_CATEGORIES[0];
if (!fallback) throw new Error("LOOK_CATEGORIES is empty");
return fallback;
}
return category;
}
export function buildAvatarValue(color: string, shapeIndex: number): string {
return `${color}::shape_${shapeIndex}`;
}
export function lookPresetToAvatarValue(look: LookPreset): string {
return buildAvatarValue(look.color, look.shapeIndex);
}

View File

@ -1,31 +1,24 @@
import { Trans, useLingui } from "@lingui/react/macro";
import type { IntegrationSetupState, Me } from "@rakazo/contracts";
import {
BotAvatar,
GROK_COLOR_LIST,
GrokShapePreview,
SHIPPED_SHAPE_KEYS,
Button,
Input,
} from "@rakazo/ui-web";
import type { GrokColorDef } from "@rakazo/ui-web";
import { Button, Input } from "@rakazo/ui-web";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { IntegrationSetup } from "../components/integrations/IntegrationSetup";
import { OnboardingDiscoveryShell } from "../components/onboarding/OnboardingDiscoveryShell";
import {
type OnboardingEmojiConfig,
OnboardingOoDesigner,
} from "../components/onboarding/OnboardingOoDesigner";
import {
computeDiscoveryItems,
countConfiguredIntegrations,
type OnboardingDiscoveryPhase,
} from "../lib/onboarding-discovery";
import { findLookCategory, lookPresetToAvatarValue } from "../lib/onboarding-look-categories";
import { rpc } from "../lib/rpc";
import { persistUiLocale, UI_LOCALE_LABELS, type UiLocale } from "../lib/ui-locale";
import { inferHelpVibeHints, type UserHelpVibeHints } from "../lib/user-help-vibe";
import { collectLocalProfileSignals, persistUserProfileHarvest } from "../lib/user-profile-harvest";
import {
UI_LOCALE_LABELS,
persistUiLocale,
type UiLocale,
} from "../lib/ui-locale";
const FIRST_BOT_NAME = "Chief";
const FIRST_BOT_SPAWN_KEY = "onboarding:first";
@ -79,49 +72,23 @@ async function ensureFirstBot(avatarColor?: string): Promise<{ id: string }> {
return firstBotEnsure;
}
interface EmojiConfig {
color: string;
shapeIndex: number;
accessory: string;
pose: string;
avatarValue: string;
}
const SHAPE_OPTIONS = SHIPPED_SHAPE_KEYS.map((key) => ({
key,
label: key.charAt(0).toUpperCase() + key.slice(1),
}));
const ACCESSORY_OPTIONS = [
{ key: "none", label: <Trans>None</Trans> },
{ key: "headset", label: <Trans>Headset</Trans> },
{ key: "glasses", label: <Trans>Glasses</Trans> },
{ key: "clipboard", label: <Trans>Clipboard</Trans> },
{ key: "spark", label: <Trans>Spark</Trans> },
{ key: "tool", label: <Trans>Tool</Trans> },
{ key: "magnifier", label: <Trans>Magnifier</Trans> },
{ key: "thought", label: <Trans>Thought</Trans> },
{ key: "zzz", label: <Trans>Zzz</Trans> },
];
const POSE_OPTIONS = [
{ key: "idle", label: <Trans>Idle</Trans> },
{ key: "greeting", label: <Trans>Greeting</Trans> },
{ key: "thinking", label: <Trans>Thinking</Trans> },
{ key: "working", label: <Trans>Working</Trans> },
{ key: "listening", label: <Trans>Listening</Trans> },
{ key: "speaking", label: <Trans>Speaking</Trans> },
{ key: "success", label: <Trans>Success</Trans> },
{ key: "curious", label: <Trans>Curious</Trans> },
];
function defaultEmojiConfig(): EmojiConfig {
function defaultEmojiConfig(): OnboardingEmojiConfig {
const preset = findLookCategory("calm").looks[0];
if (!preset) {
return {
color: "#8B5CF6",
shapeIndex: 0,
accessory: "none",
pose: "idle",
avatarValue: "#8B5CF6::shape_0",
};
}
return {
color: "#8B5CF6",
shapeIndex: 0,
color: preset.color,
shapeIndex: preset.shapeIndex,
accessory: "none",
pose: "idle",
avatarValue: "#8B5CF6::shape_0",
pose: preset.pose,
avatarValue: lookPresetToAvatarValue(preset),
};
}
@ -186,19 +153,18 @@ export function OnboardingPage() {
const [crewReady, setCrewReady] = useState(false);
const [helpVibe, setHelpVibe] = useState<UserHelpVibeHints | null>(null);
const [emojiConfig, setEmojiConfig] = useState<EmojiConfig>(defaultEmojiConfig);
const [previewColor, setPreviewColor] = useState(emojiConfig.color);
const [previewShape, setPreviewShape] = useState(emojiConfig.shapeIndex);
const [emojiConfig, setEmojiConfig] = useState<OnboardingEmojiConfig>(defaultEmojiConfig);
const [prefs, setPrefs] = useState<UserPreferences>(defaultPreferences());
const [currentLocale, setCurrentLocale] = useState<string>(() => {
try { return localStorage.getItem("rakazo.uiLocale") ?? "en"; } catch { return "en"; }
try {
return localStorage.getItem("rakazo.uiLocale") ?? "en";
} catch {
return "en";
}
});
useEffect(() => {
void Promise.all([
rpc.me(),
rpc.integrationSetup.get().catch(() => null),
])
void Promise.all([rpc.me(), rpc.integrationSetup.get().catch(() => null)])
.then(([me, integrations]) => {
setBootstrapMe(me);
setHelpVibe(inferHelpVibeHints(collectLocalProfileSignals(me)));
@ -208,38 +174,6 @@ export function OnboardingPage() {
.catch(() => setStep("emoji"));
}, []);
const updateAvatarValue = (config: EmojiConfig) => {
const newValue = `${config.color}::shape_${config.shapeIndex}`;
setEmojiConfig({ ...config, avatarValue: newValue });
};
function selectColor(color: string) {
setPreviewColor(color);
const next = { ...emojiConfig, color };
updateAvatarValue(next);
}
function selectShape(shapeIndex: number) {
setPreviewShape(shapeIndex);
const next = { ...emojiConfig, shapeIndex };
updateAvatarValue(next);
}
function selectAccessory(key: string) {
setEmojiConfig({ ...emojiConfig, accessory: key });
}
function selectPose(key: string) {
setEmojiConfig({ ...emojiConfig, pose: key });
}
function resetEmoji() {
const def = defaultEmojiConfig();
setEmojiConfig(def);
setPreviewColor(def.color);
setPreviewShape(def.shapeIndex);
}
function toggleHobby(key: string) {
const hobbies = prefs.hobbies.includes(key)
? prefs.hobbies.filter((h) => h !== key)
@ -269,7 +203,14 @@ export function OnboardingPage() {
profilePersisted,
crewReady,
}),
[bootstrapMe, crewReady, discoveryPhase, integrationServers.length, integrationSetup, profilePersisted],
[
bootstrapMe,
crewReady,
discoveryPhase,
integrationServers.length,
integrationSetup,
profilePersisted,
],
);
async function createFirstBot() {
@ -286,22 +227,25 @@ export function OnboardingPage() {
if (prefs.hobbies.length > 0) ctxParts.push(`Hobbies: ${prefs.hobbies.join(", ")}`);
if (prefs.workStyle) ctxParts.push(`Work style: ${prefs.workStyle}`);
if (prefs.communication) ctxParts.push(`Communication: ${prefs.communication}`);
const userCtx = ctxParts.length > 0
? `## User Context\n${ctxParts.map((p) => `- ${p}`).join("\n")}`
: "";
const userCtx =
ctxParts.length > 0 ? `## User Context\n${ctxParts.map((p) => `- ${p}`).join("\n")}` : "";
await rpc.bots.update({ botId: bot.id, instructions: userCtx }).catch(() => null);
const me = await rpc.me().catch(() => null);
if (me) setBootstrapMe(me);
const harvested = await persistUserProfileHarvest(
rpc, collectLocalProfileSignals(me ?? undefined),
rpc,
collectLocalProfileSignals(me ?? undefined),
).catch(() => null);
if (harvested) setHelpVibe(harvested);
setProfilePersisted(true);
for (const serverId of integrationServers) {
await rpc.mcp.assignments.approve({ botId: bot.id, serverId });
}
const started = await rpc.onboarding.start({ botId: bot.id }).then(() => true).catch(() => false);
const started = await rpc.onboarding
.start({ botId: bot.id })
.then(() => true)
.catch(() => false);
if (started) {
await rpc.onboarding.promptFocus({ botId: bot.id }).catch(() => undefined);
}
@ -323,113 +267,22 @@ export function OnboardingPage() {
<div className="min-h-full bg-background px-6 py-12" data-rakazo-surface="onboarding">
<OnboardingDiscoveryShell
items={discoveryItems}
compact={step === "emoji" || step === "preferences" || step === "language" || step === "integrations"}
compact={
step === "emoji" ||
step === "preferences" ||
step === "language" ||
step === "integrations"
}
>
{error && step !== "bot" ? (
<p className="mb-4 text-sm text-destructive">{error}</p>
) : null}
{error && step !== "bot" ? <p className="mb-4 text-sm text-destructive">{error}</p> : null}
{/* Step: Emoji Configuration */}
{step === "emoji" ? (
<div className="mx-auto max-w-xl">
<h2 className="text-xl font-semibold tracking-tight text-foreground">
<Trans>Design your Oo</Trans>
</h2>
<p className="mt-1 text-sm text-muted-foreground">
<Trans>Give your Oo a look that feels like you — shape, color, accessories.</Trans>
</p>
<div className="mt-8 flex justify-center">
<BotAvatar color={previewColor} size={140} identity="onboarding-preview" pose={emojiConfig.pose as any} />
</div>
{/* Shape */}
<div className="mt-8">
<label className="block text-sm font-medium text-foreground"><Trans>Shape</Trans></label>
<div className="mt-3 grid grid-cols-4 gap-3">
{SHAPE_OPTIONS.map((shape, idx) => (
<GrokShapePreview
key={shape.key}
shapeIndex={idx}
color={previewColor}
selected={previewShape === idx}
onClick={() => selectShape(idx)}
/>
))}
</div>
</div>
{/* Color */}
<div className="mt-6">
<label className="block text-sm font-medium text-foreground"><Trans>Color</Trans></label>
<div className="mt-3 flex flex-wrap gap-3">
{GROK_COLOR_LIST.map((colorDef: GrokColorDef) => (
<button
key={colorDef.id}
type="button"
onClick={() => selectColor(colorDef.hex)}
aria-label={`Color ${colorDef.name}`}
aria-pressed={previewColor.toLowerCase() === colorDef.hex.toLowerCase()}
className={`h-10 w-10 rounded-full border-2 transition-all hover:scale-110 active:scale-95 ${
previewColor.toLowerCase() === colorDef.hex.toLowerCase()
? "border-primary ring-2 ring-primary ring-offset-2 ring-offset-background scale-105"
: "border-border"
}`}
style={{ backgroundColor: colorDef.hex }}
/>
))}
</div>
</div>
{/* Accessories */}
<div className="mt-6">
<label className="block text-sm font-medium text-foreground"><Trans>Accessory</Trans></label>
<div className="mt-3 flex flex-wrap gap-2">
{ACCESSORY_OPTIONS.map((acc) => (
<button
key={acc.key}
type="button"
onClick={() => selectAccessory(acc.key)}
aria-pressed={emojiConfig.accessory === acc.key}
className={`rounded-full border px-4 py-1.5 text-sm font-medium transition-all ${
emojiConfig.accessory === acc.key
? "border-primary bg-primary/10 text-foreground"
: "border-border bg-card text-muted-foreground hover:border-muted hover:bg-muted"
}`}
>
{acc.label}
</button>
))}
</div>
</div>
{/* Pose */}
<div className="mt-6">
<label className="block text-sm font-medium text-foreground"><Trans>Pose</Trans></label>
<div className="mt-3 flex flex-wrap gap-2">
{POSE_OPTIONS.map((pose) => (
<button
key={pose.key}
type="button"
onClick={() => selectPose(pose.key)}
aria-pressed={emojiConfig.pose === pose.key}
className={`rounded-full border px-4 py-1.5 text-sm font-medium transition-all ${
emojiConfig.pose === pose.key
? "border-primary bg-primary/10 text-foreground"
: "border-border bg-card text-muted-foreground hover:border-muted hover:bg-muted"
}`}
>
{pose.label}
</button>
))}
</div>
</div>
<div className="mt-8 flex items-center justify-between">
<Button variant="ghost" size="sm" onClick={resetEmoji}><Trans>Reset</Trans></Button>
<Button onClick={() => setStep("preferences")}><Trans>Continue</Trans></Button>
</div>
</div>
<OnboardingOoDesigner
config={emojiConfig}
onChange={setEmojiConfig}
onContinue={() => setStep("preferences")}
/>
) : null}
{/* Step: Preferences */}
@ -443,7 +296,10 @@ export function OnboardingPage() {
</p>
<div className="mt-6">
<label htmlFor={`${fieldId}-name`} className="block text-sm font-medium text-foreground">
<label
htmlFor={`${fieldId}-name`}
className="block text-sm font-medium text-foreground"
>
<Trans>Name</Trans>
</label>
<Input
@ -456,7 +312,9 @@ export function OnboardingPage() {
</div>
<div className="mt-6">
<label className="block text-sm font-medium text-foreground"><Trans>Timezone</Trans></label>
<label className="block text-sm font-medium text-foreground">
<Trans>Timezone</Trans>
</label>
<Input
value={prefs.timezone}
onChange={(e) => setPrefs({ ...prefs, timezone: e.target.value })}
@ -466,8 +324,12 @@ export function OnboardingPage() {
</div>
<div className="mt-6">
<label className="block text-sm font-medium text-foreground"><Trans>Hobbies & interests</Trans></label>
<p className="mt-1 text-xs text-muted-foreground"><Trans>Select all that apply</Trans></p>
<label className="block text-sm font-medium text-foreground">
<Trans>Hobbies & interests</Trans>
</label>
<p className="mt-1 text-xs text-muted-foreground">
<Trans>Select all that apply</Trans>
</p>
<div className="mt-3 flex flex-wrap gap-2">
{HOBBY_OPTIONS.map((hobby) => (
<button
@ -488,8 +350,12 @@ export function OnboardingPage() {
</div>
<div className="mt-6">
<label className="block text-sm font-medium text-foreground"><Trans>Work style</Trans></label>
<p className="mt-1 text-xs text-muted-foreground"><Trans>How do you like to work?</Trans></p>
<label className="block text-sm font-medium text-foreground">
<Trans>Work style</Trans>
</label>
<p className="mt-1 text-xs text-muted-foreground">
<Trans>How do you like to work?</Trans>
</p>
<div className="mt-3 grid grid-cols-2 gap-2">
{WORK_STYLE_OPTIONS.map((ws) => (
<button
@ -510,8 +376,12 @@ export function OnboardingPage() {
</div>
<div className="mt-6">
<label className="block text-sm font-medium text-foreground"><Trans>Communication style</Trans></label>
<p className="mt-1 text-xs text-muted-foreground"><Trans>How should your Oo talk to you?</Trans></p>
<label className="block text-sm font-medium text-foreground">
<Trans>Communication style</Trans>
</label>
<p className="mt-1 text-xs text-muted-foreground">
<Trans>How should your Oo talk to you?</Trans>
</p>
<div className="mt-3 grid grid-cols-2 gap-2">
{COMMUNICATION_OPTIONS.map((comm) => (
<button
@ -532,8 +402,12 @@ export function OnboardingPage() {
</div>
<div className="mt-8 flex items-center justify-between">
<Button variant="ghost" size="sm" onClick={() => setStep("emoji")}><Trans>Back</Trans></Button>
<Button onClick={() => setStep("language")}><Trans>Continue</Trans></Button>
<Button variant="ghost" size="sm" onClick={() => setStep("emoji")}>
<Trans>Back</Trans>
</Button>
<Button onClick={() => setStep("language")}>
<Trans>Continue</Trans>
</Button>
</div>
</div>
) : null}
@ -568,8 +442,12 @@ export function OnboardingPage() {
))}
</div>
<div className="mt-8 flex items-center justify-between">
<Button variant="ghost" size="sm" onClick={() => setStep("preferences")}><Trans>Back</Trans></Button>
<Button onClick={() => setStep(needsIntegrationSetup ? "integrations" : "bot")}><Trans>Skip · English</Trans></Button>
<Button variant="ghost" size="sm" onClick={() => setStep("preferences")}>
<Trans>Back</Trans>
</Button>
<Button onClick={() => setStep(needsIntegrationSetup ? "integrations" : "bot")}>
<Trans>Skip · English</Trans>
</Button>
</div>
</div>
) : null}
@ -595,7 +473,9 @@ export function OnboardingPage() {
{step === "bot" && error ? (
<div className="text-center">
<p className="text-sm text-destructive">{error}</p>
<Button className="mt-4" onClick={() => void createFirstBot()}><Trans>Try again</Trans></Button>
<Button className="mt-4" onClick={() => void createFirstBot()}>
<Trans>Try again</Trans>
</Button>
</div>
) : null}
</OnboardingDiscoveryShell>

View File

@ -389,6 +389,21 @@ body,
}
}
@keyframes onboarding-oo-pop {
0% {
transform: scale(0.92);
opacity: 0.75;
}
55% {
transform: scale(1.04);
opacity: 1;
}
100% {
transform: scale(1);
opacity: 1;
}
}
@keyframes voiceGlassRing {
0%,
100% {