feat(home): animated typewriter placeholder for the prompt composer

Cycle through localized example prompts with a typing/deleting animation
in the home composer placeholder, start the field empty instead of with
a default prompt, and style the home editor with larger prompt text.

Also rebrand the device-management copy from Lovart to Moteva.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-08 09:00:20 +08:00
parent 3c5006e2f3
commit ef45606a24
3 changed files with 101 additions and 4 deletions
+2 -2
View File
@@ -29,7 +29,7 @@ const dictionaries: Record<Locale, Dictionary> = {
email: "电子邮箱",
deviceManagement: "设备管理",
removeAllDevices: "移除全部设备",
removeAllDevicesDescription: "Lovart 账户仅限个人使用。为防止滥用并确保平台稳定,任务最多可同时在 2 个桌面 Web 会话和 1 个移动 Web 会话中运行。如需团队或多人使用,请购买 Team Plan。",
removeAllDevicesDescription: "Moteva 账户仅限个人使用。为防止滥用并确保平台稳定,任务最多可同时在 2 个桌面 Web 会话和 1 个移动 Web 会话中运行。如需团队或多人使用,请购买 Team Plan。",
onlineStatus: "在线状态",
deviceInfo: "设备信息",
currentDevice: "当前设备",
@@ -490,7 +490,7 @@ const dictionaries: Record<Locale, Dictionary> = {
email: "Email",
deviceManagement: "Device Management",
removeAllDevices: "Remove All Devices",
removeAllDevicesDescription: "A Lovart account is for personal use only. To prevent abuse and keep the platform stable, tasks can run in up to 2 desktop web sessions and 1 mobile web session at the same time. For team or multi-person use, please purchase Team Plan.",
removeAllDevicesDescription: "A Moteva account is for personal use only. To prevent abuse and keep the platform stable, tasks can run in up to 2 desktop web sessions and 1 mobile web session at the same time. For team or multi-person use, please purchase Team Plan.",
onlineStatus: "Online",
deviceInfo: "Device Info",
currentDevice: "Current Device",
+88 -2
View File
@@ -75,6 +75,41 @@ type HomeImageModel = {
credits: number;
};
type HomePromptPlaceholderBlueprint = {
prefix: string;
phrases: string[];
};
type TypewriterPhase = "typing" | "deleting" | "pausing";
const homePromptPlaceholderBlueprints: Record<Locale, HomePromptPlaceholderBlueprint> = {
"zh-CN": {
prefix: "让 Moteva ",
phrases: [
"设计一个令人惊艳的标志",
"创建一个温馨的咖啡厅风格菜单海报",
"生成一组水彩电影感插画分镜",
"为一家日系面包店设计完整品牌视觉",
"制作一张高转化的电商产品图"
]
},
"en-US": {
prefix: "Ask Moteva to ",
phrases: [
"design a striking logo",
"create a warm cafe-style menu poster",
"generate a watercolor cinematic storyboard",
"build a complete brand identity for a Japanese bakery",
"make a high-converting e-commerce product visual"
]
}
};
const HOME_PLACEHOLDER_TYPE_DELAY_MS = 48;
const HOME_PLACEHOLDER_DELETE_DELAY_MS = 24;
const HOME_PLACEHOLDER_HOLD_DELAY_MS = 1600;
const HOME_PLACEHOLDER_PAUSE_DELAY_MS = 260;
const promptModeBlueprints = [
{
id: "poster",
@@ -519,6 +554,55 @@ function hasPromptText(value: string) {
return value.replace(/\u00a0/g, " ").trim().length > 0;
}
function useHomePromptPlaceholder(locale: Locale) {
const blueprint = homePromptPlaceholderBlueprints[locale];
const [phraseIndex, setPhraseIndex] = useState(0);
const [visibleCharacters, setVisibleCharacters] = useState(0);
const [phase, setPhase] = useState<TypewriterPhase>("typing");
const phrase = blueprint.phrases[phraseIndex] ?? blueprint.phrases[0] ?? "";
const characters = useMemo(() => Array.from(phrase), [phrase]);
useEffect(() => {
setPhraseIndex(0);
setVisibleCharacters(0);
setPhase("typing");
}, [locale]);
useEffect(() => {
if (phase === "typing") {
if (visibleCharacters < characters.length) {
const timeoutId = window.setTimeout(() => {
setVisibleCharacters((count) => Math.min(count + 1, characters.length));
}, HOME_PLACEHOLDER_TYPE_DELAY_MS);
return () => window.clearTimeout(timeoutId);
}
const timeoutId = window.setTimeout(() => setPhase("deleting"), HOME_PLACEHOLDER_HOLD_DELAY_MS);
return () => window.clearTimeout(timeoutId);
}
if (phase === "deleting") {
if (visibleCharacters > 0) {
const timeoutId = window.setTimeout(() => {
setVisibleCharacters((count) => Math.max(count - 1, 0));
}, HOME_PLACEHOLDER_DELETE_DELAY_MS);
return () => window.clearTimeout(timeoutId);
}
const timeoutId = window.setTimeout(() => setPhase("pausing"), HOME_PLACEHOLDER_PAUSE_DELAY_MS);
return () => window.clearTimeout(timeoutId);
}
const timeoutId = window.setTimeout(() => {
setPhraseIndex((index) => (index + 1) % blueprint.phrases.length);
setPhase("typing");
}, HOME_PLACEHOLDER_PAUSE_DELAY_MS);
return () => window.clearTimeout(timeoutId);
}, [blueprint, characters.length, phase, visibleCharacters]);
return `${blueprint.prefix}${characters.slice(0, visibleCharacters).join("")}`;
}
type CreateProjectOptions = {
allowEmptyPrompt?: boolean;
};
@@ -535,7 +619,7 @@ export function HomePage() {
const promptComposerRef = useRef<PromptComposerHandle | null>(null);
const referenceImagesRef = useRef<UploadedReferenceImage[]>([]);
const [dashboard, setDashboard] = useState<Dashboard | null>(null);
const [prompt, setPrompt] = useState(() => t("defaultPrompt"));
const [prompt, setPrompt] = useState("");
const [referenceImages, setReferenceImages] = useState<UploadedReferenceImage[]>([]);
const [activeModeId, setActiveModeId] = useState("poster");
const [activeInspirationCategory, setActiveInspirationCategory] = useState("all");
@@ -571,6 +655,7 @@ export function HomePage() {
const selectedModel = imageModels.find((model) => model.id === selectedModelId) ?? imageModels[0];
const inspirationCategories = useMemo(() => getInspirationCategories(locale), [locale]);
const inspirationCatalog = useMemo(() => getInspirationCatalog(locale), [locale]);
const promptPlaceholder = useHomePromptPlaceholder(locale);
const activeMode = promptModes.find((mode) => mode.id === activeModeId) ?? promptModes[0];
const projects = dashboard?.projects ?? [];
const canSubmitPrompt = hasPromptText(prompt);
@@ -728,8 +813,9 @@ export function HomePage() {
<PromptComposer
ref={promptComposerRef}
text={prompt}
placeholder={t("promptPlaceholder")}
placeholder={promptPlaceholder}
ariaLabel={t("promptPlaceholder")}
className="home-prompt-editor"
removeLabel={t("removeReference")}
onTextChange={setPrompt}
onReferencesChange={syncReferenceImages}
+11
View File
@@ -137,6 +137,13 @@ button:disabled {
cursor: text;
}
.prompt-rich-editor.home-prompt-editor {
color: #1f2937;
font-size: 24px;
line-height: 1.45;
font-weight: 600;
}
.prompt-rich-editor[data-empty="true"]::before {
position: absolute;
top: 0;
@@ -146,6 +153,10 @@ button:disabled {
pointer-events: none;
}
.prompt-rich-editor.home-prompt-editor[data-empty="true"]::before {
color: #b5b7bc;
}
.prompt-card.has-references .prompt-rich-editor {
min-height: 118px;
}