diff --git a/apps/web/e2e/onboarding-emoji-first.spec.ts b/apps/web/e2e/onboarding-emoji-first.spec.ts index e8302d5..e90bef2 100644 --- a/apps/web/e2e/onboarding-emoji-first.spec.ts +++ b/apps/web/e2e/onboarding-emoji-first.spec.ts @@ -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 diff --git a/apps/web/e2e/onboarding-oo-swipe.spec.ts b/apps/web/e2e/onboarding-oo-swipe.spec.ts new file mode 100644 index 0000000..9b38db5 --- /dev/null +++ b/apps/web/e2e/onboarding-oo-swipe.spec.ts @@ -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(); +}); diff --git a/apps/web/src/components/onboarding/LookPreviewCarousel.tsx b/apps/web/src/components/onboarding/LookPreviewCarousel.tsx new file mode 100644 index 0000000..f17d0fb --- /dev/null +++ b/apps/web/src/components/onboarding/LookPreviewCarousel.tsx @@ -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(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 ( +
+
{ + 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)} + > +
+ + +
+ {preview} +
+ + +
+
+ +
+ +

+ Swipe for more looks +

+ +
+ +
+ {looks.map((look, index) => ( +
+
+ ); +} diff --git a/apps/web/src/components/onboarding/OnboardingOoDesigner.tsx b/apps/web/src/components/onboarding/OnboardingOoDesigner.tsx new file mode 100644 index 0000000..1a34edd --- /dev/null +++ b/apps/web/src/components/onboarding/OnboardingOoDesigner.tsx @@ -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 Work; + case "hobby": + return Hobby; + case "scene": + return Scene; + case "coder": + return Coder; + case "calm": + return Calm helper; + 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("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 ( +
+

+ Design your Oo +

+

+ Pick a vibe, swipe through looks, and watch your Oo come alive. +

+ + + } + /> + +
+

+ What feels like you? +

+
+ {LOOK_CATEGORIES.map((entry) => { + const selected = entry.id === categoryId; + return ( + + ); + })} +
+
+ +
+ + +
+
+ ); +} diff --git a/apps/web/src/lib/onboarding-look-categories.test.ts b/apps/web/src/lib/onboarding-look-categories.test.ts new file mode 100644 index 0000000..86b1136 --- /dev/null +++ b/apps/web/src/lib/onboarding-look-categories.test.ts @@ -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"); + }); +}); diff --git a/apps/web/src/lib/onboarding-look-categories.ts b/apps/web/src/lib/onboarding-look-categories.ts new file mode 100644 index 0000000..6ce42ef --- /dev/null +++ b/apps/web/src/lib/onboarding-look-categories.ts @@ -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); +} diff --git a/apps/web/src/pages/Onboarding.tsx b/apps/web/src/pages/Onboarding.tsx index 1915db0..128e300 100644 --- a/apps/web/src/pages/Onboarding.tsx +++ b/apps/web/src/pages/Onboarding.tsx @@ -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: None }, - { key: "headset", label: Headset }, - { key: "glasses", label: Glasses }, - { key: "clipboard", label: Clipboard }, - { key: "spark", label: Spark }, - { key: "tool", label: Tool }, - { key: "magnifier", label: Magnifier }, - { key: "thought", label: Thought }, - { key: "zzz", label: Zzz }, -]; - -const POSE_OPTIONS = [ - { key: "idle", label: Idle }, - { key: "greeting", label: Greeting }, - { key: "thinking", label: Thinking }, - { key: "working", label: Working }, - { key: "listening", label: Listening }, - { key: "speaking", label: Speaking }, - { key: "success", label: Success }, - { key: "curious", label: Curious }, -]; - -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(null); - const [emojiConfig, setEmojiConfig] = useState(defaultEmojiConfig); - const [previewColor, setPreviewColor] = useState(emojiConfig.color); - const [previewShape, setPreviewShape] = useState(emojiConfig.shapeIndex); + const [emojiConfig, setEmojiConfig] = useState(defaultEmojiConfig); const [prefs, setPrefs] = useState(defaultPreferences()); const [currentLocale, setCurrentLocale] = useState(() => { - 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() {
- {error && step !== "bot" ? ( -

{error}

- ) : null} + {error && step !== "bot" ?

{error}

: null} {/* Step: Emoji Configuration */} {step === "emoji" ? ( -
-

- Design your Oo -

-

- Give your Oo a look that feels like you — shape, color, accessories. -

- -
- -
- - {/* Shape */} -
- -
- {SHAPE_OPTIONS.map((shape, idx) => ( - selectShape(idx)} - /> - ))} -
-
- - {/* Color */} -
- -
- {GROK_COLOR_LIST.map((colorDef: GrokColorDef) => ( -
-
- - {/* Accessories */} -
- -
- {ACCESSORY_OPTIONS.map((acc) => ( - - ))} -
-
- - {/* Pose */} -
- -
- {POSE_OPTIONS.map((pose) => ( - - ))} -
-
- -
- - -
-
+ setStep("preferences")} + /> ) : null} {/* Step: Preferences */} @@ -443,7 +296,10 @@ export function OnboardingPage() {

-
- - + +
) : null} @@ -595,7 +473,9 @@ export function OnboardingPage() { {step === "bot" && error ? (

{error}

- +
) : null} diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index a6251a8..7d040b3 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -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% {