Phase 1 Foundations: onboarding profile, nav padding, backpack views, AI persona
CI / policy (push) Has been cancelled
CI / backend (push) Has been cancelled
CI / profile (push) Has been cancelled
CI / mobile (push) Has been cancelled

- profileStore (MMKV) + pure profileLogic (types, options, isProfileComplete,
  buildPersona). Rich first-run onboarding (app/onboarding.tsx): name/role,
  industry, vibes (multi), goal, brand colors, social links, inspirations.
- First-launch gate in (tabs)/_layout via <Redirect> guarded by a hydration
  flag; onboarding registered in the root stack.
- Bottom tab bar made taller with safe-area bottom padding.
- Backpack (Cards tab): Grid / Showcase view toggle + add-new entry.
- Business cards skip data entry: demo.tsx prefills name/title/links from the
  profile (creative tools lead).
- Persona → AI: SceneBrief/refine_prompt gain an optional persona (role/vibe/
  colors/goal); mobile generators pass buildPersona(profile). FakeAiClient
  echoes the role. genwizard gains MultiChips.
- Tests: mobile 28 (new profileLogic), backend 121 (new refine_includes_persona).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-05 23:09:17 -05:00
co-authored by Claude Opus 4.8
parent e0b6200091
commit 8865dc9683
16 changed files with 655 additions and 36 deletions
@@ -8,7 +8,7 @@
//! Behind an [`AiClient`] trait so handlers/tests don't depend on the network. //! Behind an [`AiClient`] trait so handlers/tests don't depend on the network.
use async_trait::async_trait; use async_trait::async_trait;
use serde::Serialize; use serde::{Deserialize, Serialize};
const GEMINI_BASE: &str = "https://generativelanguage.googleapis.com/v1beta"; const GEMINI_BASE: &str = "https://generativelanguage.googleapis.com/v1beta";
const TEXT_MODEL: &str = "gemini-2.5-flash"; const TEXT_MODEL: &str = "gemini-2.5-flash";
@@ -25,12 +25,23 @@ pub enum AiError {
Empty(&'static str), Empty(&'static str),
} }
/// The user's persona (from their onboarding profile) used to tailor generation.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Persona {
pub role: Option<String>,
pub vibe: Option<String>,
pub colors: Option<Vec<String>>,
pub goal: Option<String>,
}
/// Rough fields collected by the "Describe your scene" wizard. /// Rough fields collected by the "Describe your scene" wizard.
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct SceneBrief { pub struct SceneBrief {
pub scene: String, pub scene: String,
pub style: Option<String>, pub style: Option<String>,
pub mood: Option<String>, pub mood: Option<String>,
pub persona: Option<Persona>,
} }
/// A generated image as base64 plus its MIME type. /// A generated image as base64 plus its MIME type.
@@ -109,10 +120,11 @@ impl AiClient for GeminiClient {
let instruction = format!( let instruction = format!(
"You are helping design a digital business card image. Turn the \ "You are helping design a digital business card image. Turn the \
following into ONE vivid, concrete image-generation prompt (2-3 \ following into ONE vivid, concrete image-generation prompt (2-3 \
sentences, no preamble, no quotes).\nScene: {}\nStyle: {}\nMood: {}", sentences, no preamble, no quotes).\nScene: {}\nStyle: {}\nMood: {}{}",
brief.scene, brief.scene,
brief.style.as_deref().unwrap_or("(any)"), brief.style.as_deref().unwrap_or("(any)"),
brief.mood.as_deref().unwrap_or("(any)"), brief.mood.as_deref().unwrap_or("(any)"),
persona_clause(brief.persona.as_ref()),
); );
let json = self.generate_content(TEXT_MODEL, &instruction).await?; let json = self.generate_content(TEXT_MODEL, &instruction).await?;
first_text(&json).ok_or(AiError::Empty("text")) first_text(&json).ok_or(AiError::Empty("text"))
@@ -192,6 +204,22 @@ impl AiClient for GeminiClient {
} }
} }
/// Build the persona suffix for the refine instruction (empty when no persona).
fn persona_clause(persona: Option<&Persona>) -> String {
match persona {
None => String::new(),
Some(p) => format!(
"\nPersona (bias the image to fit this person): role={}; aesthetic vibe={}; \
brand colors={}; goal={}. Weave the brand colors and vibe into the composition; \
do not render any text.",
p.role.as_deref().unwrap_or("(any)"),
p.vibe.as_deref().unwrap_or("(any)"),
p.colors.as_ref().map(|c| c.join(", ")).unwrap_or_default(),
p.goal.as_deref().unwrap_or("(any)"),
),
}
}
/// Extract the first text part from a generateContent response. /// Extract the first text part from a generateContent response.
fn first_text(json: &serde_json::Value) -> Option<String> { fn first_text(json: &serde_json::Value) -> Option<String> {
json["candidates"][0]["content"]["parts"] json["candidates"][0]["content"]["parts"]
@@ -246,10 +274,16 @@ const PIXEL_PNG_B64: &str =
#[async_trait] #[async_trait]
impl AiClient for FakeAiClient { impl AiClient for FakeAiClient {
async fn refine_prompt(&self, brief: &SceneBrief) -> Result<String, AiError> { async fn refine_prompt(&self, brief: &SceneBrief) -> Result<String, AiError> {
let role = brief
.persona
.as_ref()
.and_then(|p| p.role.as_deref())
.unwrap_or("");
Ok(format!( Ok(format!(
"A {} {} scene: {}", "A {} {} scene for {}: {}",
brief.mood.as_deref().unwrap_or("striking"), brief.mood.as_deref().unwrap_or("striking"),
brief.style.as_deref().unwrap_or("cinematic"), brief.style.as_deref().unwrap_or("cinematic"),
role,
brief.scene, brief.scene,
)) ))
} }
@@ -10,7 +10,7 @@ use base64::Engine;
use cardclaws_types::AppError; use cardclaws_types::AppError;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::ai::{AiError, SceneBrief, VideoStatus}; use crate::ai::{AiError, Persona, SceneBrief, VideoStatus};
use crate::error::ApiResult; use crate::error::ApiResult;
use crate::handlers::analytics::client_ip; use crate::handlers::analytics::client_ip;
use crate::middleware::rate_limit; use crate::middleware::rate_limit;
@@ -23,6 +23,8 @@ pub struct RefineRequest {
pub style: Option<String>, pub style: Option<String>,
#[serde(default)] #[serde(default)]
pub mood: Option<String>, pub mood: Option<String>,
#[serde(default)]
pub persona: Option<Persona>,
} }
#[derive(Serialize)] #[derive(Serialize)]
@@ -63,6 +65,7 @@ pub async fn refine(
scene: req.scene, scene: req.scene,
style: req.style, style: req.style,
mood: req.mood, mood: req.mood,
persona: req.persona,
}; };
let prompt = state.ai.refine_prompt(&brief).await.map_err(map_ai_err)?; let prompt = state.ai.refine_prompt(&brief).await.map_err(map_ai_err)?;
Ok(Json(RefineResponse { prompt })) Ok(Json(RefineResponse { prompt }))
@@ -41,6 +41,7 @@ pub async fn set_welcome(
scene: prompt.to_string(), scene: prompt.to_string(),
style: style.map(str::to_string), style: style.map(str::to_string),
mood: mood.map(str::to_string), mood: mood.map(str::to_string),
persona: None,
}; };
let refined = state.ai.refine_prompt(&brief).await.map_err(ai_to_app)?; let refined = state.ai.refine_prompt(&brief).await.map_err(ai_to_app)?;
let img = state.ai.generate_image(&refined).await.map_err(ai_to_app)?; let img = state.ai.generate_image(&refined).await.map_err(ai_to_app)?;
@@ -22,6 +22,25 @@ async fn refine_returns_a_prompt() {
assert!(prompt.contains("neon Tokyo street")); assert!(prompt.contains("neon Tokyo street"));
} }
#[tokio::test]
async fn refine_includes_persona() {
let app = require_app!();
let (status, body) = app
.request(
"POST",
"/v1/ai/refine",
None,
Some(json!({
"scene": "a calm forest at dawn",
"persona": { "role": "Founder", "vibe": "Cinematic, Luxe", "colors": ["#ff3b30"], "goal": "networking" }
})),
)
.await;
assert_eq!(status, StatusCode::OK);
// FakeAiClient echoes the persona role into the refined prompt.
assert!(body["prompt"].as_str().unwrap().contains("Founder"));
}
#[tokio::test] #[tokio::test]
async fn refine_requires_a_scene() { async fn refine_requires_a_scene() {
let app = require_app!(); let app = require_app!();
@@ -0,0 +1,36 @@
import {
buildPersona,
EMPTY_PROFILE,
isProfileComplete,
Profile,
} from "../../src/stores/profileLogic";
const base: Profile = { ...EMPTY_PROFILE };
describe("profileLogic", () => {
test("isProfileComplete requires a non-empty name", () => {
expect(isProfileComplete(base)).toBe(false);
expect(isProfileComplete({ ...base, displayName: " " })).toBe(false);
expect(isProfileComplete({ ...base, displayName: "Omar" })).toBe(true);
});
test("buildPersona returns undefined for an empty profile", () => {
expect(buildPersona(base)).toBeUndefined();
});
test("buildPersona maps fields and joins vibes, dropping empties", () => {
const persona = buildPersona({
...base,
title: "Founder",
vibes: ["Cinematic", "Luxe"],
brandColors: ["#ff3b30"],
goal: "networking",
});
expect(persona).toEqual({
role: "Founder",
vibe: "Cinematic, Luxe",
colors: ["#ff3b30"],
goal: "networking",
});
});
});
+19 -1
View File
@@ -4,7 +4,10 @@
// robot → work-agent cards // robot → work-agent cards
// cog → settings // cog → settings
import { MaterialCommunityIcons } from "@expo/vector-icons"; import { MaterialCommunityIcons } from "@expo/vector-icons";
import { Tabs } from "expo-router"; import { Redirect, Tabs } from "expo-router";
import { StyleSheet } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useProfileStore } from "../../src/stores/profileStore";
type IconName = keyof typeof MaterialCommunityIcons.glyphMap; type IconName = keyof typeof MaterialCommunityIcons.glyphMap;
@@ -15,6 +18,15 @@ function tabIcon(name: IconName) {
} }
export default function TabsLayout() { export default function TabsLayout() {
const insets = useSafeAreaInsets();
const hydrated = useProfileStore((s) => s.hydrated);
const onboarded = useProfileStore((s) => s.onboarded);
// Wait for MMKV to rehydrate before deciding, then send first-run users to
// onboarding (avoids a flash of the tabs).
if (!hydrated) return null;
if (!onboarded) return <Redirect href="/onboarding" />;
return ( return (
<Tabs <Tabs
screenOptions={{ screenOptions={{
@@ -23,8 +35,14 @@ export default function TabsLayout() {
tabBarInactiveTintColor: "#6b6b70", tabBarInactiveTintColor: "#6b6b70",
tabBarStyle: { tabBarStyle: {
backgroundColor: "#0e0e12", backgroundColor: "#0e0e12",
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: "#1c1c22", borderTopColor: "#1c1c22",
height: 64 + insets.bottom,
paddingTop: 10,
paddingBottom: Math.max(insets.bottom, 14),
}, },
tabBarLabelStyle: { fontSize: 11, fontWeight: "600", marginBottom: 2 },
tabBarItemStyle: { paddingVertical: 4 },
}} }}
> >
<Tabs.Screen name="index" options={{ title: "Cards", tabBarIcon: tabIcon("bag-personal") }} /> <Tabs.Screen name="index" options={{ title: "Cards", tabBarIcon: tabIcon("bag-personal") }} />
+85 -13
View File
@@ -1,14 +1,16 @@
// Gallery of cards saved on this device (standalone demo). Clean, chrome-free: // Cards (Backpack): your collection of business cards, with switchable views
// just the cards, auto-arranging by count, plus a New-card button. // (Grid / Showcase) and an always-present "New card" entry.
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { useState } from "react";
import { FlatList, Pressable, StyleSheet, Text, View } from "react-native"; import { FlatList, Pressable, StyleSheet, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { CardThumb } from "../../src/components/CardThumb"; import { CardThumb } from "../../src/components/CardThumb";
import { LocalCard, useLocalCardsStore } from "../../src/stores/localCardsStore"; import { LocalCard, useLocalCardsStore } from "../../src/stores/localCardsStore";
/// Shrink tiles as the gallery grows: 2 columns → 4 fit a screen, 3 columns → type ViewMode = "grid" | "showcase";
/// ~8 fit, 4 columns beyond that.
/// Shrink grid tiles as the collection grows.
function columnsFor(count: number): number { function columnsFor(count: number): number {
if (count <= 4) return 2; if (count <= 4) return 2;
if (count <= 9) return 3; if (count <= 9) return 3;
@@ -19,25 +21,51 @@ export default function Gallery() {
const router = useRouter(); const router = useRouter();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const cards = useLocalCardsStore((s) => s.cards); const cards = useLocalCardsStore((s) => s.cards);
const columns = columnsFor(cards.length); const [view, setView] = useState<ViewMode>("grid");
const columns = view === "showcase" ? 1 : columnsFor(cards.length);
return ( return (
<View style={styles.root}> <View style={styles.root}>
<View style={[styles.topBar, { paddingTop: insets.top + 10 }]}>
<Segmented value={view} onChange={setView} />
</View>
<FlatList <FlatList
data={cards} data={cards}
keyExtractor={(c) => c.id} keyExtractor={(c) => c.id}
// numColumns can't change on the fly without remounting the list. key={`${view}-${columns}`}
key={`cols-${columns}`}
numColumns={columns} numColumns={columns}
columnWrapperStyle={cards.length > 0 ? styles.column : undefined} columnWrapperStyle={columns > 1 ? styles.column : undefined}
contentContainerStyle={[styles.list, { paddingTop: insets.top + 12 }]} contentContainerStyle={styles.list}
ListEmptyComponent={ ListEmptyComponent={
<View style={styles.empty}> <View style={styles.empty}>
<Text style={styles.emptyTitle}>No cards yet</Text> <Text style={styles.emptyTitle}>No cards yet</Text>
<Text style={styles.emptyHint}>Tap “New card” to create your first.</Text> <Text style={styles.emptyHint}>Tap “New card” to create your first.</Text>
</View> </View>
} }
renderItem={({ item }: { item: LocalCard }) => ( ListFooterComponent={
view === "showcase" && cards.length > 0 ? (
<Pressable style={styles.addTile} onPress={() => router.push("/demo")}>
<Text style={styles.addTileText}>+ New card</Text>
</Pressable>
) : null
}
renderItem={({ item }: { item: LocalCard }) =>
view === "showcase" ? (
<Pressable style={styles.showTile} onPress={() => router.push(`/demo?cardId=${item.id}`)}>
<CardThumb item={item} style={styles.showThumb} />
<View style={styles.showOverlay}>
<Text style={styles.showName} numberOfLines={1}>
{item.name}
</Text>
{!!item.title && (
<Text style={styles.showTitle} numberOfLines={1}>
{item.title}
</Text>
)}
</View>
</Pressable>
) : (
<Pressable style={styles.tile} onPress={() => router.push(`/demo?cardId=${item.id}`)}> <Pressable style={styles.tile} onPress={() => router.push(`/demo?cardId=${item.id}`)}>
<CardThumb item={item} style={styles.tileImage} /> <CardThumb item={item} style={styles.tileImage} />
<View style={[styles.tileLabel, columns >= 3 && styles.tileLabelCompact]}> <View style={[styles.tileLabel, columns >= 3 && styles.tileLabelCompact]}>
@@ -51,20 +79,42 @@ export default function Gallery() {
)} )}
</View> </View>
</Pressable> </Pressable>
)} )
}
/> />
<Pressable style={[styles.fab, { bottom: 24 }]} onPress={() => router.push("/demo")}> <Pressable style={[styles.fab, { bottom: insets.bottom + 80 }]} onPress={() => router.push("/demo")}>
<Text style={styles.fabText}>+ New card</Text> <Text style={styles.fabText}>+ New card</Text>
</Pressable> </Pressable>
</View> </View>
); );
} }
function Segmented({ value, onChange }: { value: ViewMode; onChange: (v: ViewMode) => void }) {
return (
<View style={styles.segment}>
{(["grid", "showcase"] as const).map((v) => (
<Pressable key={v} onPress={() => onChange(v)} style={[styles.segBtn, value === v && styles.segOn]}>
<Text style={[styles.segText, value === v && styles.segTextOn]}>
{v === "grid" ? "Grid" : "Showcase"}
</Text>
</Pressable>
))}
</View>
);
}
const styles = StyleSheet.create({ const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: "#0a0a0c" }, root: { flex: 1, backgroundColor: "#0a0a0c" },
list: { paddingHorizontal: 16, paddingBottom: 96, flexGrow: 1 }, topBar: { paddingHorizontal: 16, paddingBottom: 10 },
segment: { flexDirection: "row", backgroundColor: "#15151a", borderRadius: 12, padding: 4, gap: 4 },
segBtn: { flex: 1, paddingVertical: 9, borderRadius: 9, alignItems: "center" },
segOn: { backgroundColor: "#ff3b30" },
segText: { color: "#9a9aa0", fontWeight: "600", fontSize: 14 },
segTextOn: { color: "#fff" },
list: { paddingHorizontal: 16, paddingBottom: 120, flexGrow: 1 },
column: { gap: 12 }, column: { gap: 12 },
// Grid
tile: { flex: 1, marginBottom: 12, borderRadius: 18, overflow: "hidden", backgroundColor: "#15151a" }, tile: { flex: 1, marginBottom: 12, borderRadius: 18, overflow: "hidden", backgroundColor: "#15151a" },
tileImage: { width: "100%", aspectRatio: 2 / 3, overflow: "hidden" }, tileImage: { width: "100%", aspectRatio: 2 / 3, overflow: "hidden" },
tileLabel: { padding: 12 }, tileLabel: { padding: 12 },
@@ -72,6 +122,28 @@ const styles = StyleSheet.create({
tileName: { color: "#f5f5f7", fontSize: 16, fontWeight: "700" }, tileName: { color: "#f5f5f7", fontSize: 16, fontWeight: "700" },
tileNameCompact: { fontSize: 12 }, tileNameCompact: { fontSize: 12 },
tileTitle: { color: "#9a9aa0", fontSize: 13, marginTop: 2 }, tileTitle: { color: "#9a9aa0", fontSize: 13, marginTop: 2 },
// Showcase
showTile: { marginBottom: 16, borderRadius: 22, overflow: "hidden", backgroundColor: "#15151a" },
showThumb: { width: "100%", aspectRatio: 3 / 4, overflow: "hidden" },
showOverlay: {
position: "absolute",
left: 0,
right: 0,
bottom: 0,
padding: 18,
backgroundColor: "rgba(10,10,12,0.72)",
},
showName: { color: "#fff", fontSize: 22, fontWeight: "800" },
showTitle: { color: "#cfcfd4", fontSize: 14, marginTop: 2 },
addTile: {
borderWidth: 1,
borderColor: "#2a2a30",
borderStyle: "dashed",
borderRadius: 22,
paddingVertical: 28,
alignItems: "center",
},
addTileText: { color: "#f5f5f7", fontWeight: "700", fontSize: 16 },
empty: { flex: 1, alignItems: "center", justifyContent: "center", paddingTop: 120, gap: 6 }, empty: { flex: 1, alignItems: "center", justifyContent: "center", paddingTop: 120, gap: 6 },
emptyTitle: { color: "#f5f5f7", fontSize: 20, fontWeight: "700" }, emptyTitle: { color: "#f5f5f7", fontSize: 20, fontWeight: "700" },
emptyHint: { color: "#6b6b70" }, emptyHint: { color: "#6b6b70" },
+2 -1
View File
@@ -17,8 +17,9 @@ export default function RootLayout() {
contentStyle: { backgroundColor: "#0a0a0c" }, contentStyle: { backgroundColor: "#0a0a0c" },
}} }}
> >
{/* The tab bar owns its own chrome; no stack header above it. */} {/* The tab bar + onboarding own their own chrome; no stack header. */}
<Stack.Screen name="(tabs)" options={{ headerShown: false }} /> <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="onboarding" options={{ headerShown: false }} />
</Stack> </Stack>
</QueryClientProvider> </QueryClientProvider>
</GestureHandlerRootView> </GestureHandlerRootView>
+11 -4
View File
@@ -24,6 +24,7 @@ import { defaultLinks, LinksEditor } from "../src/components/LinksEditor";
import { newLayerId } from "../src/stores/cardStore"; import { newLayerId } from "../src/stores/cardStore";
import { useDraftStore } from "../src/stores/draftStore"; import { useDraftStore } from "../src/stores/draftStore";
import { CardLink, useLocalCardsStore } from "../src/stores/localCardsStore"; import { CardLink, useLocalCardsStore } from "../src/stores/localCardsStore";
import { useProfileStore } from "../src/stores/profileStore";
import { CardDefinition, DEFAULT_SETTINGS, TextLayer } from "../src/types/card"; import { CardDefinition, DEFAULT_SETTINGS, TextLayer } from "../src/types/card";
import { deleteImage, persistImage, persistVideo } from "../src/utils/imageStore"; import { deleteImage, persistImage, persistVideo } from "../src/utils/imageStore";
@@ -105,13 +106,18 @@ export default function CardEditorScreen() {
const getById = useLocalCardsStore((s) => s.getById); const getById = useLocalCardsStore((s) => s.getById);
// A stable id for the lifetime of this editor (new card or the one we're editing). // A stable id for the lifetime of this editor (new card or the one we're editing).
// New cards prefill from the user's profile (so there's no data entry); the
// edit path (cardId) overrides these in the effect below.
const profile = useProfileStore((s) => s.profile);
const [id] = useState(() => cardId ?? newLayerId()); const [id] = useState(() => cardId ?? newLayerId());
const [imageUri, setImageUri] = useState<string | null>(null); const [imageUri, setImageUri] = useState<string | null>(null);
const [videoUri, setVideoUri] = useState<string | null>(null); const [videoUri, setVideoUri] = useState<string | null>(null);
const [name, setName] = useState("Omar Sobh"); const [name, setName] = useState(() => (cardId ? "" : profile.displayName));
const [title, setTitle] = useState("Founder & CEO"); const [title, setTitle] = useState(() => (cardId ? "" : profile.title));
const [url, setUrl] = useState("https://cardclaws.com/omar"); const [url, setUrl] = useState("");
const [links, setLinks] = useState<CardLink[]>(() => defaultLinks()); const [links, setLinks] = useState<CardLink[]>(() =>
cardId ? [] : profile.socialLinks.length > 0 ? profile.socialLinks : defaultLinks(),
);
const [showing, setShowing] = useState(false); const [showing, setShowing] = useState(false);
const [onBack, setOnBack] = useState(false); const [onBack, setOnBack] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -303,6 +309,7 @@ export default function CardEditorScreen() {
</Pressable> </Pressable>
</View> </View>
<Text style={styles.sectionLabel}>Details · prefilled from your profile</Text>
<Field label="Name" value={name} onChangeText={setName} /> <Field label="Name" value={name} onChangeText={setName} />
<Field label="Title" value={title} onChangeText={setTitle} /> <Field label="Title" value={title} onChangeText={setTitle} />
<Field label="QR link" value={url} onChangeText={setUrl} autoCapitalize="none" /> <Field label="QR link" value={url} onChangeText={setUrl} autoCapitalize="none" />
+4
View File
@@ -17,6 +17,8 @@ import {
import { generateImage, refineScene } from "../src/api/ai"; import { generateImage, refineScene } from "../src/api/ai";
import { Chips, wizardInputStyle } from "../src/components/genwizard/Wizard"; import { Chips, wizardInputStyle } from "../src/components/genwizard/Wizard";
import { useDraftStore } from "../src/stores/draftStore"; import { useDraftStore } from "../src/stores/draftStore";
import { buildPersona } from "../src/stores/profileLogic";
import { useProfileStore } from "../src/stores/profileStore";
const STYLES = ["Cinematic", "Minimal", "Neon", "Studio portrait", "Nature", "Abstract"]; const STYLES = ["Cinematic", "Minimal", "Neon", "Studio portrait", "Nature", "Abstract"];
const MOODS = ["Bold", "Calm", "Luxe", "Playful", "Dark"]; const MOODS = ["Bold", "Calm", "Luxe", "Playful", "Dark"];
@@ -24,6 +26,7 @@ const MOODS = ["Bold", "Calm", "Luxe", "Playful", "Dark"];
export default function GenerateImage() { export default function GenerateImage() {
const router = useRouter(); const router = useRouter();
const setPendingImageUri = useDraftStore((s) => s.setPendingImageUri); const setPendingImageUri = useDraftStore((s) => s.setPendingImageUri);
const profile = useProfileStore((s) => s.profile);
const [scene, setScene] = useState(""); const [scene, setScene] = useState("");
const [style, setStyle] = useState(""); const [style, setStyle] = useState("");
const [mood, setMood] = useState(""); const [mood, setMood] = useState("");
@@ -40,6 +43,7 @@ export default function GenerateImage() {
scene, scene,
style: style || undefined, style: style || undefined,
mood: mood || undefined, mood: mood || undefined,
persona: buildPersona(profile),
}); });
const img = await generateImage(prompt); const img = await generateImage(prompt);
const path = `${FileSystem.cacheDirectory}ai-${Date.now()}.png`; const path = `${FileSystem.cacheDirectory}ai-${Date.now()}.png`;
+8 -1
View File
@@ -17,6 +17,8 @@ import {
import { pollVideo, startVideo } from "../src/api/ai"; import { pollVideo, startVideo } from "../src/api/ai";
import { Chips, wizardInputStyle } from "../src/components/genwizard/Wizard"; import { Chips, wizardInputStyle } from "../src/components/genwizard/Wizard";
import { useDraftStore } from "../src/stores/draftStore"; import { useDraftStore } from "../src/stores/draftStore";
import { buildPersona } from "../src/stores/profileLogic";
import { useProfileStore } from "../src/stores/profileStore";
const MOTION = ["Parallax", "Particles", "Slow zoom", "Liquid", "Aurora", "Glitch"]; const MOTION = ["Parallax", "Particles", "Slow zoom", "Liquid", "Aurora", "Glitch"];
const LENGTH = ["3 seconds", "5 seconds", "8 seconds"]; const LENGTH = ["3 seconds", "5 seconds", "8 seconds"];
@@ -26,6 +28,7 @@ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
export default function GenerateVideo() { export default function GenerateVideo() {
const router = useRouter(); const router = useRouter();
const setPendingVideoUri = useDraftStore((s) => s.setPendingVideoUri); const setPendingVideoUri = useDraftStore((s) => s.setPendingVideoUri);
const profile = useProfileStore((s) => s.profile);
const [concept, setConcept] = useState(""); const [concept, setConcept] = useState("");
const [motion, setMotion] = useState(""); const [motion, setMotion] = useState("");
const [length, setLength] = useState(""); const [length, setLength] = useState("");
@@ -38,7 +41,11 @@ export default function GenerateVideo() {
} }
const m = motion ? `${motion.toLowerCase()} ` : ""; const m = motion ? `${motion.toLowerCase()} ` : "";
const len = length ? ` (${length})` : ""; const len = length ? ` (${length})` : "";
const prompt = `A ${m}motion clip for a digital business card: ${concept}${len}.` const persona = buildPersona(profile);
const personaPrefix = persona
? `For a ${persona.role ?? "professional"}${persona.vibe ? ` (${persona.vibe} aesthetic)` : ""}: `
: "";
const prompt = `${personaPrefix}A ${m}motion clip for a digital business card: ${concept}${len}.`
.replace(/\s+/g, " ") .replace(/\s+/g, " ")
.trim(); .trim();
+231
View File
@@ -0,0 +1,231 @@
// First-run onboarding: builds a rich user profile that personalizes AI assets
// and prefills business cards. Shown once (gated by profileStore.onboarded).
import { Redirect, Stack, useRouter } from "expo-router";
import { useState } from "react";
import { Pressable, ScrollView, StyleSheet, Text, TextInput, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Chips, MultiChips, wizardInputStyle } from "../src/components/genwizard/Wizard";
import { LinksEditor } from "../src/components/LinksEditor";
import { CardLink } from "../src/stores/localCardsStore";
import {
BRAND_COLOR_SWATCHES,
EMPTY_PROFILE,
Goal,
GOAL_OPTIONS,
INDUSTRY_OPTIONS,
INSPIRATION_OPTIONS,
Profile,
VIBE_OPTIONS,
} from "../src/stores/profileLogic";
import { useProfileStore } from "../src/stores/profileStore";
const STEPS = 8;
export default function Onboarding() {
const router = useRouter();
const insets = useSafeAreaInsets();
const onboarded = useProfileStore((s) => s.onboarded);
const completeOnboarding = useProfileStore((s) => s.completeOnboarding);
const [step, setStep] = useState(0);
const [p, setP] = useState<Profile>(EMPTY_PROFILE);
const patch = (u: Partial<Profile>) => setP((cur) => ({ ...cur, ...u }));
const toggle = (key: "vibes" | "inspirations" | "brandColors", v: string, max?: number) =>
setP((cur) => {
const has = cur[key].includes(v);
let next = has ? cur[key].filter((x) => x !== v) : [...cur[key], v];
if (!has && max && next.length > max) next = next.slice(next.length - max);
return { ...cur, [key]: next };
});
if (onboarded) return <Redirect href="/(tabs)" />;
const canNext = step !== 0 || p.displayName.trim().length > 0;
const finish = () => {
completeOnboarding(p);
router.replace("/(tabs)");
};
return (
<View style={[styles.root, { paddingTop: insets.top + 8 }]}>
<Stack.Screen options={{ headerShown: false }} />
<View style={styles.dots}>
{Array.from({ length: STEPS }).map((_, i) => (
<View key={i} style={[styles.dot, i === step && styles.dotOn, i < step && styles.dotDone]} />
))}
</View>
<ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
{step === 0 && (
<Step title="Welcome 👋" subtitle="Let's set up your profile so the AI can tailor your cards.">
<Text style={styles.label}>Your name</Text>
<TextInput
style={styles.input}
placeholder="e.g. Omar Sobh"
placeholderTextColor="#6b6b70"
value={p.displayName}
onChangeText={(t) => patch({ displayName: t })}
/>
<Text style={styles.label}>Role / title</Text>
<TextInput
style={styles.input}
placeholder="e.g. Founder & CEO"
placeholderTextColor="#6b6b70"
value={p.title}
onChangeText={(t) => patch({ title: t })}
/>
</Step>
)}
{step === 1 && (
<Step title="Your industry" subtitle="Helps frame the look.">
<Chips options={INDUSTRY_OPTIONS} value={p.industry} onSelect={(v) => patch({ industry: v })} />
</Step>
)}
{step === 2 && (
<Step title="Your aesthetic" subtitle="Pick the vibes you like — choose a few.">
<MultiChips options={VIBE_OPTIONS} values={p.vibes} onToggle={(v) => toggle("vibes", v)} />
</Step>
)}
{step === 3 && (
<Step title="Primary goal" subtitle="What are these cards for?">
<Chips
options={GOAL_OPTIONS.map((g) => g.label)}
value={GOAL_OPTIONS.find((g) => g.value === p.goal)?.label ?? ""}
onSelect={(label) =>
patch({ goal: (GOAL_OPTIONS.find((g) => g.label === label)?.value ?? "") as Goal })
}
/>
</Step>
)}
{step === 4 && (
<Step title="Brand colors" subtitle="Pick up to 3 — we'll weave them into your art.">
<View style={styles.swatches}>
{BRAND_COLOR_SWATCHES.map((c) => (
<Pressable
key={c}
onPress={() => toggle("brandColors", c, 3)}
style={[
styles.swatch,
{ backgroundColor: c },
p.brandColors.includes(c) && styles.swatchOn,
]}
/>
))}
</View>
</Step>
)}
{step === 5 && (
<Step title="Social links" subtitle="Add any you want on your cards (optional).">
<LinksEditor links={p.socialLinks} onChange={(l: CardLink[]) => patch({ socialLinks: l })} />
</Step>
)}
{step === 6 && (
<Step title="Inspiration" subtitle="Any looks you're drawn to? (up to 2)">
<MultiChips
options={INSPIRATION_OPTIONS}
values={p.inspirations}
onToggle={(v) => toggle("inspirations", v, 2)}
/>
</Step>
)}
{step === 7 && (
<Step title="You're set 🎉" subtitle="You can tweak any of this later in Settings.">
<View style={styles.review}>
<ReviewRow label="Name" value={p.displayName || "—"} />
<ReviewRow label="Role" value={p.title || "—"} />
<ReviewRow label="Industry" value={p.industry || "—"} />
<ReviewRow label="Vibes" value={p.vibes.join(", ") || "—"} />
<ReviewRow label="Goal" value={GOAL_OPTIONS.find((g) => g.value === p.goal)?.label ?? "—"} />
<ReviewRow label="Colors" value={`${p.brandColors.length} picked`} />
<ReviewRow label="Links" value={`${p.socialLinks.filter((l) => l.url).length}`} />
</View>
</Step>
)}
</ScrollView>
<View style={[styles.footer, { paddingBottom: insets.bottom + 16 }]}>
{step > 0 && (
<Pressable style={styles.back} onPress={() => setStep(step - 1)}>
<Text style={styles.backText}>Back</Text>
</Pressable>
)}
<Pressable
style={[styles.next, !canNext && styles.disabled]}
disabled={!canNext}
onPress={() => (step === STEPS - 1 ? finish() : setStep(step + 1))}
>
<Text style={styles.nextText}>{step === STEPS - 1 ? "Get started" : "Next"}</Text>
</Pressable>
</View>
</View>
);
}
function Step({ title, subtitle, children }: { title: string; subtitle: string; children: React.ReactNode }) {
return (
<View style={{ gap: 10 }}>
<Text style={styles.h1}>{title}</Text>
<Text style={styles.sub}>{subtitle}</Text>
<View style={{ marginTop: 8 }}>{children}</View>
</View>
);
}
function ReviewRow({ label, value }: { label: string; value: string }) {
return (
<View style={styles.reviewRow}>
<Text style={styles.reviewLabel}>{label}</Text>
<Text style={styles.reviewValue} numberOfLines={1}>
{value}
</Text>
</View>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: "#0a0a0c" },
dots: { flexDirection: "row", gap: 6, paddingHorizontal: 20, paddingBottom: 8 },
dot: { flex: 1, height: 4, borderRadius: 2, backgroundColor: "#222228" },
dotOn: { backgroundColor: "#ff3b30" },
dotDone: { backgroundColor: "#7a2620" },
content: { padding: 20, paddingBottom: 40 },
h1: { color: "#f5f5f7", fontSize: 28, fontWeight: "800" },
sub: { color: "#9a9aa0", fontSize: 15 },
label: { color: "#9a9aa0", fontSize: 14, marginTop: 8 },
input: { ...wizardInputStyle, minHeight: 0 },
swatches: { flexDirection: "row", flexWrap: "wrap", gap: 14 },
swatch: { width: 48, height: 48, borderRadius: 24, borderWidth: 3, borderColor: "transparent" },
swatchOn: { borderColor: "#ffffff" },
review: { backgroundColor: "#15151a", borderRadius: 16, paddingHorizontal: 16 },
reviewRow: {
flexDirection: "row",
justifyContent: "space-between",
paddingVertical: 12,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: "#22222a",
gap: 16,
},
reviewLabel: { color: "#9a9aa0", fontSize: 14 },
reviewValue: { color: "#f5f5f7", fontSize: 14, fontWeight: "600", flexShrink: 1 },
footer: { flexDirection: "row", gap: 12, paddingHorizontal: 20, paddingTop: 12 },
back: {
backgroundColor: "#222228",
borderRadius: 16,
paddingVertical: 16,
paddingHorizontal: 24,
alignItems: "center",
},
backText: { color: "#f5f5f7", fontWeight: "600", fontSize: 16 },
next: { flex: 1, backgroundColor: "#ff3b30", borderRadius: 16, paddingVertical: 16, alignItems: "center" },
disabled: { opacity: 0.4 },
nextText: { color: "#fff", fontWeight: "700", fontSize: 17 },
});
+9
View File
@@ -3,10 +3,19 @@
import { api } from "./client"; import { api } from "./client";
export interface Persona {
role?: string;
vibe?: string;
colors?: string[];
goal?: string;
}
export interface SceneBrief { export interface SceneBrief {
scene: string; scene: string;
style?: string; style?: string;
mood?: string; mood?: string;
/** User persona (from their profile) to tailor the result. */
persona?: Persona;
} }
/** Refine the rough fields into a single vivid prompt (Gemini text). */ /** Refine the rough fields into a single vivid prompt (Gemini text). */
@@ -39,6 +39,30 @@ export function Chips({
); );
} }
/** Multi-select chip row (sibling of Chips; same styling). */
export function MultiChips({
options,
values,
onToggle,
}: {
options: string[];
values: string[];
onToggle: (v: string) => void;
}) {
return (
<View style={styles.chips}>
{options.map((o) => {
const on = values.includes(o);
return (
<Pressable key={o} onPress={() => onToggle(o)} style={[styles.chip, on && styles.chipOn]}>
<Text style={[styles.chipText, on && styles.chipTextOn]}>{o}</Text>
</Pressable>
);
})}
</View>
);
}
export function Wizard({ export function Wizard({
title, title,
steps, steps,
+109
View File
@@ -0,0 +1,109 @@
// Pure profile logic + option lists (no MMKV import, so it's unit-testable under
// the node jest preset). The MMKV-bound store lives in profileStore.ts.
import type { CardLink } from "./localCardsStore";
export type Goal = "networking" | "sales" | "creator" | "recruiting";
export interface Profile {
displayName: string;
title: string;
industry: string;
vibes: string[];
goal: Goal | "";
brandColors: string[];
socialLinks: CardLink[];
inspirations: string[];
}
export const EMPTY_PROFILE: Profile = {
displayName: "",
title: "",
industry: "",
vibes: [],
goal: "",
brandColors: [],
socialLinks: [],
inspirations: [],
};
export const VIBE_OPTIONS = [
"Cinematic",
"Minimal",
"Neon",
"Luxe",
"Studio",
"Nature",
"Abstract",
"Playful",
"Dark",
"Bold",
];
export const GOAL_OPTIONS: { value: Goal; label: string }[] = [
{ value: "networking", label: "Networking" },
{ value: "sales", label: "Sales" },
{ value: "creator", label: "Creator" },
{ value: "recruiting", label: "Recruiting" },
];
export const INDUSTRY_OPTIONS = [
"Tech",
"Design",
"Finance",
"Healthcare",
"Real estate",
"Marketing",
"Music",
"Film",
"Education",
"Other",
];
export const BRAND_COLOR_SWATCHES = [
"#ff3b30",
"#ff9f0a",
"#ffd60a",
"#30d158",
"#0a84ff",
"#5e5ce6",
"#bf5af2",
"#ff2d55",
"#f5f5f7",
"#1c1c22",
];
export const INSPIRATION_OPTIONS = [
"Apple",
"Linear",
"Cyberpunk",
"Editorial",
"Brutalist",
"Vaporwave",
"Swiss",
"Y2K",
"Organic",
"Noir",
];
/** The persona context passed to the AI to tailor generated assets. */
export interface Persona {
role?: string;
vibe?: string;
colors?: string[];
goal?: string;
}
export function isProfileComplete(p: Profile): boolean {
return p.displayName.trim().length > 0;
}
/** Map a profile to the AI persona, dropping empty fields; undefined if empty. */
export function buildPersona(p: Profile): Persona | undefined {
const persona: Persona = {};
if (p.title.trim()) persona.role = p.title.trim();
if (p.vibes.length) persona.vibe = p.vibes.join(", ");
if (p.brandColors.length) persona.colors = p.brandColors;
if (p.goal) persona.goal = p.goal;
return Object.keys(persona).length > 0 ? persona : undefined;
}
@@ -0,0 +1,44 @@
// The user profile (set during first-run onboarding) — MMKV-persisted, mirrors
// the localCardsStore pattern. `hydrated` flips once persist rehydrates so the
// first-launch gate doesn't flash before MMKV is read.
import { MMKV } from "react-native-mmkv";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { EMPTY_PROFILE, Profile } from "./profileLogic";
const storage = new MMKV({ id: "cardclaws-profile" });
interface ProfileState {
onboarded: boolean;
profile: Profile;
hydrated: boolean;
setProfile: (patch: Partial<Profile>) => void;
completeOnboarding: (p: Profile) => void;
reset: () => void;
}
export const useProfileStore = create<ProfileState>()(
persist(
(set) => ({
onboarded: false,
profile: EMPTY_PROFILE,
hydrated: false,
setProfile: (patch) => set((s) => ({ profile: { ...s.profile, ...patch } })),
completeOnboarding: (p) => set({ profile: p, onboarded: true }),
reset: () => set({ profile: EMPTY_PROFILE, onboarded: false }),
}),
{
name: "profile",
storage: createJSONStorage(() => ({
getItem: (k) => storage.getString(k) ?? null,
setItem: (k, v) => storage.set(k, v),
removeItem: (k) => storage.delete(k),
})),
partialize: (s) => ({ onboarded: s.onboarded, profile: s.profile }),
onRehydrateStorage: () => () => {
useProfileStore.setState({ hydrated: true });
},
},
),
);