mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
feat(web): new-user personalized onboarding — Home recommendation + Studio first-gen hint (#5111)
* feat(web): personalized first-run recommendation on Home + Studio handoff After a new user finishes the first-run questionnaire, Home shows one compact, personalized starting point (role/use-case -> product path via a static mapping) instead of the generic empty entry. Clicking it opens Studio with the recommended first request pre-filled in the composer (not auto-sent); the generic quick-start cards are suppressed while the composer carries content. Session-only, no persistence: the recommendation is memory-scoped and gone on refresh or for returning users. Adds the first-generation funnel events (onboarding_first_prompt_sent, onboarding_first_generation_completed) plus the recommendation surface_view / ui_click, all via the v2 analytics convention. i18n keys across all 19 locales. Spec: OD new-user personalized onboarding SS6-7, S11. * feat(web): one-time first-generation hint in Studio When a new user's first previewable artifact appears in Studio, show a single lightweight, dismissible note pointing them at view / edit / export. Shown at most once ever (localStorage), non-modal, in the preview corner so it never stacks with the post-turn next-step card (spec S8.5, one guide at a time). Fires onboarding_first_artifact_hint surface_view / ui_click via the v2 analytics convention. Spec: OD new-user personalized onboarding S8.3. * style(web): use real tokens in onboarding CSS modules --accent-contrast and --shadow don't exist in the token set; the fallbacks were silently in effect. Use the repo's plain-white-on-accent convention and the real --shadow-md elevation token (light/dark adaptive) instead. * feat(web): onboarding_prompt_prefilled event + is_first_session super property Completes the spec S11.1 funnel middle step: fire onboarding_prompt_prefilled once when Studio mounts with the recommended first request actually seeded in the composer (confirming the Home -> Studio handoff), carrying role / use_cases / product_type / recommendation_id through the session-only entry slot. Register is_first_session as a PostHog super property, pinned to the install's first-ever session id in localStorage. * fix(web): recommendation strip hover states + give browse-all a destination The global button:hover:not(:disabled) rule in primitives.css out-specifies plain .class:hover module rules, repainting the primary CTA to --bg-subtle (white label on near-white) and forcing a gray wash on the text actions. Match its specificity per the existing onboarding-cloud pattern: primary keeps its accent fill on hover; the text actions get a soft white wash instead of gray, and the underline is dropped. Browse-all previously just dismissed the strip with no feedback. It now also opens the New-project template picker — the concrete 'all types' catalogue the label promises (spec S7.4 通用选择). * fix(web): unify hover treatment of the two text actions on the recommendation strip 换一个 hovered orange while 浏览全部类型 stayed neutral — as peers with the same pill wash they now share one hover behavior (white wash + text darkens); only their resting text color carries the hierarchy. * fix(web): recommendation strip — card width, placed under the composer Full-page width below the template section read as an auxiliary notice bar at the bottom of Home — the one surface a brand-new user is meant to see first was the hardest to find. HomeHero gains an optional recommendationSlot rendered directly under the composer card (before the template section); the strip is constrained to the composer column (720px, centered) so it reads as a card in the same visual stack. * feat(web): path-scoped starter templates in Studio for recommendation projects The empty conversation above a pre-filled composer hid the quick-start cards entirely, leaving dead space; and the generic three cards are unrelated to what the user just told us they want. A recommendation-started project now shows its own product path's starters (e.g. prototype / component / low-fi for product_ui) as one-click composer replacements — clicking overwrites the draft. Generic projects and the general fallback keep the existing set. Supersedes the hide-when-prefilled gate from spec S8.5: with replace-on-click semantics the cards complement the pre-filled request instead of competing with it, so onDraftEmptyChange plumbing and its regression test are removed. Starter copy moves to a shared module (onboarding/starter-copy.ts) reused by the Home strip. * fix(web): icon-only cycle button on the recommendation strip The text 换一个试试 button crowded the 720px card and pushed 浏览全部类型 onto its own wrapped line. It is now an icon-only refresh button with the label in title/aria-label (zh copy shortened to 换一换); all three actions fit one row. * fix(web): tighten recommendation strip copy across locales Primary CTA drops the Studio jargon (在 Studio 中开始 -> 开始创作 / Start creating) and the fallback shortens to 全部类型 / All types; both updated across all 19 locales. * fix(web): primary CTA sits rightmost on the recommendation strip Action order is now cycle icon, 全部类型, then 开始创作 — the standard primary-on-the-right reading order. * feat(web): attention entrance for the recommendation card The old 200ms fade rode in with the page load and read as furniture. The card now enters 350ms after mount — motion-after-stillness, the only thing moving once Home settles — then a one-time accent glint sweeps it twice, mirroring HomeHero's attention-sheen invitation language. Both animations respect prefers-reduced-motion. * fix(web): recommendation card motion + hierarchy — push-down reveal, CTA glint, tinted primary Entrance is now the repo's canonical accordion reveal: the slot stays collapsed for 600ms, then expands (grid-rows 0fr -> 1fr) and pushes the template section down — displacement as the attention cue, replacing the card-wide sheen that read oddly on an optional surface. A one-time tinted glint then sweeps the CTA (the same click-me-next language home-hero puts on the send button). 开始创作 demotes from a solid accent fill to the tinted accent-secondary (NextStepActions Share vocabulary): the composer's 发送 stays the screen's one filled primary; first-time attention is carried by motion, not fill weight. The shown->clicked funnel can re-promote it later if data says so. * fix(web): drop the hover state on starter-card return glyph The accent-filled circle on hover reads as 'this will send' — the cards only fill the composer. Keep the glyph static; the card hover itself is the affordance. * fix(web): spend the first-artifact hint budget on dismiss, not on show Marking seen at mount meant any parent-gate flicker (files refresh / streaming blip unmounting and remounting the component) burned the once-ever hint before anyone could read it — reproduced live in the desktop app. The budget is now spent by the user's own close action; remounts before dismissal simply show the hint again, matching the spec's 'visible until closed or used'. * fix(web): anchor the first-artifact hint under the preview toolbar Bottom-right corner had zero directionality (and sat on the pet widget) — the hint talks about view/edit/export but pointed at nothing. It now sits directly below the artifact toolbar with an upward caret aimed at the share/download cluster, and slides down from it on entry. Offsets track the stable chrome heights, not preview internals. * feat(web): settle-then-bob entrance for the first-artifact hint 600ms delay lets the freshly-opened preview settle; the slide-down then ends in two decaying bobs (4px, 2px) — a nudge that says 'I appeared' without a glint, sized for a persistent hint rather than a toast. * feat(web): motion-driven drop-and-knock entrance for the first-artifact hint Fully opaque throughout — the 600ms settle window is a delayed mount, not an opacity fade (a see-through frame made the copy unreadable). The card then drops 28px from the toolbar and sets down (easeOut), holds a beat, and gives two equal 4px knocks with a short pause between them (easeInOut segments). Driven by motion/react (already a dependency; ProjectView uses AnimatePresence) with useReducedMotion opting the whole sequence out. * test(web): export useReducedMotion from the motion/react mock FirstArtifactHint is the first component to call it; without the export every jsdom test whose project fixtures include an .html file crashed the whole ProjectView render. * fix(web): first-artifact hint must never occlude or intercept the viewer toolbar The hardcoded top offset landed the card ON the preview's action toolbar in layouts with taller tab chrome, blocking comment/zoom clicks (caught by the Playwright critical suite). Two independent fixes: the card now anchors below the lowest visible .viewer-toolbar, measured imperatively via ref (state-driven repositioning re-rendered mid-flight and froze the motion keyframes) with re-measures while the project chrome settles; and the card is pointer-events: none (dismiss button excepted), so even a transient overlap can never swallow toolbar clicks. * fix(web): first-artifact hint copy leads with editing At the first-artifact moment the loop's next step is modify, not deliver (spec S8.3 orders 查看 -> 修改 -> 导出; export already has the next-step card's Share and the export-ready nudge). Body copy now leads with edit / annotate and closes with export-when-happy, matching the caret that points at the view/edit toolbar. All 19 locales. * fix(web): address onboarding review feedback (PR #5111) - contracts: re-add `amr_auth_result` / `file_version_restore_result` to the `AnalyticsEventName` union (they were present in `AnalyticsEventPayload` but missing from the name union) and add a compile-time drift guard so the two can never diverge again. - studio: gate the one-time first-generation hint on the consumed `onboardingEntryRef` so it only shows for projects started from the Home recommendation, not for returning users opening an existing HTML project. - analytics: make `isFirstSession()` a pure read and defer pinning the first-session marker to the consented analytics-init path (`pinFirstSessionForCapture`), so an install that boots with analytics off and opts in later still records its real first analytics session as first. - onboarding: stash the Home→Studio handoff entry only for a successful create/navigation; clear it when the create fails or is aborted so a later unrelated project mount can't consume the stale slot and mis-attribute itself as recommendation-started. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): address onboarding review follow-ups (PR #5111) - analytics: centralize the first-analytics-session pin in `getAnalyticsClient` (the single consent gate every caller funnels through), so the opt-in-later path — booting with metrics off then enabling them mid-session via `setConsent(true)` — also records the real first analytics session as first. Adds `analytics-first-session-consent` regression coverage. - onboarding: canonicalize `useCases` against questionnaire display order in `resolveProductType` / `buildRecommendation`, so a set of answers resolves to the same product bucket (and echoes the same telemetry) regardless of the chip click order. Adds selection-order-independence tests. - onboarding: create the project FIRST in `handleRecommendationStart` and only dismiss the Home recommendation once it actually opened; a failed/aborted create now keeps the recommendation mounted so the user can retry instead of dropping to a dead-end generic Home. Adds `RecommendedStartRegion.handoff` reject-path coverage. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): report has_prefilled_prompt from real send state (PR #5111) The onboarding_first_prompt_sent funnel event hard-coded has_prefilled_prompt to true for every recommendation-started project, but the user can edit, clear, replace, or starter-swap the prefilled composer before sending. Snapshot the seed prompt at mount and compare it against the actually-sent prompt via a new `sentPrefilledPrompt` invariant helper, so the send-through split reflects whether the suggestion was really used. Adds unit coverage for the unmodified / edited / cleared / no-seed cases. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): pin first analytics session only after client init succeeds pinFirstSessionForCapture() ran right after the config check but before import('posthog-js') / posthog.init(), so a boot where init threw returned null from the catch while the marker was already burned — a later real session would then report is_first_session=false despite never capturing. Move the pin to the point where init is known to have succeeded (after client = posthog, before return), and add a "config enabled but client init fails" regression case. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): key onboarding handoff by project id; gate first-gen on artifact Address two review findings on PR #5111: - The Home→Studio onboarding entry was stashed in a single global sessionStorage slot before create resolved, racing any concurrent project navigation: an unrelated project mounting mid-create could consume the slot and steal the personalized starter/hint/funnel context. Key the handoff by the created project id and move the stash into the create success path (App.handleCreateProject), where the id is finally known. A failed/aborted create now leaves nothing behind, and a concurrent mount reads its own (absent) key. - onboarding_first_generation_completed fired on any succeeded run, even one producing only text or a clarifying question, overstating the funnel and diverging from the first-artifact hint. Gate it on the same artifact-producing condition the hint uses (a produced previewable .html), via a new producedPreviewableArtifact invariant. Tests: keyed per-project isolation (race regression), handoff no longer touches sessionStorage, and producedPreviewableArtifact incl. a succeeded run with no artifact. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): StrictMode-safe onboarding handoff, visible start failure, gate hint impression Address three review findings on PR #5111: - consumeOnboardingEntryForProject is a destructive read called from ProjectView render; under reactStrictMode the throwaway first mount consumed the sessionStorage slot and the committed remount saw null, dropping every recommendation-only behavior in dev. Cache the first parsed entry per project id in the helper so both StrictMode passes (and later remounts) get the value while the slot is still cleared on first read. Adds a double-consume regression test. - EntryShell.handleRecommendationStart swallowed create failures in a catch and RecommendedStartRegion discarded the promise, so a failed "Start creating" was a silent no-op. Let the failure propagate; HomeView now routes it into the same error channel other entry actions use (setError + new home.recommendation.startFailed string in all locales), and RecommendedStartRegion awaits with a pending state that re-enables for retry on failure. - FirstArtifactHint fired its surface_view impression before `settled`, while the card still rendered null, counting mounts removed during the 600ms settle window as impressions the user never saw. Gate the effect on settled. Adds an impression-timing regression test. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): make onboarding funnel guards project-scoped and gate first-prompt after pre-run gates Address two review findings on PR #5111: - The once-per-project funnel guards (first_prompt_sent / first_generation_completed) were mount-local refs. Now that the onboarding entry survives ProjectView remounts via the project-id cache, reopening a project reset the refs while the entry kept returning, so both events could re-fire on a later conversation/run. Move the guards into project-keyed module flags beside the entry cache so they hold across remounts (within the session, which is the entry's lifetime). - onboarding_first_prompt_sent was emitted before the queue-only / busy / AMR balance pre-run gates, which can abort the send without creating a run. A hard AMR gate then showed the sign-in/recharge dialog while the event had already fired and the once-only guard suppressed the real retry. Move the tracking below those gates, to the point the send is committed to creating a run. Adds regression coverage for the project-scoped guards (survive remount, per-id isolation). Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): cache onboarding seed prompt with the entry so it survives reopen-before-send Address review on PR #5111: onboardingSeedPromptRef was rebuilt only from project.pendingPrompt, which onClearPendingPrompt wipes on the first mount. Since the onboarding entry now survives ProjectView remounts via the project-id cache, a user who accepts a recommendation, opens Studio, leaves before sending, then reopens the same project in-session kept the entry but lost the seed — so onboarding_first_prompt_sent reported has_prefilled_prompt=false even when they sent the original recommended draft, undercounting the accepted-recommendation path. Cache the seed prompt WITH the entry (new OnboardingEntry.seedPrompt, stashed at create time in App.handleCreateProject). ProjectView prefers the cached seed and falls back to pendingPrompt. Adds a reopen-before-send regression. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: xiaoche-hub <298951296+xiaoche-hub@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
xiaoche-hub
parent
38c20ef6fa
commit
4f5bdeed1d
@@ -9,6 +9,10 @@ import {
|
||||
import { deriveUploadCohort } from './analytics/upload-tracking';
|
||||
import { setPendingDesignSystemCreateEntry } from './analytics/ds-create-entry';
|
||||
import { detectClientType } from './analytics/identity';
|
||||
import {
|
||||
stashOnboardingEntryForProject,
|
||||
type OnboardingEntry,
|
||||
} from './onboarding/onboarding-entry';
|
||||
import {
|
||||
deriveConfigureGlobals,
|
||||
projectKindFromMetadataToTracking,
|
||||
@@ -140,6 +144,7 @@ type AppCreateProjectInput = Omit<CreateInput, 'metadata'> & {
|
||||
pendingFiles?: File[];
|
||||
userWorkingDirToken?: string;
|
||||
linkedDirs?: string[] | null;
|
||||
onboardingEntry?: OnboardingEntry;
|
||||
};
|
||||
|
||||
const APP_CONFIG_CHANGED_EVENT = 'open-design:app-config-changed';
|
||||
@@ -1576,6 +1581,23 @@ function AppInner() {
|
||||
back to manual send. */
|
||||
}
|
||||
}
|
||||
// Home recommendation handoff: now that the project exists and its id is
|
||||
// known, stash the onboarding entry keyed by that id. Studio consumes it
|
||||
// by the same id on mount. Keying by id (instead of a single global slot
|
||||
// written before create) removes the race where opening an unrelated
|
||||
// project mid-create could steal the personalized funnel context, and
|
||||
// means a failed/aborted create leaves nothing behind.
|
||||
if (input.onboardingEntry) {
|
||||
// Cache the prefilled seed prompt WITH the entry so the first-prompt
|
||||
// funnel's `has_prefilled_prompt` comparison base survives a
|
||||
// reopen-before-send (project.pendingPrompt is wiped on first mount).
|
||||
stashOnboardingEntryForProject(result.project.id, {
|
||||
...input.onboardingEntry,
|
||||
...(derivedPendingPrompt
|
||||
? { seedPrompt: derivedPendingPrompt.trim() }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
const project = result.appliedPluginSnapshotId
|
||||
? {
|
||||
...result.project,
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
clearExceptionTrackingContext,
|
||||
setExceptionTrackingContext,
|
||||
} from './error-tracking';
|
||||
import { pinFirstSessionForCapture } from './identity';
|
||||
|
||||
interface AnalyticsContext {
|
||||
anonymousId: string;
|
||||
@@ -22,6 +23,10 @@ interface AnalyticsContext {
|
||||
clientType: AnalyticsClientType;
|
||||
locale: string;
|
||||
appVersion: string;
|
||||
// Whether this is the install's first analytics session (see
|
||||
// identity.ts#isFirstSession). Optional so callers that don't care
|
||||
// (error tracking) can omit it.
|
||||
isFirstSession?: boolean;
|
||||
}
|
||||
|
||||
let client: PostHog | null = null;
|
||||
@@ -337,6 +342,10 @@ export async function getAnalyticsClient(
|
||||
client_type: context.clientType,
|
||||
locale: context.locale,
|
||||
session_id: context.sessionId,
|
||||
// Onboarding-funnel dimension (spec §11.1 common fields).
|
||||
...(context.isFirstSession !== undefined
|
||||
? { is_first_session: context.isFirstSession }
|
||||
: {}),
|
||||
// v2 rename: was `anonymous_id`. Value is unchanged — the same
|
||||
// installationId / local-UUID fallback.
|
||||
device_id: distinctId,
|
||||
@@ -363,6 +372,15 @@ export async function getAnalyticsClient(
|
||||
},
|
||||
});
|
||||
client = posthog;
|
||||
// Pin the first-analytics-session marker only now — init returned without
|
||||
// throwing and capture is live. This is the single consent gate every
|
||||
// caller (mount, locale effect, track(), and the setConsent opt-in
|
||||
// re-init) funnels through, so an install that first booted with
|
||||
// analytics OFF and opts in later records its real first analytics
|
||||
// session as first. Pinning earlier (before import()/init) would burn the
|
||||
// marker on a boot where init actually failed and no session was ever
|
||||
// captured (see identity.ts#isFirstSession). Idempotent + best-effort.
|
||||
pinFirstSessionForCapture();
|
||||
flushPersonProperties();
|
||||
return posthog;
|
||||
} catch {
|
||||
|
||||
@@ -73,6 +73,10 @@ import type {
|
||||
AmrAuthResultProps,
|
||||
AmrEntryClickProps,
|
||||
RunFailedToastSurfaceViewProps,
|
||||
HomeRecommendationClickProps,
|
||||
HomeRecommendationSurfaceViewProps,
|
||||
StudioOnboardingHintClickProps,
|
||||
StudioOnboardingHintSurfaceViewProps,
|
||||
ChatPanelResourcesPopoverClickProps,
|
||||
ChatPanelMessageQueueClickProps,
|
||||
FileManagerClickProps,
|
||||
@@ -131,6 +135,9 @@ import type {
|
||||
OnboardingClickProps,
|
||||
OnboardingRuntimeScanResultProps,
|
||||
OnboardingCompleteResultProps,
|
||||
OnboardingPromptPrefilledProps,
|
||||
OnboardingFirstPromptSentProps,
|
||||
OnboardingFirstGenerationCompletedProps,
|
||||
DesignSystemSourceIngestResultProps,
|
||||
DesignSystemCreateResultProps,
|
||||
DesignSystemReviewResultProps,
|
||||
@@ -258,6 +265,36 @@ export function trackRunFailedToastGoAmrClick(
|
||||
send(track, 'ui_click', props);
|
||||
}
|
||||
|
||||
// Personalized first-run recommendation on Home (spec §7).
|
||||
export function trackHomeRecommendationSurfaceView(
|
||||
track: Track,
|
||||
props: HomeRecommendationSurfaceViewProps,
|
||||
): void {
|
||||
send(track, 'surface_view', props);
|
||||
}
|
||||
|
||||
export function trackHomeRecommendationClick(
|
||||
track: Track,
|
||||
props: HomeRecommendationClickProps,
|
||||
): void {
|
||||
send(track, 'ui_click', props);
|
||||
}
|
||||
|
||||
// First-generation Studio hint (spec §8.3).
|
||||
export function trackStudioOnboardingHintSurfaceView(
|
||||
track: Track,
|
||||
props: StudioOnboardingHintSurfaceViewProps,
|
||||
): void {
|
||||
send(track, 'surface_view', props);
|
||||
}
|
||||
|
||||
export function trackStudioOnboardingHintClick(
|
||||
track: Track,
|
||||
props: StudioOnboardingHintClickProps,
|
||||
): void {
|
||||
send(track, 'ui_click', props);
|
||||
}
|
||||
|
||||
export function trackAmrEntryClick(
|
||||
track: Track,
|
||||
props: AmrEntryClickProps,
|
||||
@@ -1082,6 +1119,28 @@ export function trackOnboardingCompleteResult(
|
||||
send(track, 'onboarding_complete_result', props);
|
||||
}
|
||||
|
||||
// First-generation funnel (spec §11.1).
|
||||
export function trackOnboardingPromptPrefilled(
|
||||
track: Track,
|
||||
props: OnboardingPromptPrefilledProps,
|
||||
): void {
|
||||
send(track, 'onboarding_prompt_prefilled', props);
|
||||
}
|
||||
|
||||
export function trackOnboardingFirstPromptSent(
|
||||
track: Track,
|
||||
props: OnboardingFirstPromptSentProps,
|
||||
): void {
|
||||
send(track, 'onboarding_first_prompt_sent', props);
|
||||
}
|
||||
|
||||
export function trackOnboardingFirstGenerationCompleted(
|
||||
track: Track,
|
||||
props: OnboardingFirstGenerationCompletedProps,
|
||||
): void {
|
||||
send(track, 'onboarding_first_generation_completed', props);
|
||||
}
|
||||
|
||||
// ---- Design-system lifecycle ---------------------------------------------
|
||||
//
|
||||
// `trackDesignSystem*Result` cover the five lifecycle moments in the
|
||||
|
||||
@@ -56,6 +56,51 @@ export function getSessionId(): string {
|
||||
}
|
||||
}
|
||||
|
||||
const FIRST_SESSION_ID_KEY = 'open-design:analytics.first_session_id';
|
||||
|
||||
// Whether the current browser session is this install's FIRST analytics
|
||||
// session. The first analytics session's `getSessionId()` value is pinned in
|
||||
// localStorage by `pinFirstSessionForCapture()`; the flag stays true for the
|
||||
// whole lifetime of that tab session (repeat calls compare against the pin
|
||||
// rather than re-deriving) and is false for every later session. Registered as
|
||||
// the `is_first_session` super property so onboarding funnels can split
|
||||
// first-run behavior from returning visits.
|
||||
//
|
||||
// This read is PURE: it never writes the pin. Persisting the pin is deferred to
|
||||
// `pinFirstSessionForCapture()`, which runs only once capture is actually
|
||||
// enabled (post-consent). Otherwise an install that first boots with analytics
|
||||
// OFF and opts in later would have its real first *analytics* session
|
||||
// mislabeled `is_first_session=false`, systematically skewing the onboarding
|
||||
// funnel split. Rollout caveat: installs that predate this marker report one
|
||||
// mislabeled `true` session on their first captured boot. Storage-denied
|
||||
// contexts report false — we'd rather under-count first sessions than throw.
|
||||
export function isFirstSession(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
try {
|
||||
const pinned = window.localStorage.getItem(FIRST_SESSION_ID_KEY);
|
||||
if (!pinned) return true;
|
||||
return pinned === getSessionId();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Persist the first-analytics-session marker. Call this ONLY from the consented
|
||||
// analytics-init path (i.e. once `/api/analytics/config` confirms capture is
|
||||
// enabled), never on unconditional provider mount — see `isFirstSession()` for
|
||||
// why. No-op once a pin already exists, and never throws in storage-denied
|
||||
// contexts.
|
||||
export function pinFirstSessionForCapture(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
if (window.localStorage.getItem(FIRST_SESSION_ID_KEY)) return;
|
||||
window.localStorage.setItem(FIRST_SESSION_ID_KEY, getSessionId());
|
||||
} catch {
|
||||
// Privacy mode / quota — losing the pin only risks a later session being
|
||||
// mislabeled first; never throw out of an analytics path.
|
||||
}
|
||||
}
|
||||
|
||||
// Claim the next 0-based run turn index for the current browser analytics
|
||||
// session and advance the counter. Lives in sessionStorage so it shares the
|
||||
// exact lifetime of the `session_id` above — both reset together when the tab
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
detectClientType,
|
||||
getAnonymousId,
|
||||
getSessionId,
|
||||
isFirstSession,
|
||||
} from './identity';
|
||||
import { randomUUID } from '../utils/uuid';
|
||||
|
||||
@@ -166,6 +167,8 @@ export function AnalyticsProvider({ children }: { children: ReactNode }) {
|
||||
anonymousId: getAnonymousId(),
|
||||
sessionId: getSessionId(),
|
||||
clientType: detectClientType(),
|
||||
// Pinned once per tab session (spec §11.1 common field).
|
||||
isFirstSession: isFirstSession(),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
@@ -188,6 +191,7 @@ export function AnalyticsProvider({ children }: { children: ReactNode }) {
|
||||
anonymousId: identity.anonymousId,
|
||||
sessionId: identity.sessionId,
|
||||
clientType: identity.clientType,
|
||||
isFirstSession: identity.isFirstSession,
|
||||
locale,
|
||||
appVersion: resolvedAppVersion,
|
||||
});
|
||||
@@ -195,6 +199,7 @@ export function AnalyticsProvider({ children }: { children: ReactNode }) {
|
||||
anonymousId: identity.anonymousId,
|
||||
sessionId: identity.sessionId,
|
||||
clientType: identity.clientType,
|
||||
isFirstSession: identity.isFirstSession,
|
||||
locale,
|
||||
appVersion: resolvedAppVersion,
|
||||
});
|
||||
@@ -252,6 +257,7 @@ export function AnalyticsProvider({ children }: { children: ReactNode }) {
|
||||
anonymousId: identity.anonymousId,
|
||||
sessionId: identity.sessionId,
|
||||
clientType: identity.clientType,
|
||||
isFirstSession: identity.isFirstSession,
|
||||
locale: locale,
|
||||
appVersion: resolvedAppVersion,
|
||||
});
|
||||
@@ -310,6 +316,7 @@ export function AnalyticsProvider({ children }: { children: ReactNode }) {
|
||||
anonymousId: identity.anonymousId,
|
||||
sessionId: identity.sessionId,
|
||||
clientType: identity.clientType,
|
||||
isFirstSession: identity.isFirstSession,
|
||||
locale: locale,
|
||||
appVersion: resolvedAppVersion,
|
||||
});
|
||||
@@ -350,6 +357,7 @@ export function AnalyticsProvider({ children }: { children: ReactNode }) {
|
||||
anonymousId: identity.anonymousId,
|
||||
sessionId: identity.sessionId,
|
||||
clientType: identity.clientType,
|
||||
isFirstSession: identity.isFirstSession,
|
||||
locale,
|
||||
appVersion: resolvedAppVersion,
|
||||
});
|
||||
|
||||
@@ -20,6 +20,8 @@ import { getResolvedDeviceId } from '../analytics/client';
|
||||
import { trackChatPanelClick, trackMessageQueueClick, trackRunFailedToastSurfaceView } from '../analytics/events';
|
||||
import { amrHandoffDeviceId, attributedAmrUrl, recordAmrEntry } from '../analytics/amr-attribution';
|
||||
import { useT } from '../i18n';
|
||||
import { startersForProduct, type ProductType } from '../onboarding/recommendation';
|
||||
import { starterCopyFor } from '../onboarding/starter-copy';
|
||||
import {
|
||||
FEATURED_DESIGN_TOOLBOX_ACTION_IDS,
|
||||
findDesignToolboxSkill,
|
||||
@@ -95,6 +97,7 @@ type TranslateFn = (key: keyof Dict, vars?: Record<string, string | number>) =>
|
||||
type StarterPrompt = {
|
||||
icon: string;
|
||||
title: string;
|
||||
// Empty for path-scoped onboarding starters, which have no category tag.
|
||||
tag: string;
|
||||
prompt: string;
|
||||
};
|
||||
@@ -510,6 +513,10 @@ interface Props {
|
||||
// time before the full `tool_use` arrives. Never persisted.
|
||||
liveToolInput?: Record<string, { name: string; text: string; seq?: number }>;
|
||||
initialDraft?: string;
|
||||
// Product path of the Home recommendation that started this project. When
|
||||
// set (and concrete), the empty-conversation starter cards show that path's
|
||||
// starters — one-click composer replacements — instead of the generic set.
|
||||
onboardingStarterPath?: ProductType | null;
|
||||
composerPlaceholder?: string;
|
||||
// Focus the right-hand Questions tab from the chat banner.
|
||||
onOpenQuestions?: (request?: QuestionFormOpenRequest) => void;
|
||||
@@ -796,6 +803,7 @@ export function ChatPane({
|
||||
forceStreamingMessageIds,
|
||||
liveToolInput,
|
||||
initialDraft,
|
||||
onboardingStarterPath = null,
|
||||
composerPlaceholder,
|
||||
onOpenQuestions,
|
||||
onContinueRemainingTasks,
|
||||
@@ -1029,6 +1037,19 @@ export function ChatPane({
|
||||
})),
|
||||
[projectMetadata, t],
|
||||
);
|
||||
// Empty-conversation starter cards. A recommendation-started project shows
|
||||
// its OWN product path's starters — clicking replaces the composer draft, so
|
||||
// the pre-filled first request and the cards complement rather than compete.
|
||||
// The general fallback path and every other project keep the generic set.
|
||||
const starterTemplateCards = useMemo<StarterPrompt[]>(() => {
|
||||
if (onboardingStarterPath && onboardingStarterPath !== 'general') {
|
||||
return startersForProduct(onboardingStarterPath).map((starter) => {
|
||||
const copy = starterCopyFor(starter.id);
|
||||
return { icon: '✦', title: t(copy.title), tag: '', prompt: t(copy.firstPrompt) };
|
||||
});
|
||||
}
|
||||
return pickStarters(projectMetadata, t);
|
||||
}, [onboardingStarterPath, projectMetadata, t]);
|
||||
const followUpComposerScenarios = useMemo<PlaceholderScenario[]>(() => {
|
||||
if (nextStepVariant === 'design-system') {
|
||||
return DESIGN_SYSTEM_NEXT_STEP_ACTIONS.map((action) => ({
|
||||
@@ -2209,7 +2230,7 @@ export function ChatPane({
|
||||
</span>
|
||||
</div>
|
||||
<div className="chat-examples" role="list">
|
||||
{pickStarters(projectMetadata, t).map((ex, i) => (
|
||||
{starterTemplateCards.map((ex, i) => (
|
||||
<button
|
||||
key={`${ex.title}-${i}`}
|
||||
type="button"
|
||||
@@ -2232,7 +2253,9 @@ export function ChatPane({
|
||||
<span className="chat-example-body">
|
||||
<span className="chat-example-head">
|
||||
<span className="chat-example-title">{ex.title}</span>
|
||||
<span className="chat-example-tag">{ex.tag}</span>
|
||||
{ex.tag ? (
|
||||
<span className="chat-example-tag">{ex.tag}</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="chat-example-prompt">{ex.prompt}</span>
|
||||
</span>
|
||||
|
||||
@@ -110,6 +110,11 @@ import {
|
||||
createPluginUseHandoff,
|
||||
type HomePromptHandoff,
|
||||
} from './home-hero/plugin-authoring';
|
||||
import {
|
||||
buildRecommendation,
|
||||
type Recommendation,
|
||||
} from '../onboarding/recommendation';
|
||||
import type { OnboardingEntry } from '../onboarding/onboarding-entry';
|
||||
import { ONBOARDING_ARTIFACT_CHIP_IDS } from './home-hero/chips';
|
||||
import { homeHeroChipLabel } from './home-hero/chip-labels';
|
||||
import type { PluginUseAction } from './plugins-home/useActions';
|
||||
@@ -252,6 +257,7 @@ type EntryCreateProjectInput = Omit<CreateInput, 'metadata'> & {
|
||||
pendingFiles?: File[];
|
||||
userWorkingDirToken?: string;
|
||||
linkedDirs?: string[] | null;
|
||||
onboardingEntry?: OnboardingEntry;
|
||||
};
|
||||
|
||||
function defaultPluginIdForMetadata(metadata: ProjectMetadata): string | null {
|
||||
@@ -567,6 +573,12 @@ export function EntryShell({
|
||||
useState<CreateTab>('prototype');
|
||||
const [integrationTab, setIntegrationTab] = useState<IntegrationTab>(integrationInitialTab);
|
||||
const [homePromptHandoff, setHomePromptHandoff] = useState<HomePromptHandoff | null>(null);
|
||||
// Personalized first-run starting point. Computed once, in memory, when the
|
||||
// user finishes the About-you survey with real answers (see
|
||||
// `finishOnboarding`); null for returning users, skipped/blank surveys, and
|
||||
// after any page refresh (deliberately not persisted, per onboarding spec
|
||||
// §7.1). Cleared as soon as the user takes any concrete entry (spec §7.4).
|
||||
const [onboardingRec, setOnboardingRec] = useState<Recommendation | null>(null);
|
||||
const entryMainScrollRef = useRef<HTMLElement | null>(null);
|
||||
const analytics = useAnalytics();
|
||||
const discordOnlineLabel = discordPresence
|
||||
@@ -650,6 +662,9 @@ export function EntryShell({
|
||||
// is intentionally explicit so future kind-specific scenarios
|
||||
// (e.g. a deck- or image-specialized pipeline) can take over a
|
||||
// single row without touching the form.
|
||||
// New-project modal / template / import is a concrete entry — retire any
|
||||
// pending Home recommendation (spec §7.1 / §7.4).
|
||||
dismissRecommendation();
|
||||
const pluginId = defaultPluginIdForMetadata(input.metadata);
|
||||
const pluginInputs = defaultPluginInputsForCreate(input, pluginId);
|
||||
return onCreateProject({
|
||||
@@ -714,6 +729,11 @@ export function EntryShell({
|
||||
// auto-send, which must not re-prompt what the user just answered.
|
||||
amrGatePrechecked = true;
|
||||
}
|
||||
// Starting from the Home composer is a concrete entry — retire the
|
||||
// recommendation (spec §7.4). Done only once the submit actually proceeds
|
||||
// to create (past any AMR balance gate) so a blocked/cancelled submit
|
||||
// leaves the recommendation intact for the user.
|
||||
dismissRecommendation();
|
||||
const summarizedName = summarizeProjectNameFromPrompt(payload.prompt);
|
||||
const head = payload.prompt.trim().split(/\s+/).slice(0, 8).join(' ');
|
||||
const firstAttachmentName = payload.attachments?.[0]?.name ?? '';
|
||||
@@ -783,11 +803,63 @@ export function EntryShell({
|
||||
});
|
||||
}
|
||||
|
||||
function finishOnboarding() {
|
||||
// Called when the welcome flow ends. `survey` is present on the About-you
|
||||
// completion paths; we only build a recommendation when the user actually
|
||||
// provided a role or use-case, so a skipped/blank survey lands on the
|
||||
// generic Home entry (spec §6.2 / §7.1).
|
||||
function finishOnboarding(survey?: { role: string; useCases: string[] }) {
|
||||
if (survey && (survey.role.trim() || survey.useCases.length > 0)) {
|
||||
setOnboardingRec(buildRecommendation(survey));
|
||||
}
|
||||
onCompleteOnboarding();
|
||||
changeView('home');
|
||||
}
|
||||
|
||||
// Drop the personalized recommendation. Fired when the user browses all
|
||||
// types, or as soon as they take any other concrete entry, so Home never
|
||||
// re-shows a recommendation the user has moved past (spec §7.4).
|
||||
function dismissRecommendation() {
|
||||
setOnboardingRec((current) => (current ? null : current));
|
||||
}
|
||||
|
||||
// "进入 Studio" from the Home recommendation. Creates the project with the
|
||||
// recommended first request pre-filled into the composer but NOT auto-sent —
|
||||
// the user keeps control and can edit or clear it (spec §7.4 / §8.2).
|
||||
async function handleRecommendationStart(input: {
|
||||
name: string;
|
||||
prompt: string;
|
||||
metadata: ProjectMetadata;
|
||||
onboardingEntry: OnboardingEntry;
|
||||
}): Promise<boolean> {
|
||||
const pluginId = defaultPluginIdForMetadata(input.metadata);
|
||||
// Create FIRST, then tear down the recommendation only once it actually
|
||||
// opened. Dismissing up-front turned a transient create/navigation failure
|
||||
// into an onboarding dead-end: the user dropped back to generic Home with
|
||||
// no way to retry the starter they just picked. On failure we keep the
|
||||
// recommendation mounted. The onboarding entry rides along so the create
|
||||
// success path stashes it keyed by the created project id — nothing is
|
||||
// written on failure.
|
||||
//
|
||||
// Do NOT swallow the failure here: `onCreateProject` throws on real create
|
||||
// failures, and a silent `catch` would leave the CTA looking clickable with
|
||||
// no feedback. Let the error propagate so Home surfaces it in the same
|
||||
// error channel the other entry actions use (HomeView owns `setError`), and
|
||||
// return `false` for a clean no-project result so the caller can retry.
|
||||
const ok =
|
||||
(await onCreateProject({
|
||||
name: input.name,
|
||||
skillId: null,
|
||||
designSystemId: null,
|
||||
metadata: input.metadata,
|
||||
pendingPrompt: input.prompt,
|
||||
...(pluginId ? { pluginId } : {}),
|
||||
autoSendFirstMessage: false,
|
||||
onboardingEntry: input.onboardingEntry,
|
||||
})) !== false;
|
||||
if (ok) dismissRecommendation();
|
||||
return ok;
|
||||
}
|
||||
|
||||
const avatarMenu = (
|
||||
<EntrySettingsMenu
|
||||
config={config}
|
||||
@@ -1025,6 +1097,9 @@ export function EntryShell({
|
||||
skillsLoading={skillsLoading}
|
||||
connectors={connectors}
|
||||
promptTemplates={promptTemplates}
|
||||
recommendation={onboardingRec}
|
||||
onRecommendationStart={handleRecommendationStart}
|
||||
onRecommendationDismiss={dismissRecommendation}
|
||||
executionSwitcher={view === 'home' ? homeExecutionSwitcher : undefined}
|
||||
/>
|
||||
</div>
|
||||
@@ -1195,7 +1270,9 @@ function OnboardingView({
|
||||
onApiModelChange: (model: string) => void;
|
||||
onConfigPersist: (cfg: AppConfig) => Promise<void> | void;
|
||||
onRefreshAgents: () => Promise<AgentInfo[]> | AgentInfo[];
|
||||
onFinish: () => void;
|
||||
// `survey` is passed on the About-you completion paths (not on skip) so the
|
||||
// shell can build a personalized Home recommendation.
|
||||
onFinish: (survey?: { role: string; useCases: string[] }) => void;
|
||||
onThemeChange: (theme: AppTheme) => void;
|
||||
onGoBuild: () => void;
|
||||
}) {
|
||||
@@ -1887,7 +1964,10 @@ function OnboardingView({
|
||||
}
|
||||
if (isLastStep) {
|
||||
await runOnboardingCompletion('completed_without_design_system');
|
||||
onFinish();
|
||||
onFinish({
|
||||
role: profileRef.current.role,
|
||||
useCases: profileRef.current.useCase,
|
||||
});
|
||||
return;
|
||||
}
|
||||
emitOnboardingClick('continue', 'continue');
|
||||
@@ -1955,7 +2035,10 @@ function OnboardingView({
|
||||
async function handleFinishToHome(): Promise<void> {
|
||||
if (newsletterSubmitting) return;
|
||||
await runOnboardingCompletion('completed_without_design_system');
|
||||
onFinish();
|
||||
onFinish({
|
||||
role: profileRef.current.role,
|
||||
useCases: profileRef.current.useCase,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleFinishToBuild(): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/* One-time first-artifact hint. Anchored directly UNDER the artifact
|
||||
toolbar (top-right: 分享 / 下载 / edit cluster) with an upward caret, so
|
||||
the copy "view, edit, and export from the preview" physically points at
|
||||
the controls that do it — a corner toast had no directionality and sat on
|
||||
top of the pet widget. Offsets track the stable chrome heights (tab strip
|
||||
+ artifact header ≈ 70px), not the in-flight preview internals. */
|
||||
.root {
|
||||
position: fixed;
|
||||
right: 24px;
|
||||
/* top is set inline by the component: measured below the viewer toolbar. */
|
||||
top: 78px;
|
||||
z-index: 40;
|
||||
/* Belt-and-braces: the card never intercepts clicks meant for controls it
|
||||
might momentarily overlap during layout shifts — only the dismiss button
|
||||
is interactive. */
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
max-width: 320px;
|
||||
padding: 12px 12px 12px 14px;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 26%, var(--border));
|
||||
border-radius: 12px;
|
||||
background: var(--bg-panel);
|
||||
box-shadow: var(--shadow-md);
|
||||
/* Entrance motion lives in the component (motion/react): 600ms settle
|
||||
delay, slide-down, then a double knock with easeInOut segments. */
|
||||
}
|
||||
|
||||
/* Upward caret aimed at the share/download cluster above. Same surface and
|
||||
border as the card; rotated square, clipped by stacking order. */
|
||||
.root::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: 120px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
transform: rotate(45deg);
|
||||
background: var(--bg-panel);
|
||||
border-top: 1px solid color-mix(in srgb, var(--accent) 26%, var(--border));
|
||||
border-left: 1px solid color-mix(in srgb, var(--accent) 26%, var(--border));
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
margin-top: 1px;
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.text {
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.dismiss {
|
||||
pointer-events: auto;
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
color: var(--text-muted);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 140ms cubic-bezier(0.23, 1, 0.32, 1),
|
||||
color 140ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
|
||||
.dismiss:hover:not(:disabled) {
|
||||
/* :not(:disabled) matches the global button:hover rule in primitives.css
|
||||
so it can't repaint this to --bg-subtle. */
|
||||
color: var(--text);
|
||||
background: color-mix(in srgb, var(--text) 8%, transparent);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { motion, useReducedMotion } from 'motion/react';
|
||||
import { useT } from '../i18n';
|
||||
import { useAnalytics } from '../analytics/provider';
|
||||
import {
|
||||
trackStudioOnboardingHintClick,
|
||||
trackStudioOnboardingHintSurfaceView,
|
||||
} from '../analytics/events';
|
||||
import {
|
||||
hasSeenFirstArtifactHint,
|
||||
markFirstArtifactHintSeen,
|
||||
} from '../onboarding/first-artifact-hint';
|
||||
import { Icon } from './Icon';
|
||||
import styles from './FirstArtifactHint.module.css';
|
||||
|
||||
// One-time, one-line hint shown when a new user's first previewable artifact
|
||||
// appears in Studio (spec §8.3). The once-ever budget is spent when the USER
|
||||
// dismisses it — not on show — so parent-gate flicker (a transient files
|
||||
// refresh or streaming blip unmounting and remounting this component) can't
|
||||
// silently burn the hint before anyone reads it. Remounts before dismissal
|
||||
// simply show it again, which matches the spec: visible until closed or used.
|
||||
// Kept deliberately small and non-modal so it never stacks as a second guide
|
||||
// against the post-turn NextStepActions card (spec §8.5: one main guide at a
|
||||
// time) — it sits in the preview corner while NextStepActions lives in the
|
||||
// chat.
|
||||
export function FirstArtifactHint() {
|
||||
const t = useT();
|
||||
const analytics = useAnalytics();
|
||||
const reducedMotion = useReducedMotion();
|
||||
const [visible, setVisible] = useState(() => !hasSeenFirstArtifactHint());
|
||||
// Delayed mount replaces an opacity fade: the card appears fully opaque
|
||||
// (no see-through frame, per review) — the 600ms settle window is simply
|
||||
// "not rendered yet".
|
||||
const [settled, setSettled] = useState(false);
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => setSettled(true), 600);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, []);
|
||||
// Anchor below the preview's action toolbar wherever it currently sits —
|
||||
// tab strips above it vary per layout, so a hardcoded offset can land ON
|
||||
// the toolbar and occlude its controls (caught by the Playwright critical
|
||||
// suite). Re-measured on resize; falls back to the chrome-height estimate
|
||||
// when no viewer toolbar is mounted.
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (!settled) return;
|
||||
const measure = () => {
|
||||
const el = rootRef.current;
|
||||
if (!el) return;
|
||||
// Multiple viewers can be mounted (one per open tab); anchor below the
|
||||
// lowest visible toolbar so the card clears the active one. Written
|
||||
// straight to the element — a state update would re-render mid-flight
|
||||
// and restart (or freeze) the motion keyframes.
|
||||
let bottom = 0;
|
||||
for (const bar of document.querySelectorAll('.viewer-toolbar')) {
|
||||
const rect = bar.getBoundingClientRect();
|
||||
if (rect.width > 0 && rect.height > 0) bottom = Math.max(bottom, rect.bottom);
|
||||
}
|
||||
if (bottom > 0) el.style.top = `${Math.round(bottom + 10)}px`;
|
||||
};
|
||||
measure();
|
||||
// Tab strips / toolbars settle over the first seconds of a project load;
|
||||
// re-measure a few times instead of trusting the first layout pass.
|
||||
const timers = [400, 1200, 2600].map((ms) => window.setTimeout(measure, ms));
|
||||
window.addEventListener('resize', measure);
|
||||
return () => {
|
||||
timers.forEach((id) => window.clearTimeout(id));
|
||||
window.removeEventListener('resize', measure);
|
||||
};
|
||||
}, [settled]);
|
||||
const firedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
// The card renders nothing until `settled` (the 600ms layout-settle
|
||||
// window). Emitting the impression before that would count a mount that
|
||||
// was unmounted during settle as a `surface_view` the user never saw,
|
||||
// inflating the hint's denominator. Gate on `settled` so the impression
|
||||
// fires only once the card is actually renderable.
|
||||
if (!visible || !settled || firedRef.current) return;
|
||||
firedRef.current = true;
|
||||
trackStudioOnboardingHintSurfaceView(analytics.track, {
|
||||
page_name: 'chat_panel',
|
||||
area: 'onboarding_first_artifact_hint',
|
||||
hint_type: 'view_artifact',
|
||||
});
|
||||
}, [visible, settled, analytics.track]);
|
||||
|
||||
if (!visible || !settled) return null;
|
||||
|
||||
function dismiss() {
|
||||
trackStudioOnboardingHintClick(analytics.track, {
|
||||
page_name: 'chat_panel',
|
||||
area: 'onboarding_first_artifact_hint',
|
||||
element: 'dismiss',
|
||||
hint_type: 'view_artifact',
|
||||
});
|
||||
// Spend the once-ever budget on the user's own close action.
|
||||
markFirstArtifactHintSeen();
|
||||
setVisible(false);
|
||||
}
|
||||
|
||||
return (
|
||||
// Motion-driven entrance (the repo already ships `motion`), fully opaque
|
||||
// throughout: the card DROPS from the toolbar and settles (easeOut — set
|
||||
// down gently, same vertical axis as what follows), holds a beat, then
|
||||
// gives two equal 4px knocks with a pause between them (easeInOut).
|
||||
// Skipped entirely under prefers-reduced-motion.
|
||||
<motion.div
|
||||
ref={rootRef}
|
||||
className={styles.root}
|
||||
role="status"
|
||||
data-testid="first-artifact-hint"
|
||||
initial={reducedMotion ? false : { y: -28 }}
|
||||
animate={reducedMotion ? { y: 0 } : { y: [-28, 0, 0, -4, 0, 0, -4, 0] }}
|
||||
transition={
|
||||
reducedMotion
|
||||
? { duration: 0 }
|
||||
: {
|
||||
duration: 2.4,
|
||||
times: [0, 0.19, 0.32, 0.44, 0.56, 0.64, 0.76, 0.88],
|
||||
ease: [
|
||||
'easeOut',
|
||||
'linear',
|
||||
'easeInOut',
|
||||
'easeInOut',
|
||||
'linear',
|
||||
'easeInOut',
|
||||
'easeInOut',
|
||||
],
|
||||
}
|
||||
}
|
||||
>
|
||||
<span className={styles.icon} aria-hidden>
|
||||
<Icon name="sparkles" size={18} />
|
||||
</span>
|
||||
<div className={styles.body}>
|
||||
<span className={styles.title}>{t('studio.firstArtifactHint.title')}</span>
|
||||
<span className={styles.text}>{t('studio.firstArtifactHint.body')}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.dismiss}
|
||||
onClick={dismiss}
|
||||
aria-label={t('studio.firstArtifactHint.dismiss')}
|
||||
>
|
||||
<Icon name="close" size={15} />
|
||||
</button>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -236,6 +236,10 @@ interface Props {
|
||||
// no design system / template / prompt) and enters it. Omit to hide the link.
|
||||
onStartBlankProject?: () => void;
|
||||
executionSwitcher?: ReactNode;
|
||||
// Personalized first-run starting point (spec §7). Rendered directly under
|
||||
// the composer card — before the template section — so a brand-new user sees
|
||||
// their recommended entry without scrolling.
|
||||
recommendationSlot?: ReactNode;
|
||||
}
|
||||
|
||||
type HomeMentionTab = 'all' | 'files' | 'plugins' | 'skills' | 'mcp' | 'connectors';
|
||||
@@ -359,6 +363,7 @@ export const HomeHero = forwardRef<HomeHeroHandle, Props>(function HomeHero(
|
||||
onExamplePromptStatusChange,
|
||||
onStartBlankProject,
|
||||
executionSwitcher,
|
||||
recommendationSlot,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
@@ -2022,6 +2027,8 @@ export const HomeHero = forwardRef<HomeHeroHandle, Props>(function HomeHero(
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{recommendationSlot}
|
||||
|
||||
{activeCreateChip ? null : (
|
||||
<div className="home-hero__template-section" data-testid="home-hero-template-section">
|
||||
<div className="home-hero__template-heading">
|
||||
|
||||
@@ -107,6 +107,9 @@ import type { PluginUseAction } from './plugins-home/useActions';
|
||||
import { examplePresetSeedPrompt } from './plugins-home/presetSeedPrompt';
|
||||
import { localizePluginDescription } from './plugins-home/localization';
|
||||
import { RecentProjectsStrip } from './RecentProjectsStrip';
|
||||
import { RecommendedStartRegion } from './RecommendedStartRegion';
|
||||
import type { Recommendation } from '../onboarding/recommendation';
|
||||
import type { OnboardingEntry } from '../onboarding/onboarding-entry';
|
||||
import { AnimatePresence } from 'motion/react';
|
||||
|
||||
export interface ActivePlugin {
|
||||
@@ -237,6 +240,16 @@ interface Props {
|
||||
skillsLoading?: boolean;
|
||||
connectors?: ConnectorDetail[];
|
||||
promptTemplates?: PromptTemplateSummary[];
|
||||
// Personalized first-run starting point (spec §7). Null unless the user just
|
||||
// finished the About-you survey this session; EntryShell owns the state.
|
||||
recommendation?: Recommendation | null;
|
||||
onRecommendationStart?: (input: {
|
||||
name: string;
|
||||
prompt: string;
|
||||
metadata: ProjectMetadata;
|
||||
onboardingEntry: OnboardingEntry;
|
||||
}) => boolean | void | Promise<boolean | void>;
|
||||
onRecommendationDismiss?: () => void;
|
||||
executionSwitcher?: ReactNode;
|
||||
}
|
||||
|
||||
@@ -307,6 +320,9 @@ export function HomeView({
|
||||
skillsLoading = false,
|
||||
connectors = EMPTY_CONNECTORS,
|
||||
promptTemplates = EMPTY_PROMPT_TEMPLATES,
|
||||
recommendation = null,
|
||||
onRecommendationStart,
|
||||
onRecommendationDismiss,
|
||||
executionSwitcher,
|
||||
}: Props) {
|
||||
const { locale, t } = useI18n();
|
||||
@@ -2090,6 +2106,41 @@ export function HomeView({
|
||||
void startBlankProject();
|
||||
}}
|
||||
executionSwitcher={executionSwitcher}
|
||||
recommendationSlot={
|
||||
recommendation && onRecommendationStart && onRecommendationDismiss ? (
|
||||
<RecommendedStartRegion
|
||||
recommendation={recommendation}
|
||||
onStart={async (input) => {
|
||||
// Route recommendation-start failures into the same Home error
|
||||
// channel every other entry action uses, so a failed "Start
|
||||
// creating" surfaces a visible, retryable message instead of a
|
||||
// silent no-op. `onRecommendationStart` returns `false` for a
|
||||
// clean no-project result and throws on real create failures;
|
||||
// both land here as the localized error, and returning `false`
|
||||
// lets RecommendedStartRegion drop its pending state for retry.
|
||||
setError(null);
|
||||
try {
|
||||
const ok = await onRecommendationStart(input);
|
||||
if (ok === false) {
|
||||
setError(t('home.recommendation.startFailed'));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
setError(t('home.recommendation.startFailed'));
|
||||
return false;
|
||||
}
|
||||
}}
|
||||
onDismiss={() => {
|
||||
onRecommendationDismiss();
|
||||
// "浏览全部类型" must land the user somewhere concrete — open
|
||||
// the template picker (the "all types" catalogue) instead of
|
||||
// the strip silently vanishing (spec §7.4: 放弃推荐, 进入通用选择).
|
||||
onOpenNewProject?.('template');
|
||||
}}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<RecentProjectsStrip
|
||||
|
||||
@@ -80,6 +80,9 @@ import {
|
||||
trackDesignSystemApplyResult,
|
||||
trackDesignSystemEnrichClick,
|
||||
trackPageView,
|
||||
trackOnboardingPromptPrefilled,
|
||||
trackOnboardingFirstPromptSent,
|
||||
trackOnboardingFirstGenerationCompleted,
|
||||
} from '../analytics/events';
|
||||
import {
|
||||
clearOnboardingSessionId,
|
||||
@@ -232,6 +235,17 @@ import { SHARE_TO_COMMUNITY_PROMPT } from './share-to-community/shareToCommunity
|
||||
import { CenteredLoader } from './Loading';
|
||||
import type { SettingsSection } from './SettingsDialog';
|
||||
import { Toast } from './Toast';
|
||||
import { FirstArtifactHint } from './FirstArtifactHint';
|
||||
import {
|
||||
consumeOnboardingEntryForProject,
|
||||
hasSentFirstOnboardingPrompt,
|
||||
markFirstOnboardingPromptSent,
|
||||
hasCompletedFirstOnboardingGeneration,
|
||||
markFirstOnboardingGenerationCompleted,
|
||||
type OnboardingEntry,
|
||||
} from '../onboarding/onboarding-entry';
|
||||
import { producedPreviewableArtifact } from '../onboarding/first-generation';
|
||||
import { sentPrefilledPrompt } from '../onboarding/first-prompt';
|
||||
import { BrandReadyPrompt } from './BrandReadyPrompt';
|
||||
import { useDesignMdState } from '../hooks/useDesignMdState';
|
||||
import { useFinalizeProject } from '../hooks/useFinalizeProject';
|
||||
@@ -1332,6 +1346,32 @@ export function ProjectView({
|
||||
}: Props) {
|
||||
const { locale, t } = useI18n();
|
||||
const analytics = useAnalytics();
|
||||
// Onboarding first-generation funnel (spec §11.1). Consume the pending entry
|
||||
// (set by the Home recommendation) exactly once on mount; the refs guard the
|
||||
// two lifecycle events so each fires only for the genuine first send / first
|
||||
// successful generation of a recommendation-started project.
|
||||
const onboardingEntryInitRef = useRef(false);
|
||||
const onboardingEntryRef = useRef<OnboardingEntry | null>(null);
|
||||
// The prompt the recommendation prefilled into the composer. Prefer the seed
|
||||
// cached WITH the onboarding entry (it survives a reopen-before-send, whereas
|
||||
// `project.pendingPrompt` is wiped by `onClearPendingPrompt` on the first
|
||||
// mount); fall back to `pendingPrompt` for the very first mount / any project
|
||||
// without a cached seed. The first-prompt-sent funnel event compares the
|
||||
// actually-sent prompt against this seed so `has_prefilled_prompt` reflects
|
||||
// real behavior — the user is free to edit, clear, or replace the suggestion
|
||||
// before sending (spec §7.4 / §8.2).
|
||||
const onboardingSeedPromptRef = useRef('');
|
||||
if (!onboardingEntryInitRef.current) {
|
||||
onboardingEntryInitRef.current = true;
|
||||
onboardingEntryRef.current = consumeOnboardingEntryForProject(project.id);
|
||||
onboardingSeedPromptRef.current =
|
||||
onboardingEntryRef.current?.seedPrompt ?? (project.pendingPrompt ?? '').trim();
|
||||
}
|
||||
// The once-per-project funnel guards live in the onboarding-entry module
|
||||
// (project-keyed), not mount-local refs: ProjectView remounts on every
|
||||
// leave/reopen, and the entry now survives those remounts via its cache, so a
|
||||
// mount-local guard would let the funnel events re-fire on a later
|
||||
// conversation/run of the same project.
|
||||
const iframeKeepAlivePool = useIframeKeepAlivePool();
|
||||
const handleThemeChange = onThemeChange ?? (() => {});
|
||||
const projectDetail = useProjectDetail(project.id);
|
||||
@@ -2586,6 +2626,15 @@ export function ProjectView({
|
||||
() => new Set(projectFiles.map((f) => f.name)),
|
||||
[projectFiles],
|
||||
);
|
||||
// A previewable artifact exists once any HTML file has been produced. Gates
|
||||
// the one-time first-generation hint (spec §8.3); the hint component owns its
|
||||
// own once-ever "seen" budget.
|
||||
const hasPreviewableArtifact = useMemo(() => {
|
||||
for (const name of projectFileNames) {
|
||||
if (name.toLowerCase().endsWith('.html')) return true;
|
||||
}
|
||||
return false;
|
||||
}, [projectFileNames]);
|
||||
const activeProjectFileName = useMemo(
|
||||
() => (
|
||||
openTabsState.active && projectFileNames.has(openTabsState.active)
|
||||
@@ -4789,6 +4838,33 @@ export function ProjectView({
|
||||
amrGateInFlightConversationsRef.current.delete(gateConversationId);
|
||||
}
|
||||
}
|
||||
// First genuine send in a recommendation-started project — the
|
||||
// send-through half of the onboarding funnel. Fires once per project (the
|
||||
// guard is project-scoped so it survives ProjectView remounts), on the
|
||||
// first message of the conversation (not retries). Placed AFTER the
|
||||
// queue-only / busy / AMR balance gates above: those can abort the send
|
||||
// without creating a run, so emitting earlier would over-count blocked
|
||||
// attempts and then suppress the real retry via the once-only guard. By
|
||||
// here the send is committed to creating a run.
|
||||
if (
|
||||
onboardingEntryRef.current &&
|
||||
!hasSentFirstOnboardingPrompt(project.id) &&
|
||||
!retryTarget &&
|
||||
historyBase.length === 0
|
||||
) {
|
||||
markFirstOnboardingPromptSent(project.id);
|
||||
const entry = onboardingEntryRef.current;
|
||||
trackOnboardingFirstPromptSent(analytics.track, {
|
||||
entry_source: entry.source,
|
||||
product_type: entry.productType,
|
||||
recommendation_id: entry.recommendationId,
|
||||
// True only when the user sent the prefilled suggestion unmodified;
|
||||
// an edited, cleared, replaced, or starter-swapped prompt (or an
|
||||
// attachments-only send) reports false so the send-through split
|
||||
// stays honest.
|
||||
has_prefilled_prompt: sentPrefilledPrompt(onboardingSeedPromptRef.current, prompt),
|
||||
});
|
||||
}
|
||||
setChatSeed(null);
|
||||
const runConversationId = activeConversationId;
|
||||
setError(null);
|
||||
@@ -5332,6 +5408,27 @@ export function ProjectView({
|
||||
}
|
||||
}
|
||||
const produced = computeProducedFiles(beforeFileNames, nextFiles) ?? [];
|
||||
// Completion half of the onboarding funnel: the first generation
|
||||
// in a recommendation-started project that actually produced a
|
||||
// previewable artifact. Gated on the same artifact-producing
|
||||
// condition as the first-artifact hint (a produced `.html`), so a
|
||||
// `succeeded` run that returned only text or a clarifying question
|
||||
// does NOT count. Fires once.
|
||||
if (
|
||||
ownsCurrentRun &&
|
||||
onboardingEntryRef.current &&
|
||||
!hasCompletedFirstOnboardingGeneration(project.id) &&
|
||||
finalRunStatus === 'succeeded' &&
|
||||
producedPreviewableArtifact(produced)
|
||||
) {
|
||||
markFirstOnboardingGenerationCompleted(project.id);
|
||||
const entry = onboardingEntryRef.current;
|
||||
trackOnboardingFirstGenerationCompleted(analytics.track, {
|
||||
entry_source: entry.source,
|
||||
product_type: entry.productType,
|
||||
recommendation_id: entry.recommendationId,
|
||||
});
|
||||
}
|
||||
const traceObjectFiles = computeTraceObjectFiles(
|
||||
beforeFileNames,
|
||||
nextFiles,
|
||||
@@ -7510,6 +7607,23 @@ export function ProjectView({
|
||||
? undefined
|
||||
: (initialDraft?.projectId === project.id ? initialDraft.value : undefined)
|
||||
);
|
||||
// Home → Studio handoff confirmation (spec §11.1 onboarding_prompt_prefilled):
|
||||
// the recommendation's first request actually reached this composer. Fires
|
||||
// once, only for recommendation-started projects that arrived with a seed.
|
||||
const onboardingPrefilledFiredRef = useRef(false);
|
||||
useEffect(() => {
|
||||
const entry = onboardingEntryRef.current;
|
||||
if (!entry || onboardingPrefilledFiredRef.current) return;
|
||||
if (typeof chatInitialDraft !== 'string' || chatInitialDraft.trim().length === 0) return;
|
||||
onboardingPrefilledFiredRef.current = true;
|
||||
trackOnboardingPromptPrefilled(analytics.track, {
|
||||
entry_source: entry.source,
|
||||
product_type: entry.productType,
|
||||
recommendation_id: entry.recommendationId,
|
||||
...(entry.role ? { role: entry.role } : {}),
|
||||
...(entry.useCases && entry.useCases.length > 0 ? { use_cases: entry.useCases } : {}),
|
||||
});
|
||||
}, [chatInitialDraft, analytics.track]);
|
||||
const brandEnrichmentPromptSeed =
|
||||
project.pendingPrompt?.trim() ||
|
||||
(initialDraft?.projectId === project.id ? initialDraft.value.trim() : '');
|
||||
@@ -8262,6 +8376,7 @@ export function ProjectView({
|
||||
shareToOpenDesignBusyMessageId={shareToOpenDesignBusyMessageId}
|
||||
forceStreamingMessageIds={forceStreamingPluginMessageIds}
|
||||
initialDraft={chatInitialDraft}
|
||||
onboardingStarterPath={onboardingEntryRef.current?.productType ?? null}
|
||||
onOpenQuestions={openQuestionsTab}
|
||||
onContinueRemainingTasks={handleContinueRemainingTasks}
|
||||
onAssistantFeedback={handleAssistantFeedback}
|
||||
@@ -8560,6 +8675,14 @@ export function ProjectView({
|
||||
onClose={() => setContextDesignSystemDetails(null)}
|
||||
/>
|
||||
) : null}
|
||||
{/* One-time first-generation hint (spec §8.3) is scoped to the new-user
|
||||
onboarding handoff: only projects started from the Home recommendation
|
||||
carry a consumed `onboardingEntryRef`. Without this gate the hint
|
||||
would surface for any returning user opening an existing HTML project
|
||||
and burn its once-ever localStorage budget outside the intended flow. */}
|
||||
{onboardingEntryRef.current && hasPreviewableArtifact && !currentConversationStreaming ? (
|
||||
<FirstArtifactHint />
|
||||
) : null}
|
||||
{amrBalanceGateBlock ? (
|
||||
<AmrBalanceDialog
|
||||
reason={amrBalanceGateBlock.reason}
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
/* Compact one-row recommendation strip. Sits between the Home hero and the
|
||||
recent-projects rail; deliberately lighter than a full card so it doesn't
|
||||
compete with the hero input for first-screen attention. */
|
||||
/* Delayed accordion reveal — the repo's canonical auto-height pattern
|
||||
(grid-template-rows 0fr -> 1fr, see plugins-home home-templates-reveal).
|
||||
The slot stays collapsed while Home paints, then expands and PUSHES the
|
||||
template section down: displacement is the attention cue for this
|
||||
optional entry — quieter than a glint, impossible to miss. */
|
||||
.reveal {
|
||||
display: grid;
|
||||
/* Match the hero composer column (720px, centered) so the strip reads as a
|
||||
card in the same visual stack — not a full-width notice bar. */
|
||||
width: 100%;
|
||||
max-width: 720px;
|
||||
align-self: center;
|
||||
animation: recommendation-reveal 420ms cubic-bezier(0.23, 1, 0.32, 1) 600ms both;
|
||||
}
|
||||
|
||||
.revealInner {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.root {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin: 12px 0 4px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 24%, var(--border));
|
||||
border-radius: var(--radius);
|
||||
background: color-mix(in srgb, var(--accent-tint) 40%, var(--bg-panel));
|
||||
/* Content fades in as the slot finishes opening. */
|
||||
animation: recommendation-fade 240ms cubic-bezier(0.23, 1, 0.32, 1) 780ms both;
|
||||
}
|
||||
|
||||
@keyframes recommendation-reveal {
|
||||
from {
|
||||
grid-template-rows: 0fr;
|
||||
}
|
||||
to {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes recommendation-fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* One-time glint on the primary CTA once the card has settled — the same
|
||||
"click me next" invitation home-hero's attention sheen puts on the send
|
||||
button. Card opens (look here) -> button glints (act here), and the eye
|
||||
picks up the neighboring cycle / all-types options on the way. */
|
||||
.primary::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 45%;
|
||||
pointer-events: none;
|
||||
/* Tinted glint — the light-surface variant from home-hero's attention
|
||||
sheen; a white glint would vanish on the tinted button. */
|
||||
background: linear-gradient(
|
||||
105deg,
|
||||
transparent,
|
||||
color-mix(in srgb, var(--accent) 28%, white),
|
||||
transparent
|
||||
);
|
||||
animation: recommendation-cta-sheen 0.9s cubic-bezier(0.23, 1, 0.32, 1) 1.15s 2 both;
|
||||
}
|
||||
|
||||
@keyframes recommendation-cta-sheen {
|
||||
0% {
|
||||
transform: translateX(-130%) skewX(-18deg);
|
||||
opacity: 0;
|
||||
}
|
||||
18% {
|
||||
opacity: 1;
|
||||
}
|
||||
82% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translateX(230%) skewX(-18deg);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.reveal,
|
||||
.root {
|
||||
animation: none;
|
||||
}
|
||||
.primary::after {
|
||||
animation: none;
|
||||
content: none;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
|
||||
/* Title + subline stack; takes the remaining width and can wrap on narrow
|
||||
layouts without pushing the actions off-row. */
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex: 1 1 auto;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.desc {
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.primary {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
flex: 0 0 auto;
|
||||
padding: 7px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
/* Tinted secondary, not a solid accent fill: the composer's Send stays the
|
||||
screen's ONE filled primary. First-time attention is carried by the
|
||||
reveal + glint motion, not by fill weight. Same accent-secondary
|
||||
vocabulary as NextStepActions' Share button. */
|
||||
color: var(--accent-strong);
|
||||
background: var(--accent-tint);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 40%, var(--border));
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 140ms cubic-bezier(0.23, 1, 0.32, 1),
|
||||
transform 140ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
|
||||
.primary:hover:not(:disabled) {
|
||||
/* :not(:disabled) matches the global `button:hover:not(:disabled)` rule in
|
||||
primitives.css — without it that rule repaints the background to
|
||||
--bg-subtle. Deepen the tint on hover, same as the Share button. */
|
||||
background: color-mix(in srgb, var(--accent-tint) 70%, var(--accent) 14%);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.primary:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
/* "Try another" — quiet text action, no chrome. */
|
||||
/* Icon-only "换一换" — the label lives in title/aria-label so the row stays
|
||||
compact enough for all three actions to share one line at 720px. */
|
||||
.change {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 140ms cubic-bezier(0.23, 1, 0.32, 1),
|
||||
background 140ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
|
||||
.change:hover:not(:disabled) {
|
||||
/* Specificity-matches the global button:hover rule (see .primary above);
|
||||
a soft white wash instead of its gray --bg-subtle fill. Same hover
|
||||
treatment as .tertiary — the two text actions are peers, only their
|
||||
resting text color separates them. */
|
||||
color: var(--text);
|
||||
background: color-mix(in srgb, #fff 55%, transparent);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
/* "Browse all types" — quietest action, trailing edge. */
|
||||
.tertiary {
|
||||
flex: 0 0 auto;
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: color 140ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
|
||||
.tertiary:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
background: color-mix(in srgb, #fff 55%, transparent);
|
||||
border-radius: 999px;
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useT } from '../i18n';
|
||||
import { useAnalytics } from '../analytics/provider';
|
||||
import {
|
||||
trackHomeRecommendationClick,
|
||||
trackHomeRecommendationSurfaceView,
|
||||
} from '../analytics/events';
|
||||
import type { TrackingOnboardingProductType } from '@open-design/contracts/analytics';
|
||||
import type { ProjectMetadata } from '../types';
|
||||
import {
|
||||
nextStarter,
|
||||
type ProductType,
|
||||
type Recommendation,
|
||||
} from '../onboarding/recommendation';
|
||||
import type { OnboardingEntry } from '../onboarding/onboarding-entry';
|
||||
import { starterCopyFor } from '../onboarding/starter-copy';
|
||||
import { Icon } from './Icon';
|
||||
import styles from './RecommendedStartRegion.module.css';
|
||||
|
||||
// Product bucket → the project kind used at creation. Concrete paths seed a web
|
||||
// prototype pipeline; the general fallback uses `other`, which routes through
|
||||
// the default agent that asks the user to pin down the task (spec §6.3
|
||||
// "完整菜单 + 通用起点").
|
||||
const PRODUCT_KIND: Record<ProductType, ProjectMetadata['kind']> = {
|
||||
product_ui: 'prototype',
|
||||
marketing: 'prototype',
|
||||
internal_tool: 'prototype',
|
||||
general: 'other',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
recommendation: Recommendation;
|
||||
// Open Studio with the recommended first request pre-filled (not auto-sent).
|
||||
// The onboarding entry rides along so the create success path can stash it
|
||||
// keyed by the created project id — the funnel context is only handed off
|
||||
// after (and for) the project that actually gets created, so a concurrent
|
||||
// navigation can never steal it.
|
||||
onStart: (input: {
|
||||
name: string;
|
||||
prompt: string;
|
||||
metadata: ProjectMetadata;
|
||||
onboardingEntry: OnboardingEntry;
|
||||
}) => boolean | void | Promise<boolean | void>;
|
||||
// Abandon the recommendation for the generic entry ("浏览全部类型").
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
// Derive a short project name from the first request, mirroring the Home
|
||||
// composer submit path (`handlePluginLoopSubmit`). Falls back to the localized
|
||||
// default when the prompt has no usable head.
|
||||
function projectNameFromPrompt(prompt: string, fallback: string): string {
|
||||
const head = prompt.trim().split(/\s+/).slice(0, 8).join(' ');
|
||||
return head.length > 0 ? head : fallback;
|
||||
}
|
||||
|
||||
export function RecommendedStartRegion({ recommendation, onStart, onDismiss }: Props) {
|
||||
const t = useT();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
// Currently surfaced starter within this path. Seeded from the primary and
|
||||
// resynced if the recommendation itself changes (e.g. a fresh session).
|
||||
const [currentId, setCurrentId] = useState(recommendation.primary.id);
|
||||
useEffect(() => {
|
||||
setCurrentId(recommendation.primary.id);
|
||||
}, [recommendation.primary.id]);
|
||||
|
||||
const current =
|
||||
recommendation.options.find((option) => option.id === currentId) ?? recommendation.primary;
|
||||
const productType = recommendation.productType as TrackingOnboardingProductType;
|
||||
const canChange = recommendation.options.length > 1;
|
||||
|
||||
// Fire the impression once per exposure so the funnel can divide the three
|
||||
// actions by how often the card was actually seen.
|
||||
const shownRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (shownRef.current) return;
|
||||
shownRef.current = true;
|
||||
trackHomeRecommendationSurfaceView(analytics.track, {
|
||||
page_name: 'home',
|
||||
area: 'onboarding_recommendation',
|
||||
product_type: productType,
|
||||
recommendation_id: recommendation.primary.id,
|
||||
...(recommendation.role ? { role: recommendation.role } : {}),
|
||||
...(recommendation.useCases.length > 0 ? { use_cases: recommendation.useCases } : {}),
|
||||
});
|
||||
}, [analytics.track, productType, recommendation.primary.id, recommendation.role, recommendation.useCases]);
|
||||
|
||||
const copy = starterCopyFor(current.id);
|
||||
const firstPrompt = t(copy.firstPrompt);
|
||||
|
||||
function fireClick(element: 'enter_studio' | 'change' | 'browse_all', recommendationId: string) {
|
||||
trackHomeRecommendationClick(analytics.track, {
|
||||
page_name: 'home',
|
||||
area: 'onboarding_recommendation',
|
||||
element,
|
||||
product_type: productType,
|
||||
recommendation_id: recommendationId,
|
||||
...(recommendation.role ? { role: recommendation.role } : {}),
|
||||
...(recommendation.useCases.length > 0 ? { use_cases: recommendation.useCases } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
// Pending state for the create round-trip. The CTA disables while a start is
|
||||
// in flight and re-enables on failure so the user can retry (a successful
|
||||
// start unmounts this region because the parent dismisses the recommendation).
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
async function handleEnter() {
|
||||
if (pending) return;
|
||||
fireClick('enter_studio', current.id);
|
||||
// Hand the entry context to the create pipeline (session-only) so the
|
||||
// first-prompt / first-generation funnel events can attribute back to this
|
||||
// recommendation without persisting anything. The create success path
|
||||
// stashes it keyed by the created project id; nothing is written to
|
||||
// sessionStorage here, so a failed/aborted create leaves no stale slot and
|
||||
// a concurrent unrelated project mount cannot mis-attribute itself.
|
||||
const onboardingEntry: OnboardingEntry = {
|
||||
source: 'home_recommendation',
|
||||
productType: current.productType,
|
||||
recommendationId: current.id,
|
||||
...(recommendation.role ? { role: recommendation.role } : {}),
|
||||
...(recommendation.useCases.length > 0 ? { useCases: recommendation.useCases } : {}),
|
||||
};
|
||||
setPending(true);
|
||||
// `onStart` surfaces its own visible error (Home error channel) and never
|
||||
// rejects; it resolves `false` when the start failed. Only drop the pending
|
||||
// state on failure — on success the parent unmounts this region.
|
||||
const started =
|
||||
(await onStart({
|
||||
name: projectNameFromPrompt(firstPrompt, t('home.recommendation.defaultProjectName')),
|
||||
prompt: firstPrompt,
|
||||
metadata: { kind: PRODUCT_KIND[current.productType], nameSource: 'prompt' },
|
||||
onboardingEntry,
|
||||
})) !== false;
|
||||
if (!started) setPending(false);
|
||||
}
|
||||
|
||||
function handleChange() {
|
||||
const next = nextStarter(recommendation.options, current.id);
|
||||
fireClick('change', next.id);
|
||||
setCurrentId(next.id);
|
||||
}
|
||||
|
||||
function handleBrowseAll() {
|
||||
fireClick('browse_all', current.id);
|
||||
onDismiss();
|
||||
}
|
||||
|
||||
return (
|
||||
// Delayed accordion reveal (repo's canonical grid-rows expand): the slot
|
||||
// opens from 0fr and pushes the template section down — displacement is
|
||||
// the attention cue, no glint needed for an optional entry.
|
||||
<div className={styles.reveal}>
|
||||
<div className={styles.revealInner}>
|
||||
<section
|
||||
className={styles.root}
|
||||
data-testid="home-recommendation"
|
||||
aria-label={t('home.recommendation.eyebrow')}
|
||||
>
|
||||
<span className={styles.icon} aria-hidden>
|
||||
<Icon name="sparkles" size={18} />
|
||||
</span>
|
||||
<span className={styles.body}>
|
||||
<span className={styles.title}>{t(copy.title)}</span>
|
||||
<span className={styles.desc}>{t(copy.desc)}</span>
|
||||
</span>
|
||||
{canChange ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.change}
|
||||
onClick={handleChange}
|
||||
data-testid="home-recommendation-change"
|
||||
title={t('home.recommendation.change')}
|
||||
aria-label={t('home.recommendation.change')}
|
||||
>
|
||||
<Icon name="refresh" size={15} />
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.tertiary}
|
||||
onClick={handleBrowseAll}
|
||||
data-testid="home-recommendation-browse-all"
|
||||
>
|
||||
{t('home.recommendation.browseAll')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.primary}
|
||||
onClick={() => void handleEnter()}
|
||||
data-testid="home-recommendation-start"
|
||||
disabled={pending}
|
||||
aria-busy={pending}
|
||||
>
|
||||
{t('home.recommendation.primaryCta')}
|
||||
<Icon name="chevron-right" size={14} />
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2269,6 +2269,39 @@ export const ar: Dict = {
|
||||
'chat.example3Title': 'تقرير سنوي بالتمرير الطويل',
|
||||
'chat.example3Tag': 'تحريري',
|
||||
'chat.example3Prompt': 'تقرير سنوي تفاعلي لمنظمة مناخية غير ربحية - تخطيط تحريري بالتمرير الطويل يمزج بين كتل الاقتباسات الكبيرة، تصورات البيانات (أعمدة متراكمة، عدادات متحركة، خريطة لمواقع المشاريع)، فواصل صور، جدار المتبرعين، ودعوة نهائية للعمل. خط متن حديث، تسميات مخططات sans-serif، لوحة ألوان ورقية ترابية.',
|
||||
'home.recommendation.eyebrow': 'موصى به لك',
|
||||
'home.recommendation.primaryCta': 'ابدأ الإنشاء',
|
||||
'home.recommendation.change': 'جرّب نوعًا آخر',
|
||||
'home.recommendation.browseAll': 'كل الأنواع',
|
||||
'home.recommendation.defaultProjectName': 'مشروع جديد',
|
||||
'home.recommendation.startFailed': 'تعذّر بدء الإنشاء. حاول مرة أخرى.',
|
||||
'studio.firstArtifactHint.title': 'تصميمك الأول جاهز',
|
||||
'studio.firstArtifactHint.body': 'جرّب التحرير وإضافة التعليقات مباشرة — صدِّر أو شارك متى كنت راضيًا.',
|
||||
'studio.firstArtifactHint.dismiss': 'حسنًا',
|
||||
'home.starter.product_ui_prototype.title': 'ابدأ بنموذج منتج أولي قابل للتحرير',
|
||||
'home.starter.product_ui_prototype.desc': 'بناءً على هدفك، ابدأ بواجهة قابلة للنقر يمكنك تحسينها.',
|
||||
'home.starter.product_ui_prototype.firstPrompt': 'صمّم نموذجًا أوليًا قابلًا للتحرير لمنتج [منتجك] يتضمن الشاشات الرئيسية وتدفقًا أساسيًا واضحًا.',
|
||||
'home.starter.product_ui_component.title': 'ابدأ من مكوّن قابل لإعادة الاستخدام',
|
||||
'home.starter.product_ui_component.desc': 'ابنِ مكوّنًا واحدًا أولًا، ثم اجمعه في شاشات كاملة.',
|
||||
'home.starter.product_ui_component.firstPrompt': 'صمّم مكوّن [اسم المكوّن] قابلًا لإعادة الاستخدام مع حالاته الرئيسية وتنويعاته.',
|
||||
'home.starter.product_ui_lowfi.title': 'ابدأ بنموذج أولي منخفض الدقة',
|
||||
'home.starter.product_ui_lowfi.desc': 'ارسم الهيكل أولًا، ثم ارفع مستوى الدقة.',
|
||||
'home.starter.product_ui_lowfi.firstPrompt': 'أنشئ إطارًا سلكيًا منخفض الدقة للشاشات الرئيسية لمنتج [منتجك].',
|
||||
'home.starter.marketing_landing.title': 'ابدأ بصفحة هبوط',
|
||||
'home.starter.marketing_landing.desc': 'حوّل حملتك إلى صفحة هبوط جاهزة للنشر.',
|
||||
'home.starter.marketing_landing.firstPrompt': 'صمّم صفحة هبوط لمنتج [منتجك] تتضمن قسمًا رئيسيًا، والمزايا الأساسية، ودعوة واضحة للعمل.',
|
||||
'home.starter.marketing_multivariant.title': 'ابدأ بعدة تنويعات لصفحة الهبوط',
|
||||
'home.starter.marketing_multivariant.desc': 'أنشئ بضع نسخ للمقارنة قبل أن تحسم قرارك.',
|
||||
'home.starter.marketing_multivariant.firstPrompt': 'أنشئ 3 تنويعات لصفحة الهبوط لمنتج [منتجك] برسائل رئيسية مختلفة للمقارنة.',
|
||||
'home.starter.internal_dashboard.title': 'ابدأ بلوحة تحكم داخلية',
|
||||
'home.starter.internal_dashboard.desc': 'ابدأ من تخطيط لوحة تحكم لسير عملك.',
|
||||
'home.starter.internal_dashboard.firstPrompt': 'صمّم لوحة تحكم داخلية تعرض [المقاييس الرئيسية] مع عوامل تصفية وجدول بيانات.',
|
||||
'home.starter.internal_report.title': 'ابدأ بتقرير',
|
||||
'home.starter.internal_report.desc': 'صِف البيانات والحقول التي تريد رؤيتها.',
|
||||
'home.starter.internal_report.firstPrompt': 'أنشئ صفحة تقرير تلخّص [صف البيانات والحقول والمقاييس التي تهمك].',
|
||||
'home.starter.general_menu.title': 'ابدأ بمشروعك الأول',
|
||||
'home.starter.general_menu.desc': 'صِف ما تريده وسننشئ نسخة أولى قابلة للتحرير.',
|
||||
'home.starter.general_menu.firstPrompt': 'أنشئ نسخة أولى قابلة للتحرير من [صف ما تريد إنشاءه].',
|
||||
'chat.example4Title': 'فيديو حركة للمنتج',
|
||||
'chat.example4Tag': 'حركة',
|
||||
'chat.example4Prompt': 'أنشئ ستوريبورد فيديو حركة مدته 15 ثانية لإطلاق تطبيق إنتاجية: افتتاحية جاذبة، لقطات قريبة للواجهة، تايبوجرافي حركي، انتقالات للميزات، شعار ختامي و CTA. أضف التوقيت، وصف المشاهد، حركة الكاميرا، والنصوص على الشاشة.',
|
||||
|
||||
@@ -2269,6 +2269,39 @@ export const de: Dict = {
|
||||
'chat.example3Title': 'Annual Report Long-Scroll',
|
||||
'chat.example3Tag': 'Redaktionell',
|
||||
'chat.example3Prompt': 'Ein interaktiver Annual Report für eine Klima-Non-Profit-Organisation — Long-Scroll-Editorial-Layout mit großen Pull-Quote-Blöcken, Datenvisualisierungen (gestapelte Balken, animierte Counter, Choropleth-Karte der Projektstandorte), Fotobrechern, Spenderwand und finalem Call-to-Action. Moderne Serifenschrift für Body, Sans-Serif-Chartlabels, erdige Papierpalette.',
|
||||
'home.recommendation.eyebrow': 'Für dich empfohlen',
|
||||
'home.recommendation.primaryCta': 'Loslegen',
|
||||
'home.recommendation.change': 'Anderes ausprobieren',
|
||||
'home.recommendation.browseAll': 'Alle Typen',
|
||||
'home.recommendation.defaultProjectName': 'Neues Projekt',
|
||||
'home.recommendation.startFailed': 'Erstellung konnte nicht gestartet werden. Bitte erneut versuchen.',
|
||||
'studio.firstArtifactHint.title': 'Dein erstes Design ist fertig',
|
||||
'studio.firstArtifactHint.body': 'Bearbeite und kommentiere es direkt — exportieren oder teilen kannst du jederzeit.',
|
||||
'studio.firstArtifactHint.dismiss': 'Verstanden',
|
||||
'home.starter.product_ui_prototype.title': 'Mit einem bearbeitbaren Produktprototyp starten',
|
||||
'home.starter.product_ui_prototype.desc': 'Beginne ausgehend von deinem Ziel mit einer klickbaren Oberfläche, die du verfeinern kannst.',
|
||||
'home.starter.product_ui_prototype.firstPrompt': 'Entwirf einen bearbeitbaren Produktprototyp für [dein Produkt] mit den wichtigsten Screens und einem klaren Hauptflow.',
|
||||
'home.starter.product_ui_component.title': 'Mit einer wiederverwendbaren Komponente starten',
|
||||
'home.starter.product_ui_component.desc': 'Baue zuerst eine Komponente und setze sie dann zu vollständigen Screens zusammen.',
|
||||
'home.starter.product_ui_component.firstPrompt': 'Entwirf eine wiederverwendbare Komponente [Komponentenname] mit ihren wichtigsten Zuständen und Varianten.',
|
||||
'home.starter.product_ui_lowfi.title': 'Mit einem Low-Fidelity-Prototyp starten',
|
||||
'home.starter.product_ui_lowfi.desc': 'Skizziere zuerst die Struktur und erhöhe dann die Detailtreue.',
|
||||
'home.starter.product_ui_lowfi.firstPrompt': 'Erstelle ein Low-Fidelity-Wireframe der wichtigsten Screens für [dein Produkt].',
|
||||
'home.starter.marketing_landing.title': 'Mit einer Landingpage starten',
|
||||
'home.starter.marketing_landing.desc': 'Verwandle deine Kampagne in eine Landingpage, die du veröffentlichen kannst.',
|
||||
'home.starter.marketing_landing.firstPrompt': 'Entwirf eine Landingpage für [dein Produkt] mit einem Hero, den wichtigsten Vorteilen und einem klaren Call-to-Action.',
|
||||
'home.starter.marketing_multivariant.title': 'Mit mehreren Landingpage-Varianten starten',
|
||||
'home.starter.marketing_multivariant.desc': 'Generiere ein paar Versionen zum Vergleichen, bevor du dich festlegst.',
|
||||
'home.starter.marketing_multivariant.firstPrompt': 'Erstelle 3 Landingpage-Varianten für [dein Produkt] mit unterschiedlicher Hero-Botschaft zum Vergleichen.',
|
||||
'home.starter.internal_dashboard.title': 'Mit einem internen Dashboard starten',
|
||||
'home.starter.internal_dashboard.desc': 'Beginne mit einem Dashboard-Layout für deinen Workflow.',
|
||||
'home.starter.internal_dashboard.firstPrompt': 'Entwirf ein internes Dashboard, das [wichtige Kennzahlen] mit Filtern und einer Datentabelle anzeigt.',
|
||||
'home.starter.internal_report.title': 'Mit einem Bericht starten',
|
||||
'home.starter.internal_report.desc': 'Beschreibe die Daten und Felder, die du sehen möchtest.',
|
||||
'home.starter.internal_report.firstPrompt': 'Erstelle eine Berichtsseite, die [beschreibe die Daten, Felder und Kennzahlen, die dir wichtig sind] zusammenfasst.',
|
||||
'home.starter.general_menu.title': 'Mit deinem ersten Projekt starten',
|
||||
'home.starter.general_menu.desc': 'Beschreibe, was du möchtest, und wir generieren eine bearbeitbare erste Version.',
|
||||
'home.starter.general_menu.firstPrompt': 'Erstelle eine bearbeitbare erste Version von [beschreibe, was du erstellen möchtest].',
|
||||
'chat.example4Title': 'Produkt-Motion-Video',
|
||||
'chat.example4Tag': 'Motion',
|
||||
'chat.example4Prompt': 'Erstelle ein 15-sekündiges Motion-Video-Storyboard für den Launch einer Produktivitäts-App: starker Einstieg, UI-Nahaufnahmen, kinetische Typografie, Feature-Übergänge, Logo-Ende und CTA. Mit Timing, Szenenbeschreibungen, Kamerabewegungen und Texteinblendungen.',
|
||||
|
||||
@@ -2269,6 +2269,53 @@ export const en: Dict = {
|
||||
'chat.example3Title': 'Meditation mobile prototype',
|
||||
'chat.example3Tag': 'Mobile',
|
||||
'chat.example3Prompt': 'Prototype four mobile screens for a meditation app: home, session player, progress and streaks, and sleep collection. Include onboarding states, bottom navigation, accessible controls, and gentle motion notes so the flow feels ready to test.',
|
||||
|
||||
'home.recommendation.eyebrow': 'Recommended for you',
|
||||
'home.recommendation.primaryCta': 'Start creating',
|
||||
'home.recommendation.change': 'Try another',
|
||||
'home.recommendation.browseAll': 'All types',
|
||||
'home.recommendation.defaultProjectName': 'New project',
|
||||
'home.recommendation.startFailed': "Couldn't start creating. Please try again.",
|
||||
'studio.firstArtifactHint.title': 'Your first design is ready',
|
||||
'studio.firstArtifactHint.body': "Try editing and annotating it directly — export or share whenever you're happy.",
|
||||
'studio.firstArtifactHint.dismiss': 'Got it',
|
||||
'home.starter.product_ui_prototype.title': 'Start with an editable product prototype',
|
||||
'home.starter.product_ui_prototype.desc':
|
||||
'Based on your goal, begin with a clickable interface you can refine.',
|
||||
'home.starter.product_ui_prototype.firstPrompt':
|
||||
'Design an editable product prototype for [your product] with the key screens and a clear primary flow.',
|
||||
'home.starter.product_ui_component.title': 'Start from a reusable component',
|
||||
'home.starter.product_ui_component.desc':
|
||||
'Build one component first, then compose it into full screens.',
|
||||
'home.starter.product_ui_component.firstPrompt':
|
||||
'Design a reusable [component name] component with its main states and variants.',
|
||||
'home.starter.product_ui_lowfi.title': 'Start with a low-fidelity prototype',
|
||||
'home.starter.product_ui_lowfi.desc':
|
||||
'Sketch the structure first, then raise the fidelity.',
|
||||
'home.starter.product_ui_lowfi.firstPrompt':
|
||||
'Create a low-fidelity wireframe of the main screens for [your product].',
|
||||
'home.starter.marketing_landing.title': 'Start with a landing page',
|
||||
'home.starter.marketing_landing.desc': 'Turn your campaign into a landing page you can ship.',
|
||||
'home.starter.marketing_landing.firstPrompt':
|
||||
'Design a landing page for [your product] with a hero, key benefits, and a clear call to action.',
|
||||
'home.starter.marketing_multivariant.title': 'Start with multiple landing variants',
|
||||
'home.starter.marketing_multivariant.desc':
|
||||
'Generate a few versions to compare before you commit.',
|
||||
'home.starter.marketing_multivariant.firstPrompt':
|
||||
'Create 3 landing page variants for [your product] with different hero messaging to compare.',
|
||||
'home.starter.internal_dashboard.title': 'Start with an internal dashboard',
|
||||
'home.starter.internal_dashboard.desc': 'Begin from a dashboard layout for your workflow.',
|
||||
'home.starter.internal_dashboard.firstPrompt':
|
||||
'Design an internal dashboard showing [key metrics] with filters and a data table.',
|
||||
'home.starter.internal_report.title': 'Start with a report',
|
||||
'home.starter.internal_report.desc': 'Describe the data and fields you want to see.',
|
||||
'home.starter.internal_report.firstPrompt':
|
||||
'Create a report page summarizing [describe the data, fields, and metrics you care about].',
|
||||
'home.starter.general_menu.title': 'Start with your first project',
|
||||
'home.starter.general_menu.desc':
|
||||
"Describe what you want and we'll generate an editable first version.",
|
||||
'home.starter.general_menu.firstPrompt':
|
||||
'Create an editable first version of [describe what you want to make].',
|
||||
'chat.example4Title': 'Product motion video',
|
||||
'chat.example4Tag': 'Motion',
|
||||
'chat.example4Prompt': 'Create a 15-second motion video storyboard for a productivity app launch: opening hook, UI close-ups, kinetic type, feature transitions, ending logo, and CTA. Include timing, scene descriptions, camera moves, and text overlays.',
|
||||
|
||||
@@ -2269,6 +2269,39 @@ export const esES: Dict = {
|
||||
'chat.example3Title': 'Memoria anual long-scroll',
|
||||
'chat.example3Tag': 'Editorial',
|
||||
'chat.example3Prompt': 'Una memoria anual interactiva para una ONG climática: maquetación editorial long-scroll mezclando grandes bloques de citas, visualizaciones de datos (barras apiladas, contadores animados, un mapa coroplético de proyectos), separadores con fotografía, muro de donantes y llamada a la acción final. Cuerpo en serif moderna, etiquetas de gráficos en sans-serif, paleta terrosa de papel.',
|
||||
'home.recommendation.eyebrow': 'Recomendado para ti',
|
||||
'home.recommendation.primaryCta': 'Empezar a crear',
|
||||
'home.recommendation.change': 'Probar otro',
|
||||
'home.recommendation.browseAll': 'Todos los tipos',
|
||||
'home.recommendation.defaultProjectName': 'Nuevo proyecto',
|
||||
'home.recommendation.startFailed': 'No se pudo empezar a crear. Inténtalo de nuevo.',
|
||||
'studio.firstArtifactHint.title': 'Tu primer diseño está listo',
|
||||
'studio.firstArtifactHint.body': 'Prueba a editarlo y anotarlo directamente; exporta o comparte cuando estés satisfecho.',
|
||||
'studio.firstArtifactHint.dismiss': 'Entendido',
|
||||
'home.starter.product_ui_prototype.title': 'Empieza con un prototipo de producto editable',
|
||||
'home.starter.product_ui_prototype.desc': 'Según tu objetivo, empieza con una interfaz interactiva que puedes refinar.',
|
||||
'home.starter.product_ui_prototype.firstPrompt': 'Diseña un prototipo de producto editable para [tu producto] con las pantallas clave y un flujo principal claro.',
|
||||
'home.starter.product_ui_component.title': 'Empieza a partir de un componente reutilizable',
|
||||
'home.starter.product_ui_component.desc': 'Crea primero un componente y luego compónlo en pantallas completas.',
|
||||
'home.starter.product_ui_component.firstPrompt': 'Diseña un componente [nombre del componente] reutilizable con sus estados y variantes principales.',
|
||||
'home.starter.product_ui_lowfi.title': 'Empieza con un prototipo de baja fidelidad',
|
||||
'home.starter.product_ui_lowfi.desc': 'Esboza primero la estructura y luego aumenta la fidelidad.',
|
||||
'home.starter.product_ui_lowfi.firstPrompt': 'Crea un wireframe de baja fidelidad de las pantallas principales para [tu producto].',
|
||||
'home.starter.marketing_landing.title': 'Empieza con una página de aterrizaje',
|
||||
'home.starter.marketing_landing.desc': 'Convierte tu campaña en una página de aterrizaje lista para lanzar.',
|
||||
'home.starter.marketing_landing.firstPrompt': 'Diseña una página de aterrizaje para [tu producto] con un hero, beneficios clave y una llamada a la acción clara.',
|
||||
'home.starter.marketing_multivariant.title': 'Empieza con varias variantes de página de aterrizaje',
|
||||
'home.starter.marketing_multivariant.desc': 'Genera varias versiones para comparar antes de decidir.',
|
||||
'home.starter.marketing_multivariant.firstPrompt': 'Crea 3 variantes de página de aterrizaje para [tu producto] con distintos mensajes de hero para comparar.',
|
||||
'home.starter.internal_dashboard.title': 'Empieza con un panel interno',
|
||||
'home.starter.internal_dashboard.desc': 'Parte de un diseño de panel para tu flujo de trabajo.',
|
||||
'home.starter.internal_dashboard.firstPrompt': 'Diseña un panel interno que muestre [métricas clave] con filtros y una tabla de datos.',
|
||||
'home.starter.internal_report.title': 'Empieza con un informe',
|
||||
'home.starter.internal_report.desc': 'Describe los datos y campos que quieres ver.',
|
||||
'home.starter.internal_report.firstPrompt': 'Crea una página de informe que resuma [describe los datos, campos y métricas que te interesan].',
|
||||
'home.starter.general_menu.title': 'Empieza con tu primer proyecto',
|
||||
'home.starter.general_menu.desc': 'Describe lo que quieres y generaremos una primera versión editable.',
|
||||
'home.starter.general_menu.firstPrompt': 'Crea una primera versión editable de [describe lo que quieres crear].',
|
||||
'chat.example4Title': 'Video motion de producto',
|
||||
'chat.example4Tag': 'Motion',
|
||||
'chat.example4Prompt': 'Crea un storyboard de video motion de 15 segundos para el lanzamiento de una app de productividad: gancho inicial, primeros planos de UI, tipografía cinética, transiciones de funciones, cierre con logo y CTA. Incluye tiempos, escenas, cámara y textos sobreimpresos.',
|
||||
|
||||
@@ -2269,6 +2269,39 @@ export const fa: Dict = {
|
||||
'chat.example3Title': 'گزارش سالانه اسکرول بلند',
|
||||
'chat.example3Tag': 'سردبیری',
|
||||
'chat.example3Prompt': 'یک گزارش سالانه تعاملی برای یک سازمان غیرانتفاعی آب و هوایی — چیدمان سردبیری اسکرول بلند که بلوکهای نقل قول بزرگ، تصویرسازیهای داده، عکاسی، دیوار اهداکنندگان و یک فراخوان به عمل نهایی را ترکیب میکند.',
|
||||
'home.recommendation.eyebrow': 'پیشنهادشده برای شما',
|
||||
'home.recommendation.primaryCta': 'شروع ساخت',
|
||||
'home.recommendation.change': 'یک مورد دیگر را امتحان کنید',
|
||||
'home.recommendation.browseAll': 'همه انواع',
|
||||
'home.recommendation.defaultProjectName': 'پروژه جدید',
|
||||
'home.recommendation.startFailed': 'شروع ساخت ممکن نشد. لطفاً دوباره تلاش کنید.',
|
||||
'studio.firstArtifactHint.title': 'اولین طراحی شما آماده است',
|
||||
'studio.firstArtifactHint.body': 'ویرایش و حاشیهنویسی را مستقیم امتحان کنید — هر وقت راضی بودید خروجی بگیرید یا به اشتراک بگذارید.',
|
||||
'studio.firstArtifactHint.dismiss': 'متوجه شدم',
|
||||
'home.starter.product_ui_prototype.title': 'با یک نمونه اولیه محصول قابل ویرایش شروع کنید',
|
||||
'home.starter.product_ui_prototype.desc': 'بر اساس هدفتان، با یک رابط قابل کلیک که میتوانید آن را پالایش کنید شروع کنید.',
|
||||
'home.starter.product_ui_prototype.firstPrompt': 'یک نمونه اولیه محصول قابل ویرایش برای [محصول شما] با صفحههای کلیدی و یک جریان اصلی روشن طراحی کنید.',
|
||||
'home.starter.product_ui_component.title': 'از یک مؤلفه قابل استفاده مجدد شروع کنید',
|
||||
'home.starter.product_ui_component.desc': 'ابتدا یک مؤلفه بسازید، سپس آن را در صفحههای کامل ترکیب کنید.',
|
||||
'home.starter.product_ui_component.firstPrompt': 'یک مؤلفه قابل استفاده مجدد [نام مؤلفه] با حالتها و گونههای اصلی آن طراحی کنید.',
|
||||
'home.starter.product_ui_lowfi.title': 'با یک نمونه اولیه کمجزئیات شروع کنید',
|
||||
'home.starter.product_ui_lowfi.desc': 'ابتدا ساختار را طرح بزنید، سپس جزئیات را بالا ببرید.',
|
||||
'home.starter.product_ui_lowfi.firstPrompt': 'یک وایرفریم کمجزئیات از صفحههای اصلی برای [محصول شما] بسازید.',
|
||||
'home.starter.marketing_landing.title': 'با یک صفحه فرود شروع کنید',
|
||||
'home.starter.marketing_landing.desc': 'کمپین خود را به یک صفحه فرود قابل انتشار تبدیل کنید.',
|
||||
'home.starter.marketing_landing.firstPrompt': 'یک صفحه فرود برای [محصول شما] با بخش هیرو، مزایای کلیدی و یک فراخوان به عمل روشن طراحی کنید.',
|
||||
'home.starter.marketing_multivariant.title': 'با چند گونه صفحه فرود شروع کنید',
|
||||
'home.starter.marketing_multivariant.desc': 'چند نسخه بسازید تا پیش از تصمیم نهایی مقایسه کنید.',
|
||||
'home.starter.marketing_multivariant.firstPrompt': '۳ گونه صفحه فرود برای [محصول شما] با پیامهای هیرو متفاوت برای مقایسه بسازید.',
|
||||
'home.starter.internal_dashboard.title': 'با یک داشبورد داخلی شروع کنید',
|
||||
'home.starter.internal_dashboard.desc': 'از یک چیدمان داشبورد برای گردشکارتان شروع کنید.',
|
||||
'home.starter.internal_dashboard.firstPrompt': 'یک داشبورد داخلی طراحی کنید که [معیارهای کلیدی] را با فیلترها و یک جدول داده نمایش دهد.',
|
||||
'home.starter.internal_report.title': 'با یک گزارش شروع کنید',
|
||||
'home.starter.internal_report.desc': 'دادهها و فیلدهایی را که میخواهید ببینید توصیف کنید.',
|
||||
'home.starter.internal_report.firstPrompt': 'یک صفحه گزارش بسازید که [دادهها، فیلدها و معیارهای موردنظرتان را توصیف کنید] را خلاصه کند.',
|
||||
'home.starter.general_menu.title': 'با اولین پروژهتان شروع کنید',
|
||||
'home.starter.general_menu.desc': 'آنچه میخواهید را توصیف کنید و ما یک نسخه اولیه قابل ویرایش میسازیم.',
|
||||
'home.starter.general_menu.firstPrompt': 'یک نسخه اولیه قابل ویرایش از [آنچه میخواهید بسازید را توصیف کنید] بسازید.',
|
||||
'chat.example4Title': 'ویدیوی موشن محصول',
|
||||
'chat.example4Tag': 'موشن',
|
||||
'chat.example4Prompt': 'یک استوریبورد ویدیوی موشن ۱۵ ثانیهای برای معرفی یک اپ بهرهوری بساز: شروع جذاب، نماهای نزدیک UI، تایپوگرافی پویا، گذارهای ویژگیها، پایان با لوگو و CTA. زمانبندی، شرح صحنه، حرکت دوربین و متنهای روی تصویر را هم اضافه کن.',
|
||||
|
||||
@@ -2269,6 +2269,39 @@ export const fr: Dict = {
|
||||
'chat.example3Title': 'Rapport annuel long défilement',
|
||||
'chat.example3Tag': 'Éditorial',
|
||||
'chat.example3Prompt': 'Un rapport annuel interactif pour une ONG climatique — mise en page éditoriale à long défilement mêlant de grands blocs de citation, des visualisations de données (barres empilées, compteurs animés, une carte choroplèthe des sites de projet), des séparateurs photo, un mur de donateurs et un appel à l\'action final. Texte en serif moderne, étiquettes de graphiques en sans-serif, palette papier aux tons terreux.',
|
||||
'home.recommendation.eyebrow': 'Recommandé pour vous',
|
||||
'home.recommendation.primaryCta': 'Commencer à créer',
|
||||
'home.recommendation.change': 'Essayer un autre',
|
||||
'home.recommendation.browseAll': 'Tous les types',
|
||||
'home.recommendation.defaultProjectName': 'Nouveau projet',
|
||||
'home.recommendation.startFailed': 'Impossible de démarrer la création. Veuillez réessayer.',
|
||||
'studio.firstArtifactHint.title': 'Votre première création est prête',
|
||||
'studio.firstArtifactHint.body': "Essayez de le modifier et de l'annoter directement — exportez ou partagez quand vous êtes satisfait.",
|
||||
'studio.firstArtifactHint.dismiss': 'Compris',
|
||||
'home.starter.product_ui_prototype.title': 'Commencer avec un prototype de produit modifiable',
|
||||
'home.starter.product_ui_prototype.desc': 'À partir de votre objectif, commencez avec une interface cliquable que vous pouvez affiner.',
|
||||
'home.starter.product_ui_prototype.firstPrompt': 'Concevez un prototype de produit modifiable pour [votre produit] avec les écrans clés et un flux principal clair.',
|
||||
'home.starter.product_ui_component.title': 'Commencer à partir d\'un composant réutilisable',
|
||||
'home.starter.product_ui_component.desc': 'Construisez d\'abord un composant, puis assemblez-le en écrans complets.',
|
||||
'home.starter.product_ui_component.firstPrompt': 'Concevez un composant réutilisable [nom du composant] avec ses principaux états et variantes.',
|
||||
'home.starter.product_ui_lowfi.title': 'Commencer avec un prototype basse fidélité',
|
||||
'home.starter.product_ui_lowfi.desc': 'Esquissez d\'abord la structure, puis augmentez la fidélité.',
|
||||
'home.starter.product_ui_lowfi.firstPrompt': 'Créez un wireframe basse fidélité des écrans principaux pour [votre produit].',
|
||||
'home.starter.marketing_landing.title': 'Commencer avec une page de destination',
|
||||
'home.starter.marketing_landing.desc': 'Transformez votre campagne en une page de destination prête à publier.',
|
||||
'home.starter.marketing_landing.firstPrompt': 'Concevez une page de destination pour [votre produit] avec un hero, les avantages clés et un appel à l\'action clair.',
|
||||
'home.starter.marketing_multivariant.title': 'Commencer avec plusieurs variantes de page de destination',
|
||||
'home.starter.marketing_multivariant.desc': 'Générez quelques versions à comparer avant de vous décider.',
|
||||
'home.starter.marketing_multivariant.firstPrompt': 'Créez 3 variantes de page de destination pour [votre produit] avec des messages hero différents à comparer.',
|
||||
'home.starter.internal_dashboard.title': 'Commencer avec un tableau de bord interne',
|
||||
'home.starter.internal_dashboard.desc': 'Commencez à partir d\'une mise en page de tableau de bord pour votre workflow.',
|
||||
'home.starter.internal_dashboard.firstPrompt': 'Concevez un tableau de bord interne affichant [indicateurs clés] avec des filtres et un tableau de données.',
|
||||
'home.starter.internal_report.title': 'Commencer avec un rapport',
|
||||
'home.starter.internal_report.desc': 'Décrivez les données et les champs que vous souhaitez voir.',
|
||||
'home.starter.internal_report.firstPrompt': 'Créez une page de rapport résumant [décrivez les données, les champs et les indicateurs qui vous intéressent].',
|
||||
'home.starter.general_menu.title': 'Commencer avec votre premier projet',
|
||||
'home.starter.general_menu.desc': 'Décrivez ce que vous voulez et nous générerons une première version modifiable.',
|
||||
'home.starter.general_menu.firstPrompt': 'Créez une première version modifiable de [décrivez ce que vous voulez réaliser].',
|
||||
'chat.example4Title': 'Vidéo motion produit',
|
||||
'chat.example4Tag': 'Motion',
|
||||
'chat.example4Prompt': 'Créez un storyboard de vidéo motion de 15 secondes pour le lancement d\'une app de productivité : accroche, gros plans UI, typographie cinétique, transitions de fonctionnalités, logo final et CTA. Incluez le timing, les scènes, la caméra et les textes à l\'écran.',
|
||||
|
||||
@@ -2269,6 +2269,39 @@ export const hu: Dict = {
|
||||
'chat.example3Title': 'Éves jelentés long-scroll',
|
||||
'chat.example3Tag': 'Magazinszerű',
|
||||
'chat.example3Prompt': 'Interaktív éves jelentés egy klíma-non-profitnak — long-scroll magazinszerű elrendezés, nagy kiemelt-idézet blokkokkal, adatvizualizációkkal (egymásra rakott oszlopok, animált számlálók, projekthelyek choropleth térképe), fotó-megszakítókkal, donor fallal, és záró cselekvésre hívással. Modern serif törzs, sans-serif diagramcímkék, földes papírpaletta.',
|
||||
'home.recommendation.eyebrow': 'Neked ajánljuk',
|
||||
'home.recommendation.primaryCta': 'Alkotás indítása',
|
||||
'home.recommendation.change': 'Próbálj ki másikat',
|
||||
'home.recommendation.browseAll': 'Összes típus',
|
||||
'home.recommendation.defaultProjectName': 'Új projekt',
|
||||
'home.recommendation.startFailed': 'Nem sikerült elindítani a létrehozást. Próbáld újra.',
|
||||
'studio.firstArtifactHint.title': 'Az első terved elkészült',
|
||||
'studio.firstArtifactHint.body': 'Próbáld meg közvetlenül szerkeszteni és jegyzetelni — exportáld vagy oszd meg, ha elégedett vagy.',
|
||||
'studio.firstArtifactHint.dismiss': 'Értem',
|
||||
'home.starter.product_ui_prototype.title': 'Kezdj egy szerkeszthető termékprototípussal',
|
||||
'home.starter.product_ui_prototype.desc': 'A célod alapján kezdj egy kattintható felülettel, amelyet finomíthatsz.',
|
||||
'home.starter.product_ui_prototype.firstPrompt': 'Tervezz egy szerkeszthető termékprototípust a(z) [terméked] számára a kulcsképernyőkkel és egy világos elsődleges folyamattal.',
|
||||
'home.starter.product_ui_component.title': 'Kezdj egy újrafelhasználható komponenssel',
|
||||
'home.starter.product_ui_component.desc': 'Először építs egy komponenst, majd állítsd össze belőle a teljes képernyőket.',
|
||||
'home.starter.product_ui_component.firstPrompt': 'Tervezz egy újrafelhasználható [komponens neve] komponenst a fő állapotaival és változataival.',
|
||||
'home.starter.product_ui_lowfi.title': 'Kezdj egy alacsony részletességű prototípussal',
|
||||
'home.starter.product_ui_lowfi.desc': 'Először vázold fel a szerkezetet, majd emeld a részletességet.',
|
||||
'home.starter.product_ui_lowfi.firstPrompt': 'Készíts egy alacsony részletességű drótvázat a fő képernyőkről a(z) [terméked] számára.',
|
||||
'home.starter.marketing_landing.title': 'Kezdj egy landing oldallal',
|
||||
'home.starter.marketing_landing.desc': 'Alakítsd a kampányodat egy közzétehető landing oldallá.',
|
||||
'home.starter.marketing_landing.firstPrompt': 'Tervezz egy landing oldalt a(z) [terméked] számára egy hero szekcióval, a fő előnyökkel és egy világos cselekvésre hívással.',
|
||||
'home.starter.marketing_multivariant.title': 'Kezdj több landing változattal',
|
||||
'home.starter.marketing_multivariant.desc': 'Generálj néhány verziót az összehasonlításhoz, mielőtt döntesz.',
|
||||
'home.starter.marketing_multivariant.firstPrompt': 'Készíts 3 landing oldal változatot a(z) [terméked] számára eltérő hero üzenetekkel az összehasonlításhoz.',
|
||||
'home.starter.internal_dashboard.title': 'Kezdj egy belső irányítópulttal',
|
||||
'home.starter.internal_dashboard.desc': 'Indulj egy irányítópult-elrendezésből a munkafolyamatodhoz.',
|
||||
'home.starter.internal_dashboard.firstPrompt': 'Tervezz egy belső irányítópultot, amely a(z) [kulcsmutatók] elemeket jeleníti meg szűrőkkel és egy adattáblával.',
|
||||
'home.starter.internal_report.title': 'Kezdj egy jelentéssel',
|
||||
'home.starter.internal_report.desc': 'Írd le az adatokat és mezőket, amelyeket látni szeretnél.',
|
||||
'home.starter.internal_report.firstPrompt': 'Készíts egy jelentésoldalt, amely összegzi a(z) [írd le a számodra fontos adatokat, mezőket és mutatókat] elemeket.',
|
||||
'home.starter.general_menu.title': 'Kezdj az első projekteddel',
|
||||
'home.starter.general_menu.desc': 'Írd le, mit szeretnél, és mi generálunk egy szerkeszthető első verziót.',
|
||||
'home.starter.general_menu.firstPrompt': 'Készíts egy szerkeszthető első verziót a(z) [írd le, mit szeretnél készíteni] elemből.',
|
||||
'chat.example4Title': 'Termék motion videó',
|
||||
'chat.example4Tag': 'Motion',
|
||||
'chat.example4Prompt': 'Készíts 15 másodperces motion videó storyboardot egy produktivitási app bevezetéséhez: nyitó hook, UI-közeli képek, kinetikus tipográfia, funkcióátmenetek, záró logo és CTA. Add meg az időzítést, jeleneteket, kameramozgást és képernyőszövegeket.',
|
||||
|
||||
@@ -2269,6 +2269,39 @@ export const id: Dict = {
|
||||
'chat.example3Title': 'Laporan tahunan long-scroll',
|
||||
'chat.example3Tag': 'Editorial',
|
||||
'chat.example3Prompt': 'Buat laporan tahunan long-scroll dengan hero editorial, angka utama, timeline, dan visualisasi data.',
|
||||
'home.recommendation.eyebrow': 'Direkomendasikan untuk Anda',
|
||||
'home.recommendation.primaryCta': 'Mulai membuat',
|
||||
'home.recommendation.change': 'Coba yang lain',
|
||||
'home.recommendation.browseAll': 'Semua jenis',
|
||||
'home.recommendation.defaultProjectName': 'Proyek baru',
|
||||
'home.recommendation.startFailed': 'Tidak dapat memulai pembuatan. Coba lagi.',
|
||||
'studio.firstArtifactHint.title': 'Desain pertama Anda sudah siap',
|
||||
'studio.firstArtifactHint.body': 'Coba edit dan beri anotasi langsung — ekspor atau bagikan kapan saja setelah puas.',
|
||||
'studio.firstArtifactHint.dismiss': 'Mengerti',
|
||||
'home.starter.product_ui_prototype.title': 'Mulai dengan prototipe produk yang dapat diedit',
|
||||
'home.starter.product_ui_prototype.desc': 'Berdasarkan tujuan Anda, mulai dengan antarmuka yang dapat diklik dan bisa Anda sempurnakan.',
|
||||
'home.starter.product_ui_prototype.firstPrompt': 'Rancang prototipe produk yang dapat diedit untuk [produk Anda] dengan layar-layar utama dan alur utama yang jelas.',
|
||||
'home.starter.product_ui_component.title': 'Mulai dari komponen yang dapat digunakan ulang',
|
||||
'home.starter.product_ui_component.desc': 'Bangun satu komponen dulu, lalu susun menjadi layar penuh.',
|
||||
'home.starter.product_ui_component.firstPrompt': 'Rancang komponen [nama komponen] yang dapat digunakan ulang dengan status utama dan variannya.',
|
||||
'home.starter.product_ui_lowfi.title': 'Mulai dengan prototipe low-fidelity',
|
||||
'home.starter.product_ui_lowfi.desc': 'Sketsakan strukturnya dulu, lalu tingkatkan detailnya.',
|
||||
'home.starter.product_ui_lowfi.firstPrompt': 'Buat wireframe low-fidelity dari layar-layar utama untuk [produk Anda].',
|
||||
'home.starter.marketing_landing.title': 'Mulai dengan landing page',
|
||||
'home.starter.marketing_landing.desc': 'Ubah kampanye Anda menjadi landing page yang siap dirilis.',
|
||||
'home.starter.marketing_landing.firstPrompt': 'Rancang landing page untuk [produk Anda] dengan hero, manfaat utama, dan ajakan bertindak yang jelas.',
|
||||
'home.starter.marketing_multivariant.title': 'Mulai dengan beberapa varian landing page',
|
||||
'home.starter.marketing_multivariant.desc': 'Buat beberapa versi untuk dibandingkan sebelum Anda memutuskan.',
|
||||
'home.starter.marketing_multivariant.firstPrompt': 'Buat 3 varian landing page untuk [produk Anda] dengan pesan hero yang berbeda untuk dibandingkan.',
|
||||
'home.starter.internal_dashboard.title': 'Mulai dengan dasbor internal',
|
||||
'home.starter.internal_dashboard.desc': 'Mulai dari tata letak dasbor untuk alur kerja Anda.',
|
||||
'home.starter.internal_dashboard.firstPrompt': 'Rancang dasbor internal yang menampilkan [metrik utama] dengan filter dan tabel data.',
|
||||
'home.starter.internal_report.title': 'Mulai dengan laporan',
|
||||
'home.starter.internal_report.desc': 'Jelaskan data dan field yang ingin Anda lihat.',
|
||||
'home.starter.internal_report.firstPrompt': 'Buat halaman laporan yang merangkum [jelaskan data, field, dan metrik yang Anda pedulikan].',
|
||||
'home.starter.general_menu.title': 'Mulai dengan proyek pertama Anda',
|
||||
'home.starter.general_menu.desc': 'Jelaskan yang Anda inginkan dan kami akan menghasilkan versi pertama yang dapat diedit.',
|
||||
'home.starter.general_menu.firstPrompt': 'Buat versi pertama yang dapat diedit dari [jelaskan yang ingin Anda buat].',
|
||||
'chat.example4Title': 'Video motion produk',
|
||||
'chat.example4Tag': 'Motion',
|
||||
'chat.example4Prompt': 'Buat storyboard video motion 15 detik untuk peluncuran app produktivitas: hook pembuka, close-up UI, tipografi kinetik, transisi fitur, logo penutup, dan CTA. Sertakan timing, deskripsi scene, gerak kamera, dan teks overlay.',
|
||||
|
||||
@@ -2269,6 +2269,39 @@ export const it: Dict = {
|
||||
'chat.example3Title': 'Report annuale a scorrimento lungo',
|
||||
'chat.example3Tag': 'Editoriale',
|
||||
'chat.example3Prompt': 'Un report annuale interattivo per un\'ONG climatica — layout editoriale a scorrimento lungo che mescola grandi blocchi di citazioni, visualizzazioni di dati (barre sovrapposte, contatori animati, una mappa coropletica dei siti di progetto), separatori fotografici, un muro di donatori e una chiamata all\'azione finale. Testo in serif moderno, etichette di grafici in sans-serif, palette di carta dai toni terrosi.',
|
||||
'home.recommendation.eyebrow': 'Consigliato per te',
|
||||
'home.recommendation.primaryCta': 'Inizia a creare',
|
||||
'home.recommendation.change': 'Prova un altro',
|
||||
'home.recommendation.browseAll': 'Tutti i tipi',
|
||||
'home.recommendation.defaultProjectName': 'Nuovo progetto',
|
||||
'home.recommendation.startFailed': 'Impossibile avviare la creazione. Riprova.',
|
||||
'studio.firstArtifactHint.title': 'Il tuo primo design è pronto',
|
||||
'studio.firstArtifactHint.body': 'Prova a modificarlo e annotarlo direttamente: esporta o condividi quando sei soddisfatto.',
|
||||
'studio.firstArtifactHint.dismiss': 'Ho capito',
|
||||
'home.starter.product_ui_prototype.title': 'Inizia con un prototipo di prodotto modificabile',
|
||||
'home.starter.product_ui_prototype.desc': 'In base al tuo obiettivo, parti da un\'interfaccia cliccabile che puoi perfezionare.',
|
||||
'home.starter.product_ui_prototype.firstPrompt': 'Progetta un prototipo di prodotto modificabile per [il tuo prodotto] con le schermate chiave e un flusso principale chiaro.',
|
||||
'home.starter.product_ui_component.title': 'Inizia da un componente riutilizzabile',
|
||||
'home.starter.product_ui_component.desc': 'Costruisci prima un componente, poi assemblalo in schermate complete.',
|
||||
'home.starter.product_ui_component.firstPrompt': 'Progetta un componente riutilizzabile [nome del componente] con i suoi stati principali e le varianti.',
|
||||
'home.starter.product_ui_lowfi.title': 'Inizia con un prototipo a bassa fedeltà',
|
||||
'home.starter.product_ui_lowfi.desc': 'Abbozza prima la struttura, poi aumenta la fedeltà.',
|
||||
'home.starter.product_ui_lowfi.firstPrompt': 'Crea un wireframe a bassa fedeltà delle schermate principali per [il tuo prodotto].',
|
||||
'home.starter.marketing_landing.title': 'Inizia con una landing page',
|
||||
'home.starter.marketing_landing.desc': 'Trasforma la tua campagna in una landing page pronta da pubblicare.',
|
||||
'home.starter.marketing_landing.firstPrompt': 'Progetta una landing page per [il tuo prodotto] con un hero, i vantaggi chiave e una chiara chiamata all\'azione.',
|
||||
'home.starter.marketing_multivariant.title': 'Inizia con più varianti di landing page',
|
||||
'home.starter.marketing_multivariant.desc': 'Genera alcune versioni da confrontare prima di decidere.',
|
||||
'home.starter.marketing_multivariant.firstPrompt': 'Crea 3 varianti di landing page per [il tuo prodotto] con messaggi hero diversi da confrontare.',
|
||||
'home.starter.internal_dashboard.title': 'Inizia con una dashboard interna',
|
||||
'home.starter.internal_dashboard.desc': 'Parti da un layout di dashboard per il tuo workflow.',
|
||||
'home.starter.internal_dashboard.firstPrompt': 'Progetta una dashboard interna che mostra [metriche chiave] con filtri e una tabella dati.',
|
||||
'home.starter.internal_report.title': 'Inizia con un report',
|
||||
'home.starter.internal_report.desc': 'Descrivi i dati e i campi che vuoi vedere.',
|
||||
'home.starter.internal_report.firstPrompt': 'Crea una pagina di report che riepiloga [descrivi i dati, i campi e le metriche che ti interessano].',
|
||||
'home.starter.general_menu.title': 'Inizia con il tuo primo progetto',
|
||||
'home.starter.general_menu.desc': 'Descrivi ciò che vuoi e genereremo una prima versione modificabile.',
|
||||
'home.starter.general_menu.firstPrompt': 'Crea una prima versione modificabile di [descrivi ciò che vuoi realizzare].',
|
||||
'chat.example4Title': 'Video motion prodotto',
|
||||
'chat.example4Tag': 'Motion',
|
||||
'chat.example4Prompt': 'Crea uno storyboard di video motion da 15 secondi per il lancio di una app di produttività: hook iniziale, close-up della UI, tipografia cinetica, transizioni delle funzioni, chiusura con logo e CTA. Includi timing, scene, camera move e testi in sovrimpressione.',
|
||||
|
||||
@@ -2269,6 +2269,39 @@ export const ja: Dict = {
|
||||
'chat.example3Title': '年次報告書 長スクロール',
|
||||
'chat.example3Tag': '編集',
|
||||
'chat.example3Prompt': '気候系 NPO のインタラクティブ年次報告書 — 大きな引用ブロック、データビジュアライゼーション(積み上げ棒グラフ、アニメーションカウンター、プロジェクトサイトのコロプレスマップ)、写真ブレーカー、ドナーウォール、最後にコールトゥアクションを混在させた長スクロール編集レイアウト。モダンなセリフ体本文、サンセリフのチャートラベル、土のようなペーパーパレット。',
|
||||
'home.recommendation.eyebrow': 'あなたへのおすすめ',
|
||||
'home.recommendation.primaryCta': '作成を開始',
|
||||
'home.recommendation.change': '別のものを試す',
|
||||
'home.recommendation.browseAll': 'すべてのタイプ',
|
||||
'home.recommendation.defaultProjectName': '新規プロジェクト',
|
||||
'home.recommendation.startFailed': '作成を開始できませんでした。もう一度お試しください。',
|
||||
'studio.firstArtifactHint.title': '最初のデザインが完成しました',
|
||||
'studio.firstArtifactHint.body': 'そのまま編集や注釈を試してみましょう。満足したらいつでもエクスポート・共有できます。',
|
||||
'studio.firstArtifactHint.dismiss': 'OK',
|
||||
'home.starter.product_ui_prototype.title': '編集可能なプロダクトプロトタイプから始める',
|
||||
'home.starter.product_ui_prototype.desc': '目標に応じて、調整できるクリック可能なインターフェースから始めましょう。',
|
||||
'home.starter.product_ui_prototype.firstPrompt': '[あなたのプロダクト] 向けに、主要な画面と明確なメインフローを備えた編集可能なプロダクトプロトタイプをデザインして。',
|
||||
'home.starter.product_ui_component.title': '再利用可能なコンポーネントから始める',
|
||||
'home.starter.product_ui_component.desc': 'まず 1 つのコンポーネントを作り、それを組み合わせて画面全体を構成します。',
|
||||
'home.starter.product_ui_component.firstPrompt': '主要なステートとバリアントを備えた、再利用可能な [コンポーネント名] コンポーネントをデザインして。',
|
||||
'home.starter.product_ui_lowfi.title': 'ローファイプロトタイプから始める',
|
||||
'home.starter.product_ui_lowfi.desc': 'まず構造をスケッチし、その後で忠実度を高めていきます。',
|
||||
'home.starter.product_ui_lowfi.firstPrompt': '[あなたのプロダクト] の主要画面のローファイなワイヤーフレームを作成して。',
|
||||
'home.starter.marketing_landing.title': 'ランディングページから始める',
|
||||
'home.starter.marketing_landing.desc': 'キャンペーンを、そのまま公開できるランディングページに仕上げます。',
|
||||
'home.starter.marketing_landing.firstPrompt': '[あなたのプロダクト] 向けに、ヒーロー、主要なメリット、明確なコールトゥアクションを備えたランディングページをデザインして。',
|
||||
'home.starter.marketing_multivariant.title': '複数のランディングバリアントから始める',
|
||||
'home.starter.marketing_multivariant.desc': '決定する前に比較できるよう、いくつかのバージョンを生成します。',
|
||||
'home.starter.marketing_multivariant.firstPrompt': '[あなたのプロダクト] 向けに、ヒーローのメッセージが異なる 3 つのランディングページのバリアントを作成して比較できるようにして。',
|
||||
'home.starter.internal_dashboard.title': '社内ダッシュボードから始める',
|
||||
'home.starter.internal_dashboard.desc': 'あなたのワークフローに合わせたダッシュボードレイアウトから始めます。',
|
||||
'home.starter.internal_dashboard.firstPrompt': '[主要な指標] を表示し、フィルターとデータテーブルを備えた社内ダッシュボードをデザインして。',
|
||||
'home.starter.internal_report.title': 'レポートから始める',
|
||||
'home.starter.internal_report.desc': '見たいデータやフィールドを説明してください。',
|
||||
'home.starter.internal_report.firstPrompt': '[見たいデータ、フィールド、指標を説明してください] をまとめたレポートページを作成して。',
|
||||
'home.starter.general_menu.title': '最初のプロジェクトから始める',
|
||||
'home.starter.general_menu.desc': '作りたいものを説明してください。編集可能な最初のバージョンを生成します。',
|
||||
'home.starter.general_menu.firstPrompt': '[作りたいものを説明してください] の編集可能な最初のバージョンを作成して。',
|
||||
'chat.example4Title': 'プロダクトモーション動画',
|
||||
'chat.example4Tag': 'モーション',
|
||||
'chat.example4Prompt': '生産性アプリのローンチ向けに 15 秒のモーション動画ストーリーボードを作って。冒頭のフック、UI の寄り、キネティックタイポグラフィ、機能トランジション、最後の logo と CTA を含め、タイミング、シーン説明、カメラワーク、画面テキストも整理して。',
|
||||
|
||||
@@ -2269,6 +2269,39 @@ export const ko: Dict = {
|
||||
'chat.example3Title': '연례 보고서 롱 스크롤 페이지',
|
||||
'chat.example3Tag': '에디토리얼',
|
||||
'chat.example3Prompt': '기후 변화 관련 비영리 단체의 인터랙티브 연례 보고서를 제작해 주세요. 큰 인용구 블록이 특징인 긴 스크롤 기반 에디토리얼 레이아웃과 함께, 누적 막대 그래프, 움직이는 카운터, 프로젝트 사이트 분포를 보여주는 코로플레스 맵 등 데이터 시각화를 섞어 주세요. 텍스트 중간중간 사진을 삽입하고, 기부자 명단과 마지막 결론 부분(CTA)을 포함해 주세요. 본문은 모던 세리프 폰트를, 차트 라벨은 산세리프 폰트를 사용하며 전체적으로 따뜻한 색감의 팔레트를 적용해 주세요.',
|
||||
'home.recommendation.eyebrow': '추천 콘텐츠',
|
||||
'home.recommendation.primaryCta': '만들기 시작',
|
||||
'home.recommendation.change': '다른 것 시도하기',
|
||||
'home.recommendation.browseAll': '전체 유형',
|
||||
'home.recommendation.defaultProjectName': '새 프로젝트',
|
||||
'home.recommendation.startFailed': '생성을 시작할 수 없습니다. 다시 시도해 주세요.',
|
||||
'studio.firstArtifactHint.title': '첫 번째 디자인이 준비되었습니다',
|
||||
'studio.firstArtifactHint.body': '바로 편집하고 주석을 달아 보세요. 마음에 들면 언제든 내보내거나 공유할 수 있어요.',
|
||||
'studio.firstArtifactHint.dismiss': '확인',
|
||||
'home.starter.product_ui_prototype.title': '편집 가능한 제품 프로토타입으로 시작하기',
|
||||
'home.starter.product_ui_prototype.desc': '목표에 맞춰, 다듬을 수 있는 클릭 가능한 인터페이스로 시작하세요.',
|
||||
'home.starter.product_ui_prototype.firstPrompt': '[내 제품]을 위한 편집 가능한 제품 프로토타입을 주요 화면과 명확한 기본 흐름과 함께 디자인해 줘.',
|
||||
'home.starter.product_ui_component.title': '재사용 가능한 컴포넌트로 시작하기',
|
||||
'home.starter.product_ui_component.desc': '먼저 컴포넌트 하나를 만든 다음, 이를 조합해 전체 화면을 구성하세요.',
|
||||
'home.starter.product_ui_component.firstPrompt': '주요 상태와 변형을 갖춘 재사용 가능한 [컴포넌트 이름] 컴포넌트를 디자인해 줘.',
|
||||
'home.starter.product_ui_lowfi.title': '로우파이 프로토타입으로 시작하기',
|
||||
'home.starter.product_ui_lowfi.desc': '먼저 구조를 스케치한 다음, 완성도를 높여 가세요.',
|
||||
'home.starter.product_ui_lowfi.firstPrompt': '[내 제품]의 주요 화면에 대한 로우파이 와이어프레임을 만들어 줘.',
|
||||
'home.starter.marketing_landing.title': '랜딩 페이지로 시작하기',
|
||||
'home.starter.marketing_landing.desc': '캠페인을 바로 출시할 수 있는 랜딩 페이지로 만들어 보세요.',
|
||||
'home.starter.marketing_landing.firstPrompt': '[내 제품]을 위한 랜딩 페이지를 히어로, 핵심 이점, 명확한 콜 투 액션과 함께 디자인해 줘.',
|
||||
'home.starter.marketing_multivariant.title': '여러 랜딩 페이지 변형으로 시작하기',
|
||||
'home.starter.marketing_multivariant.desc': '결정하기 전에 비교할 수 있도록 여러 버전을 생성하세요.',
|
||||
'home.starter.marketing_multivariant.firstPrompt': '[내 제품]을 위한 랜딩 페이지 변형 3개를 서로 다른 히어로 메시지로 만들어 비교할 수 있게 해 줘.',
|
||||
'home.starter.internal_dashboard.title': '내부 대시보드로 시작하기',
|
||||
'home.starter.internal_dashboard.desc': '워크플로에 맞는 대시보드 레이아웃으로 시작하세요.',
|
||||
'home.starter.internal_dashboard.firstPrompt': '[핵심 지표]를 보여주고 필터와 데이터 테이블을 갖춘 내부 대시보드를 디자인해 줘.',
|
||||
'home.starter.internal_report.title': '리포트로 시작하기',
|
||||
'home.starter.internal_report.desc': '보고 싶은 데이터와 필드를 설명해 주세요.',
|
||||
'home.starter.internal_report.firstPrompt': '[보고 싶은 데이터, 필드, 지표를 설명해 주세요]를 요약한 리포트 페이지를 만들어 줘.',
|
||||
'home.starter.general_menu.title': '첫 번째 프로젝트로 시작하기',
|
||||
'home.starter.general_menu.desc': '만들고 싶은 것을 설명해 주시면 편집 가능한 첫 번째 버전을 생성해 드립니다.',
|
||||
'home.starter.general_menu.firstPrompt': '[만들고 싶은 것을 설명해 주세요]의 편집 가능한 첫 번째 버전을 만들어 줘.',
|
||||
'chat.example4Title': '제품 모션 비디오',
|
||||
'chat.example4Tag': '모션',
|
||||
'chat.example4Prompt': '생산성 앱 출시를 위한 15초 모션 비디오 스토리보드를 만들어 주세요. 오프닝 훅, UI 클로즈업, 키네틱 타이포그래피, 기능 전환, 엔딩 로고와 CTA를 포함하고 타이밍, 장면 설명, 카메라 움직임, 화면 문구를 정리해 주세요.',
|
||||
|
||||
@@ -2269,6 +2269,39 @@ export const pl: Dict = {
|
||||
'chat.example3Title': 'Raport roczny (long-scroll)',
|
||||
'chat.example3Tag': 'Edytorskie',
|
||||
'chat.example3Prompt': 'Interaktywny raport roczny dla organizacji klimatycznej — układ typu long-scroll mieszający bloki z dużymi cytatami, wizualizacje danych (wykresy słupkowe, animowane liczniki, mapa choropletowa lokalizacji projektów), przerywniki fotograficzne, lista darczyńców i końcowe wezwanie do działania. Nowoczesny tekst szeryfowy, etykiety wykresów bezszeryfowe, ziemista paleta kolorów.',
|
||||
'home.recommendation.eyebrow': 'Polecane dla Ciebie',
|
||||
'home.recommendation.primaryCta': 'Zacznij tworzyć',
|
||||
'home.recommendation.change': 'Wypróbuj inny',
|
||||
'home.recommendation.browseAll': 'Wszystkie typy',
|
||||
'home.recommendation.defaultProjectName': 'Nowy projekt',
|
||||
'home.recommendation.startFailed': 'Nie udało się rozpocząć tworzenia. Spróbuj ponownie.',
|
||||
'studio.firstArtifactHint.title': 'Twój pierwszy projekt jest gotowy',
|
||||
'studio.firstArtifactHint.body': 'Spróbuj edytować i dodawać adnotacje — wyeksportuj lub udostępnij, gdy będziesz zadowolony.',
|
||||
'studio.firstArtifactHint.dismiss': 'Rozumiem',
|
||||
'home.starter.product_ui_prototype.title': 'Zacznij od edytowalnego prototypu produktu',
|
||||
'home.starter.product_ui_prototype.desc': 'Na podstawie Twojego celu zacznij od klikalnego interfejsu, który możesz dopracować.',
|
||||
'home.starter.product_ui_prototype.firstPrompt': 'Zaprojektuj edytowalny prototyp produktu dla [Twój produkt] z kluczowymi ekranami i wyraźnym głównym przepływem.',
|
||||
'home.starter.product_ui_component.title': 'Zacznij od komponentu wielokrotnego użytku',
|
||||
'home.starter.product_ui_component.desc': 'Najpierw zbuduj jeden komponent, a potem złóż go w pełne ekrany.',
|
||||
'home.starter.product_ui_component.firstPrompt': 'Zaprojektuj komponent [nazwa komponentu] wielokrotnego użytku z jego głównymi stanami i wariantami.',
|
||||
'home.starter.product_ui_lowfi.title': 'Zacznij od prototypu o niskiej wierności',
|
||||
'home.starter.product_ui_lowfi.desc': 'Najpierw naszkicuj strukturę, a potem podnieś wierność.',
|
||||
'home.starter.product_ui_lowfi.firstPrompt': 'Utwórz makietę o niskiej wierności głównych ekranów dla [Twój produkt].',
|
||||
'home.starter.marketing_landing.title': 'Zacznij od strony docelowej',
|
||||
'home.starter.marketing_landing.desc': 'Zamień swoją kampanię w gotową do wdrożenia stronę docelową.',
|
||||
'home.starter.marketing_landing.firstPrompt': 'Zaprojektuj stronę docelową dla [Twój produkt] z sekcją hero, kluczowymi korzyściami i wyraźnym wezwaniem do działania.',
|
||||
'home.starter.marketing_multivariant.title': 'Zacznij od kilku wariantów strony docelowej',
|
||||
'home.starter.marketing_multivariant.desc': 'Wygeneruj kilka wersji do porównania, zanim podejmiesz decyzję.',
|
||||
'home.starter.marketing_multivariant.firstPrompt': 'Utwórz 3 warianty strony docelowej dla [Twój produkt] z różnymi komunikatami hero do porównania.',
|
||||
'home.starter.internal_dashboard.title': 'Zacznij od wewnętrznego pulpitu',
|
||||
'home.starter.internal_dashboard.desc': 'Zacznij od układu pulpitu dla swojego przepływu pracy.',
|
||||
'home.starter.internal_dashboard.firstPrompt': 'Zaprojektuj wewnętrzny pulpit pokazujący [kluczowe metryki] z filtrami i tabelą danych.',
|
||||
'home.starter.internal_report.title': 'Zacznij od raportu',
|
||||
'home.starter.internal_report.desc': 'Opisz dane i pola, które chcesz zobaczyć.',
|
||||
'home.starter.internal_report.firstPrompt': 'Utwórz stronę raportu podsumowującą [opisz dane, pola i metryki, które Cię interesują].',
|
||||
'home.starter.general_menu.title': 'Zacznij od pierwszego projektu',
|
||||
'home.starter.general_menu.desc': 'Opisz, czego chcesz, a my wygenerujemy edytowalną pierwszą wersję.',
|
||||
'home.starter.general_menu.firstPrompt': 'Utwórz edytowalną pierwszą wersję [opisz, co chcesz stworzyć].',
|
||||
'chat.example4Title': 'Wideo motion produktu',
|
||||
'chat.example4Tag': 'Motion',
|
||||
'chat.example4Prompt': 'Stwórz storyboard 15-sekundowego wideo motion dla premiery aplikacji produktywnościowej: mocne otwarcie, zbliżenia UI, typografia kinetyczna, przejścia funkcji, końcowe logo i CTA. Uwzględnij timing, opisy scen, ruch kamery i teksty ekranowe.',
|
||||
|
||||
@@ -2269,6 +2269,39 @@ export const ptBR: Dict = {
|
||||
'chat.example3Title': 'Relatório anual long-scroll',
|
||||
'chat.example3Tag': 'Editorial',
|
||||
'chat.example3Prompt': 'Um relatório anual interativo para uma ONG climática — layout editorial long-scroll misturando grandes blocos de citações, visualizações de dados (barras empilhadas, contadores animados, mapa coroplético de locais de projetos), quebras com fotografia, mural de doadores e chamada final para ação. Corpo com serif moderna, rótulos de gráficos sem serifa, paleta terrosa de papel.',
|
||||
'home.recommendation.eyebrow': 'Recomendado para você',
|
||||
'home.recommendation.primaryCta': 'Começar a criar',
|
||||
'home.recommendation.change': 'Tentar outro',
|
||||
'home.recommendation.browseAll': 'Todos os tipos',
|
||||
'home.recommendation.defaultProjectName': 'Novo projeto',
|
||||
'home.recommendation.startFailed': 'Não foi possível iniciar a criação. Tente novamente.',
|
||||
'studio.firstArtifactHint.title': 'Seu primeiro design está pronto',
|
||||
'studio.firstArtifactHint.body': 'Experimente editar e anotar diretamente — exporte ou compartilhe quando estiver satisfeito.',
|
||||
'studio.firstArtifactHint.dismiss': 'Entendi',
|
||||
'home.starter.product_ui_prototype.title': 'Comece com um protótipo de produto editável',
|
||||
'home.starter.product_ui_prototype.desc': 'Com base no seu objetivo, comece com uma interface clicável que você pode refinar.',
|
||||
'home.starter.product_ui_prototype.firstPrompt': 'Projete um protótipo de produto editável para [seu produto] com as telas principais e um fluxo principal claro.',
|
||||
'home.starter.product_ui_component.title': 'Comece a partir de um componente reutilizável',
|
||||
'home.starter.product_ui_component.desc': 'Crie um componente primeiro e depois combine-o em telas completas.',
|
||||
'home.starter.product_ui_component.firstPrompt': 'Projete um componente [nome do componente] reutilizável com seus principais estados e variantes.',
|
||||
'home.starter.product_ui_lowfi.title': 'Comece com um protótipo de baixa fidelidade',
|
||||
'home.starter.product_ui_lowfi.desc': 'Esboce a estrutura primeiro e depois aumente a fidelidade.',
|
||||
'home.starter.product_ui_lowfi.firstPrompt': 'Crie um wireframe de baixa fidelidade das telas principais para [seu produto].',
|
||||
'home.starter.marketing_landing.title': 'Comece com uma landing page',
|
||||
'home.starter.marketing_landing.desc': 'Transforme sua campanha em uma landing page pronta para publicar.',
|
||||
'home.starter.marketing_landing.firstPrompt': 'Projete uma landing page para [seu produto] com um hero, benefícios principais e uma chamada para ação clara.',
|
||||
'home.starter.marketing_multivariant.title': 'Comece com várias variantes de landing page',
|
||||
'home.starter.marketing_multivariant.desc': 'Gere algumas versões para comparar antes de decidir.',
|
||||
'home.starter.marketing_multivariant.firstPrompt': 'Crie 3 variantes de landing page para [seu produto] com mensagens de hero diferentes para comparar.',
|
||||
'home.starter.internal_dashboard.title': 'Comece com um painel interno',
|
||||
'home.starter.internal_dashboard.desc': 'Comece a partir de um layout de painel para o seu fluxo de trabalho.',
|
||||
'home.starter.internal_dashboard.firstPrompt': 'Projete um painel interno mostrando [métricas principais] com filtros e uma tabela de dados.',
|
||||
'home.starter.internal_report.title': 'Comece com um relatório',
|
||||
'home.starter.internal_report.desc': 'Descreva os dados e campos que você quer ver.',
|
||||
'home.starter.internal_report.firstPrompt': 'Crie uma página de relatório resumindo [descreva os dados, campos e métricas que interessam a você].',
|
||||
'home.starter.general_menu.title': 'Comece com seu primeiro projeto',
|
||||
'home.starter.general_menu.desc': 'Descreva o que você quer e geraremos uma primeira versão editável.',
|
||||
'home.starter.general_menu.firstPrompt': 'Crie uma primeira versão editável de [descreva o que você quer criar].',
|
||||
'chat.example4Title': 'Vídeo motion de produto',
|
||||
'chat.example4Tag': 'Motion',
|
||||
'chat.example4Prompt': 'Crie um storyboard de vídeo motion de 15 segundos para o lançamento de um app de produtividade: gancho inicial, closes de UI, tipografia cinética, transições de recursos, logo final e CTA. Inclua timing, cenas, movimentos de câmera e textos na tela.',
|
||||
|
||||
@@ -2269,6 +2269,39 @@ export const ru: Dict = {
|
||||
'chat.example3Title': 'Годовой отчет с длинной прокруткой',
|
||||
'chat.example3Tag': 'Редакционный',
|
||||
'chat.example3Prompt': 'Интерактивный годовой отчёт для климатической некоммерческой организации — редакционный макет с длинной прокруткой, сочетающий крупные цитатные блоки, визуализации данных (составные столбчатые диаграммы, анимированные счётчики, хороплетную карту проектных площадок), фотографические разделители, стену доноров и финальный призыв к действию. Современный serif для основного текста, sans-serif для подписей графиков и землистая бумажная палитра.',
|
||||
'home.recommendation.eyebrow': 'Рекомендовано для вас',
|
||||
'home.recommendation.primaryCta': 'Начать создание',
|
||||
'home.recommendation.change': 'Попробовать другой',
|
||||
'home.recommendation.browseAll': 'Все типы',
|
||||
'home.recommendation.defaultProjectName': 'Новый проект',
|
||||
'home.recommendation.startFailed': 'Не удалось начать создание. Попробуйте снова.',
|
||||
'studio.firstArtifactHint.title': 'Ваш первый дизайн готов',
|
||||
'studio.firstArtifactHint.body': 'Попробуйте отредактировать и добавить пометки — экспортируйте или поделитесь, когда будете довольны.',
|
||||
'studio.firstArtifactHint.dismiss': 'Понятно',
|
||||
'home.starter.product_ui_prototype.title': 'Начните с редактируемого прототипа продукта',
|
||||
'home.starter.product_ui_prototype.desc': 'Исходя из вашей цели, начните с кликабельного интерфейса, который можно доработать.',
|
||||
'home.starter.product_ui_prototype.firstPrompt': 'Спроектируйте редактируемый прототип продукта для [ваш продукт] с ключевыми экранами и понятным основным сценарием.',
|
||||
'home.starter.product_ui_component.title': 'Начните с переиспользуемого компонента',
|
||||
'home.starter.product_ui_component.desc': 'Сначала создайте один компонент, затем соберите из него полноценные экраны.',
|
||||
'home.starter.product_ui_component.firstPrompt': 'Спроектируйте переиспользуемый компонент [название компонента] с его основными состояниями и вариантами.',
|
||||
'home.starter.product_ui_lowfi.title': 'Начните с прототипа низкой детализации',
|
||||
'home.starter.product_ui_lowfi.desc': 'Сначала набросайте структуру, затем повышайте детализацию.',
|
||||
'home.starter.product_ui_lowfi.firstPrompt': 'Создайте низкодетализированный каркас основных экранов для [ваш продукт].',
|
||||
'home.starter.marketing_landing.title': 'Начните с целевой страницы',
|
||||
'home.starter.marketing_landing.desc': 'Превратите свою кампанию в целевую страницу, готовую к запуску.',
|
||||
'home.starter.marketing_landing.firstPrompt': 'Спроектируйте целевую страницу для [ваш продукт] с hero-блоком, ключевыми преимуществами и понятным призывом к действию.',
|
||||
'home.starter.marketing_multivariant.title': 'Начните с нескольких вариантов целевой страницы',
|
||||
'home.starter.marketing_multivariant.desc': 'Сгенерируйте несколько версий для сравнения, прежде чем принять решение.',
|
||||
'home.starter.marketing_multivariant.firstPrompt': 'Создайте 3 варианта целевой страницы для [ваш продукт] с разными hero-сообщениями для сравнения.',
|
||||
'home.starter.internal_dashboard.title': 'Начните с внутренней панели',
|
||||
'home.starter.internal_dashboard.desc': 'Начните с макета панели для вашего рабочего процесса.',
|
||||
'home.starter.internal_dashboard.firstPrompt': 'Спроектируйте внутреннюю панель, показывающую [ключевые метрики] с фильтрами и таблицей данных.',
|
||||
'home.starter.internal_report.title': 'Начните с отчёта',
|
||||
'home.starter.internal_report.desc': 'Опишите данные и поля, которые вы хотите видеть.',
|
||||
'home.starter.internal_report.firstPrompt': 'Создайте страницу отчёта, обобщающую [опишите данные, поля и метрики, которые вас интересуют].',
|
||||
'home.starter.general_menu.title': 'Начните с вашего первого проекта',
|
||||
'home.starter.general_menu.desc': 'Опишите, что вы хотите, и мы сгенерируем редактируемую первую версию.',
|
||||
'home.starter.general_menu.firstPrompt': 'Создайте редактируемую первую версию [опишите, что вы хотите сделать].',
|
||||
'chat.example4Title': 'Продуктовое motion-видео',
|
||||
'chat.example4Tag': 'Motion',
|
||||
'chat.example4Prompt': 'Создайте сториборд 15-секундного motion-видео для запуска приложения продуктивности: вступительный хук, крупные планы UI, кинетическая типографика, переходы функций, финальный логотип и CTA. Укажите тайминг, сцены, движение камеры и экранный текст.',
|
||||
|
||||
@@ -2269,6 +2269,39 @@ export const th: Dict = {
|
||||
'chat.example3Title': 'ตัวอย่างรายงานหน้ายาว',
|
||||
'chat.example3Tag': 'บรรณาธิการ',
|
||||
'chat.example3Prompt': 'หน้าจอรายงานผลกิจกรรมประจำปีแบบแสดงข้อความและกราฟ เลื่อนดูได้ยาวๆ',
|
||||
'home.recommendation.eyebrow': 'แนะนำสำหรับคุณ',
|
||||
'home.recommendation.primaryCta': 'เริ่มสร้าง',
|
||||
'home.recommendation.change': 'ลองแบบอื่น',
|
||||
'home.recommendation.browseAll': 'ทุกประเภท',
|
||||
'home.recommendation.defaultProjectName': 'โปรเจกต์ใหม่',
|
||||
'home.recommendation.startFailed': 'ไม่สามารถเริ่มสร้างได้ โปรดลองอีกครั้ง',
|
||||
'studio.firstArtifactHint.title': 'งานออกแบบชิ้นแรกของคุณพร้อมแล้ว',
|
||||
'studio.firstArtifactHint.body': 'ลองแก้ไขและใส่คำอธิบายได้เลย พอใจเมื่อไรก็ส่งออกหรือแชร์ได้ทุกเมื่อ',
|
||||
'studio.firstArtifactHint.dismiss': 'เข้าใจแล้ว',
|
||||
'home.starter.product_ui_prototype.title': 'เริ่มด้วยต้นแบบผลิตภัณฑ์ที่แก้ไขได้',
|
||||
'home.starter.product_ui_prototype.desc': 'จากเป้าหมายของคุณ เริ่มด้วยอินเทอร์เฟซที่คลิกได้และปรับแต่งต่อได้',
|
||||
'home.starter.product_ui_prototype.firstPrompt': 'ออกแบบต้นแบบผลิตภัณฑ์ที่แก้ไขได้สำหรับ [ผลิตภัณฑ์ของคุณ] พร้อมหน้าจอหลักและโฟลว์หลักที่ชัดเจน',
|
||||
'home.starter.product_ui_component.title': 'เริ่มจากคอมโพเนนต์ที่นำกลับมาใช้ซ้ำได้',
|
||||
'home.starter.product_ui_component.desc': 'สร้างคอมโพเนนต์เดียวก่อน แล้วนำไปประกอบเป็นหน้าจอเต็ม',
|
||||
'home.starter.product_ui_component.firstPrompt': 'ออกแบบคอมโพเนนต์ [ชื่อคอมโพเนนต์] ที่นำกลับมาใช้ซ้ำได้ พร้อมสถานะหลักและรูปแบบต่าง ๆ',
|
||||
'home.starter.product_ui_lowfi.title': 'เริ่มด้วยต้นแบบความละเอียดต่ำ',
|
||||
'home.starter.product_ui_lowfi.desc': 'ร่างโครงสร้างก่อน แล้วค่อยเพิ่มความละเอียด',
|
||||
'home.starter.product_ui_lowfi.firstPrompt': 'สร้างไวร์เฟรมความละเอียดต่ำของหน้าจอหลักสำหรับ [ผลิตภัณฑ์ของคุณ]',
|
||||
'home.starter.marketing_landing.title': 'เริ่มด้วยแลนดิงเพจ',
|
||||
'home.starter.marketing_landing.desc': 'เปลี่ยนแคมเปญของคุณให้เป็นแลนดิงเพจที่พร้อมเผยแพร่',
|
||||
'home.starter.marketing_landing.firstPrompt': 'ออกแบบแลนดิงเพจสำหรับ [ผลิตภัณฑ์ของคุณ] พร้อมส่วนฮีโร่ ประโยชน์หลัก และคำกระตุ้นการกระทำที่ชัดเจน',
|
||||
'home.starter.marketing_multivariant.title': 'เริ่มด้วยแลนดิงเพจหลายแบบ',
|
||||
'home.starter.marketing_multivariant.desc': 'สร้างหลายเวอร์ชันเพื่อเปรียบเทียบก่อนตัดสินใจ',
|
||||
'home.starter.marketing_multivariant.firstPrompt': 'สร้างแลนดิงเพจ 3 แบบสำหรับ [ผลิตภัณฑ์ของคุณ] ด้วยข้อความฮีโร่ที่แตกต่างกันเพื่อเปรียบเทียบ',
|
||||
'home.starter.internal_dashboard.title': 'เริ่มด้วยแดชบอร์ดภายใน',
|
||||
'home.starter.internal_dashboard.desc': 'เริ่มจากเลย์เอาต์แดชบอร์ดสำหรับเวิร์กโฟลว์ของคุณ',
|
||||
'home.starter.internal_dashboard.firstPrompt': 'ออกแบบแดชบอร์ดภายในที่แสดง [ตัวชี้วัดหลัก] พร้อมตัวกรองและตารางข้อมูล',
|
||||
'home.starter.internal_report.title': 'เริ่มด้วยรายงาน',
|
||||
'home.starter.internal_report.desc': 'อธิบายข้อมูลและฟิลด์ที่คุณต้องการเห็น',
|
||||
'home.starter.internal_report.firstPrompt': 'สร้างหน้ารายงานที่สรุป [อธิบายข้อมูล ฟิลด์ และตัวชี้วัดที่คุณสนใจ]',
|
||||
'home.starter.general_menu.title': 'เริ่มด้วยโปรเจกต์แรกของคุณ',
|
||||
'home.starter.general_menu.desc': 'อธิบายสิ่งที่คุณต้องการ แล้วเราจะสร้างเวอร์ชันแรกที่แก้ไขได้ให้',
|
||||
'home.starter.general_menu.firstPrompt': 'สร้างเวอร์ชันแรกที่แก้ไขได้ของ [อธิบายสิ่งที่คุณต้องการสร้าง]',
|
||||
'chat.example4Title': 'วิดีโอ Motion สินค้า',
|
||||
'chat.example4Tag': 'Motion',
|
||||
'chat.example4Prompt': 'สร้างสตอรี่บอร์ดวิดีโอ motion 15 วินาทีสำหรับเปิดตัวแอปเพิ่มประสิทธิภาพ: hook เปิดเรื่อง, ภาพ UI ระยะใกล้, ตัวอักษรเคลื่อนไหว, transition ของฟีเจอร์, logo ตอนจบ และ CTA พร้อม timing คำอธิบายฉาก มุมกล้อง และข้อความบนจอ',
|
||||
|
||||
@@ -2269,6 +2269,39 @@ export const tr: Dict = {
|
||||
'chat.example3Title': 'Yıllık rapor uzun kaydırma',
|
||||
'chat.example3Tag': 'Editoryal',
|
||||
'chat.example3Prompt': 'İklim odaklı bir sivil toplum kuruluşu için etkileşimli bir yıllık rapor: büyük alıntı blokları, veri görselleştirmeleri (yığılmış çubuk grafikler, animasyonlu sayaçlar, proje sahalarını gösteren choropleth harita), fotoğraf geçişleri, bağışçı duvarı ve finalde bir call-to-action içeren uzun kaydırmalı editoryal yerleşim. Modern serif gövde metni, grafik etiketlerinde sans-serif, toprak tonlarında kâğıt paleti.',
|
||||
'home.recommendation.eyebrow': 'Sizin için önerilen',
|
||||
'home.recommendation.primaryCta': "Studio'da başla",
|
||||
'home.recommendation.change': 'Başka bir tane dene',
|
||||
'home.recommendation.browseAll': 'Tüm türler',
|
||||
'home.recommendation.defaultProjectName': 'Yeni proje',
|
||||
'home.recommendation.startFailed': 'Oluşturma başlatılamadı. Lütfen tekrar deneyin.',
|
||||
'studio.firstArtifactHint.title': 'İlk tasarımınız hazır',
|
||||
'studio.firstArtifactHint.body': 'Doğrudan düzenlemeyi ve not eklemeyi dene — memnun kaldığında dışa aktar veya paylaş.',
|
||||
'studio.firstArtifactHint.dismiss': 'Anladım',
|
||||
'home.starter.product_ui_prototype.title': 'Düzenlenebilir bir ürün prototipiyle başla',
|
||||
'home.starter.product_ui_prototype.desc': 'Hedefinize göre, üzerinde iyileştirme yapabileceğiniz tıklanabilir bir arayüzle başlayın.',
|
||||
'home.starter.product_ui_prototype.firstPrompt': '[ürününüz] için ana ekranları ve net bir birincil akışı olan, düzenlenebilir bir ürün prototipi tasarlayın.',
|
||||
'home.starter.product_ui_component.title': 'Yeniden kullanılabilir bir bileşenden başla',
|
||||
'home.starter.product_ui_component.desc': 'Önce bir bileşen oluşturun, sonra bunu tam ekranlara birleştirin.',
|
||||
'home.starter.product_ui_component.firstPrompt': 'Ana durumları ve varyantlarıyla birlikte, yeniden kullanılabilir bir [bileşen adı] bileşeni tasarlayın.',
|
||||
'home.starter.product_ui_lowfi.title': 'Düşük çözünürlüklü bir prototiple başla',
|
||||
'home.starter.product_ui_lowfi.desc': 'Önce yapıyı taslak halinde çizin, sonra çözünürlüğü artırın.',
|
||||
'home.starter.product_ui_lowfi.firstPrompt': '[ürününüz] için ana ekranların düşük çözünürlüklü bir tel çerçevesini oluşturun.',
|
||||
'home.starter.marketing_landing.title': 'Bir açılış sayfasıyla başla',
|
||||
'home.starter.marketing_landing.desc': 'Kampanyanızı yayınlayabileceğiniz bir açılış sayfasına dönüştürün.',
|
||||
'home.starter.marketing_landing.firstPrompt': '[ürününüz] için bir hero, temel faydalar ve net bir eylem çağrısı içeren bir açılış sayfası tasarlayın.',
|
||||
'home.starter.marketing_multivariant.title': 'Birden fazla açılış varyantıyla başla',
|
||||
'home.starter.marketing_multivariant.desc': 'Karar vermeden önce karşılaştırmak için birkaç versiyon oluşturun.',
|
||||
'home.starter.marketing_multivariant.firstPrompt': '[ürününüz] için karşılaştırmak üzere farklı hero mesajlarına sahip 3 açılış sayfası varyantı oluşturun.',
|
||||
'home.starter.internal_dashboard.title': 'Dahili bir kontrol paneliyle başla',
|
||||
'home.starter.internal_dashboard.desc': 'İş akışınız için bir kontrol paneli düzeninden başlayın.',
|
||||
'home.starter.internal_dashboard.firstPrompt': '[temel metrikleri] filtreler ve bir veri tablosuyla gösteren dahili bir kontrol paneli tasarlayın.',
|
||||
'home.starter.internal_report.title': 'Bir raporla başla',
|
||||
'home.starter.internal_report.desc': 'Görmek istediğiniz verileri ve alanları tanımlayın.',
|
||||
'home.starter.internal_report.firstPrompt': '[önemsediğiniz verileri, alanları ve metrikleri tanımlayın] öğelerini özetleyen bir rapor sayfası oluşturun.',
|
||||
'home.starter.general_menu.title': 'İlk projenizle başla',
|
||||
'home.starter.general_menu.desc': 'Ne istediğinizi tanımlayın, biz de düzenlenebilir bir ilk versiyon oluşturalım.',
|
||||
'home.starter.general_menu.firstPrompt': '[ne yapmak istediğinizi tanımlayın] öğesinin düzenlenebilir bir ilk versiyonunu oluşturun.',
|
||||
'chat.example4Title': 'Ürün motion videosu',
|
||||
'chat.example4Tag': 'Motion',
|
||||
'chat.example4Prompt': 'Bir üretkenlik app lansmanı için 15 saniyelik motion video storyboardu oluştur: açılış hooku, UI yakın planları, kinetik tipografi, özellik geçişleri, kapanış logo ve CTA. Zamanlama, sahne açıklamaları, kamera hareketleri ve ekran yazılarını ekle.',
|
||||
|
||||
@@ -2269,6 +2269,39 @@ export const uk: Dict = {
|
||||
'chat.example3Title': 'Довгий скрол річного звіту',
|
||||
'chat.example3Tag': 'Редакційне',
|
||||
'chat.example3Prompt': 'Інтерактивний річний звіт для екологічної організації, що не шукає прибутку — довгий скрол редакційного макета, що змішує великі блоки цитат, візуалізацію даних (накопичені бари, анімовані лічильники, хорографічна карта місць проектів), перерви за фотографіями, стіна донорів та остаточний клич. Сучасний шрифт з засічками, безсерифні ярлики графіків, землиста палітра паперу.',
|
||||
'home.recommendation.eyebrow': 'Рекомендовано для вас',
|
||||
'home.recommendation.primaryCta': 'Почати створення',
|
||||
'home.recommendation.change': 'Спробувати інший',
|
||||
'home.recommendation.browseAll': 'Усі типи',
|
||||
'home.recommendation.defaultProjectName': 'Новий проєкт',
|
||||
'home.recommendation.startFailed': 'Не вдалося почати створення. Спробуйте ще раз.',
|
||||
'studio.firstArtifactHint.title': 'Ваш перший дизайн готовий',
|
||||
'studio.firstArtifactHint.body': 'Спробуйте відредагувати й додати позначки — експортуйте або поділіться, коли будете задоволені.',
|
||||
'studio.firstArtifactHint.dismiss': 'Зрозуміло',
|
||||
'home.starter.product_ui_prototype.title': 'Почніть з редагованого прототипу продукту',
|
||||
'home.starter.product_ui_prototype.desc': 'Виходячи з вашої мети, почніть з клікабельного інтерфейсу, який можна доопрацювати.',
|
||||
'home.starter.product_ui_prototype.firstPrompt': 'Спроєктуйте редагований прототип продукту для [ваш продукт] з ключовими екранами та зрозумілим основним сценарієм.',
|
||||
'home.starter.product_ui_component.title': 'Почніть із багаторазового компонента',
|
||||
'home.starter.product_ui_component.desc': 'Спочатку створіть один компонент, а потім зберіть із нього повноцінні екрани.',
|
||||
'home.starter.product_ui_component.firstPrompt': 'Спроєктуйте багаторазовий компонент [назва компонента] з його основними станами та варіантами.',
|
||||
'home.starter.product_ui_lowfi.title': 'Почніть з прототипу низької деталізації',
|
||||
'home.starter.product_ui_lowfi.desc': 'Спершу накидайте структуру, а потім підвищуйте деталізацію.',
|
||||
'home.starter.product_ui_lowfi.firstPrompt': 'Створіть низькодеталізований каркас основних екранів для [ваш продукт].',
|
||||
'home.starter.marketing_landing.title': 'Почніть із цільової сторінки',
|
||||
'home.starter.marketing_landing.desc': 'Перетворіть свою кампанію на цільову сторінку, готову до запуску.',
|
||||
'home.starter.marketing_landing.firstPrompt': 'Спроєктуйте цільову сторінку для [ваш продукт] з hero-блоком, ключовими перевагами та зрозумілим закликом до дії.',
|
||||
'home.starter.marketing_multivariant.title': 'Почніть із кількох варіантів цільової сторінки',
|
||||
'home.starter.marketing_multivariant.desc': 'Згенеруйте кілька версій для порівняння, перш ніж ухвалити рішення.',
|
||||
'home.starter.marketing_multivariant.firstPrompt': 'Створіть 3 варіанти цільової сторінки для [ваш продукт] з різними hero-повідомленнями для порівняння.',
|
||||
'home.starter.internal_dashboard.title': 'Почніть із внутрішньої панелі',
|
||||
'home.starter.internal_dashboard.desc': 'Почніть із макета панелі для вашого робочого процесу.',
|
||||
'home.starter.internal_dashboard.firstPrompt': 'Спроєктуйте внутрішню панель, що показує [ключові метрики] з фільтрами та таблицею даних.',
|
||||
'home.starter.internal_report.title': 'Почніть зі звіту',
|
||||
'home.starter.internal_report.desc': 'Опишіть дані та поля, які ви хочете бачити.',
|
||||
'home.starter.internal_report.firstPrompt': 'Створіть сторінку звіту, що узагальнює [опишіть дані, поля та метрики, які вас цікавлять].',
|
||||
'home.starter.general_menu.title': 'Почніть із вашого першого проєкту',
|
||||
'home.starter.general_menu.desc': 'Опишіть, що ви хочете, і ми згенеруємо редаговану першу версію.',
|
||||
'home.starter.general_menu.firstPrompt': 'Створіть редаговану першу версію [опишіть, що ви хочете зробити].',
|
||||
'chat.example4Title': 'Продуктове motion-відео',
|
||||
'chat.example4Tag': 'Motion',
|
||||
'chat.example4Prompt': 'Створіть сториборд 15-секундного motion-відео для запуску застосунку продуктивності: вступний гачок, крупні плани UI, кінетична типографіка, переходи функцій, фінальний логотип і CTA. Додайте таймінг, сцени, рух камери та екранні тексти.',
|
||||
|
||||
@@ -2483,6 +2483,39 @@ export const zhCN: Dict = {
|
||||
"chat.example3Tag": "编辑",
|
||||
"chat.example3Prompt":
|
||||
"为一家关注气候议题的非营利机构制作互动式年度报告 —— 长滚动编辑式布局,混合大段引言区块、数据可视化(堆叠柱状图、动态计数器、项目地点分布的等值线地图)、摄影分隔页、捐赠者墙,以及最终行动号召。现代衬线正文、无衬线图表标签、大地纸张配色。",
|
||||
'home.recommendation.eyebrow': '为你推荐',
|
||||
'home.recommendation.primaryCta': '开始创作',
|
||||
'home.recommendation.change': '换一换',
|
||||
'home.recommendation.browseAll': '全部类型',
|
||||
'home.recommendation.defaultProjectName': '新建项目',
|
||||
'home.recommendation.startFailed': '无法开始创建,请重试。',
|
||||
'studio.firstArtifactHint.title': '你的第一个设计已就绪',
|
||||
'studio.firstArtifactHint.body': '试试直接编辑和标注,满意后随时导出分享。',
|
||||
'studio.firstArtifactHint.dismiss': '知道了',
|
||||
'home.starter.product_ui_prototype.title': '从可编辑的产品原型开始',
|
||||
'home.starter.product_ui_prototype.desc': '基于你的目标,从一个可点击、可反复打磨的界面开始。',
|
||||
'home.starter.product_ui_prototype.firstPrompt': '为 [你的产品] 设计一个可编辑的产品原型,包含关键页面和清晰的核心流程。',
|
||||
'home.starter.product_ui_component.title': '从可复用组件开始',
|
||||
'home.starter.product_ui_component.desc': '先做一个组件,再把它组合成完整页面。',
|
||||
'home.starter.product_ui_component.firstPrompt': '设计一个可复用的 [组件名称] 组件,包含它的主要状态和变体。',
|
||||
'home.starter.product_ui_lowfi.title': '从低保真原型开始',
|
||||
'home.starter.product_ui_lowfi.desc': '先勾勒结构,再逐步提升保真度。',
|
||||
'home.starter.product_ui_lowfi.firstPrompt': '为 [你的产品] 制作主要页面的低保真线框图。',
|
||||
'home.starter.marketing_landing.title': '从落地页开始',
|
||||
'home.starter.marketing_landing.desc': '把你的营销活动变成一个可直接上线的落地页。',
|
||||
'home.starter.marketing_landing.firstPrompt': '为 [你的产品] 设计一个落地页,包含主视觉、核心卖点和清晰的行动号召。',
|
||||
'home.starter.marketing_multivariant.title': '从多个落地页版本开始',
|
||||
'home.starter.marketing_multivariant.desc': '在确定方案前,先生成几个版本进行对比。',
|
||||
'home.starter.marketing_multivariant.firstPrompt': '为 [你的产品] 制作 3 个落地页版本,采用不同的主视觉文案以便对比。',
|
||||
'home.starter.internal_dashboard.title': '从内部仪表盘开始',
|
||||
'home.starter.internal_dashboard.desc': '从贴合你工作流程的仪表盘布局开始。',
|
||||
'home.starter.internal_dashboard.firstPrompt': '设计一个内部仪表盘,展示 [关键指标],并配有筛选器和数据表格。',
|
||||
'home.starter.internal_report.title': '从报表开始',
|
||||
'home.starter.internal_report.desc': '描述你想看到的数据和字段。',
|
||||
'home.starter.internal_report.firstPrompt': '制作一个报表页面,汇总 [描述你关心的数据、字段和指标]。',
|
||||
'home.starter.general_menu.title': '从你的第一个项目开始',
|
||||
'home.starter.general_menu.desc': '描述你想要的内容,我们会生成一个可编辑的初始版本。',
|
||||
'home.starter.general_menu.firstPrompt': '为 [描述你想制作的内容] 生成一个可编辑的初始版本。',
|
||||
"chat.example4Title": "产品动效视频",
|
||||
"chat.example4Tag": "视频",
|
||||
"chat.example4Prompt":
|
||||
|
||||
@@ -2493,6 +2493,39 @@ export const zhTW: Dict = {
|
||||
"chat.example3Tag": "編輯",
|
||||
"chat.example3Prompt":
|
||||
"為一家關注氣候議題的非營利機構製作互動式年度報告 —— 長捲動編輯式佈局,混合大段引言區塊、資料視覺化(堆疊柱狀圖、動態計數器、專案地點分布的等值線地圖)、攝影分隔頁、捐贈者牆,以及最終行動號召。現代襯線內文、無襯線圖表標籤、大地紙張配色。",
|
||||
'home.recommendation.eyebrow': '為你推薦',
|
||||
'home.recommendation.primaryCta': '開始創作',
|
||||
'home.recommendation.change': '換一換',
|
||||
'home.recommendation.browseAll': '全部類型',
|
||||
'home.recommendation.defaultProjectName': '新增專案',
|
||||
'home.recommendation.startFailed': '無法開始建立,請重試。',
|
||||
'studio.firstArtifactHint.title': '你的第一個設計已就緒',
|
||||
'studio.firstArtifactHint.body': '試試直接編輯和標註,滿意後隨時匯出分享。',
|
||||
'studio.firstArtifactHint.dismiss': '知道了',
|
||||
'home.starter.product_ui_prototype.title': '從可編輯的產品原型開始',
|
||||
'home.starter.product_ui_prototype.desc': '根據你的目標,從一個可點擊、可反覆打磨的介面開始。',
|
||||
'home.starter.product_ui_prototype.firstPrompt': '為 [你的產品] 設計一個可編輯的產品原型,包含關鍵頁面和清晰的核心流程。',
|
||||
'home.starter.product_ui_component.title': '從可重複使用的元件開始',
|
||||
'home.starter.product_ui_component.desc': '先做一個元件,再把它組合成完整頁面。',
|
||||
'home.starter.product_ui_component.firstPrompt': '設計一個可重複使用的 [元件名稱] 元件,包含它的主要狀態和變體。',
|
||||
'home.starter.product_ui_lowfi.title': '從低保真原型開始',
|
||||
'home.starter.product_ui_lowfi.desc': '先勾勒結構,再逐步提升保真度。',
|
||||
'home.starter.product_ui_lowfi.firstPrompt': '為 [你的產品] 製作主要頁面的低保真線框圖。',
|
||||
'home.starter.marketing_landing.title': '從著陸頁開始',
|
||||
'home.starter.marketing_landing.desc': '把你的行銷活動變成一個可直接上線的著陸頁。',
|
||||
'home.starter.marketing_landing.firstPrompt': '為 [你的產品] 設計一個著陸頁,包含主視覺、核心賣點和清晰的行動號召。',
|
||||
'home.starter.marketing_multivariant.title': '從多個著陸頁版本開始',
|
||||
'home.starter.marketing_multivariant.desc': '在確定方案前,先生成幾個版本進行比較。',
|
||||
'home.starter.marketing_multivariant.firstPrompt': '為 [你的產品] 製作 3 個著陸頁版本,採用不同的主視覺文案以便比較。',
|
||||
'home.starter.internal_dashboard.title': '從內部儀表板開始',
|
||||
'home.starter.internal_dashboard.desc': '從貼合你工作流程的儀表板佈局開始。',
|
||||
'home.starter.internal_dashboard.firstPrompt': '設計一個內部儀表板,顯示 [關鍵指標],並配有篩選器和資料表格。',
|
||||
'home.starter.internal_report.title': '從報表開始',
|
||||
'home.starter.internal_report.desc': '描述你想看到的資料和欄位。',
|
||||
'home.starter.internal_report.firstPrompt': '製作一個報表頁面,彙整 [描述你關心的資料、欄位和指標]。',
|
||||
'home.starter.general_menu.title': '從你的第一個專案開始',
|
||||
'home.starter.general_menu.desc': '描述你想要的內容,我們會生成一個可編輯的初始版本。',
|
||||
'home.starter.general_menu.firstPrompt': '為 [描述你想製作的內容] 生成一個可編輯的初始版本。',
|
||||
"chat.example4Title": "產品動效影片",
|
||||
"chat.example4Tag": "影片",
|
||||
"chat.example4Prompt":
|
||||
|
||||
@@ -3015,6 +3015,41 @@ export interface Dict {
|
||||
'chat.example3Title': string;
|
||||
'chat.example3Tag': string;
|
||||
'chat.example3Prompt': string;
|
||||
|
||||
// Home personalized recommendation (first-run onboarding starting point)
|
||||
'home.recommendation.eyebrow': string;
|
||||
'home.recommendation.primaryCta': string;
|
||||
'home.recommendation.change': string;
|
||||
'home.recommendation.browseAll': string;
|
||||
'home.recommendation.defaultProjectName': string;
|
||||
'home.recommendation.startFailed': string;
|
||||
'studio.firstArtifactHint.title': string;
|
||||
'studio.firstArtifactHint.body': string;
|
||||
'studio.firstArtifactHint.dismiss': string;
|
||||
'home.starter.product_ui_prototype.title': string;
|
||||
'home.starter.product_ui_prototype.desc': string;
|
||||
'home.starter.product_ui_prototype.firstPrompt': string;
|
||||
'home.starter.product_ui_component.title': string;
|
||||
'home.starter.product_ui_component.desc': string;
|
||||
'home.starter.product_ui_component.firstPrompt': string;
|
||||
'home.starter.product_ui_lowfi.title': string;
|
||||
'home.starter.product_ui_lowfi.desc': string;
|
||||
'home.starter.product_ui_lowfi.firstPrompt': string;
|
||||
'home.starter.marketing_landing.title': string;
|
||||
'home.starter.marketing_landing.desc': string;
|
||||
'home.starter.marketing_landing.firstPrompt': string;
|
||||
'home.starter.marketing_multivariant.title': string;
|
||||
'home.starter.marketing_multivariant.desc': string;
|
||||
'home.starter.marketing_multivariant.firstPrompt': string;
|
||||
'home.starter.internal_dashboard.title': string;
|
||||
'home.starter.internal_dashboard.desc': string;
|
||||
'home.starter.internal_dashboard.firstPrompt': string;
|
||||
'home.starter.internal_report.title': string;
|
||||
'home.starter.internal_report.desc': string;
|
||||
'home.starter.internal_report.firstPrompt': string;
|
||||
'home.starter.general_menu.title': string;
|
||||
'home.starter.general_menu.desc': string;
|
||||
'home.starter.general_menu.firstPrompt': string;
|
||||
'chat.example4Title': string;
|
||||
'chat.example4Tag': string;
|
||||
'chat.example4Prompt': string;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// One-time "your first design is ready" hint (onboarding spec §8.3).
|
||||
//
|
||||
// A new user's first generation drops them into Studio with a produced
|
||||
// artifact and no idea that it's viewable / editable / exportable. This module
|
||||
// tracks whether the single lightweight first-artifact hint has already been
|
||||
// shown, so it appears at most once — ever — per browser.
|
||||
//
|
||||
// Deliberately localStorage (persists across sessions), unlike the Home
|
||||
// recommendation which is memory-only: the first-artifact moment happens once
|
||||
// in a user's lifetime, and re-showing it on a later project would be noise.
|
||||
// The hint is gated at the call site to the first generation loop; this module
|
||||
// only owns the "seen" bit. Storage-denied contexts degrade to "not seen"
|
||||
// (the hint may show once and then silently fail to persist — acceptable).
|
||||
|
||||
const STORAGE_KEY = 'open-design:seen-first-artifact-hint';
|
||||
|
||||
export function hasSeenFirstArtifactHint(): boolean {
|
||||
try {
|
||||
return window.localStorage.getItem(STORAGE_KEY) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function markFirstArtifactHintSeen(): void {
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, '1');
|
||||
} catch {
|
||||
// Ignore storage-denied contexts (private mode / disabled storage).
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Completion half of the onboarding funnel (spec §11.1).
|
||||
//
|
||||
// `onboarding_first_generation_completed` must line up with the first-artifact
|
||||
// experience the user actually sees: the first-artifact hint is gated on a
|
||||
// previewable artifact appearing (any `.html` file — see `hasPreviewableArtifact`
|
||||
// in ProjectView), so the completion event must be gated on the SAME condition.
|
||||
// A run can finish `succeeded` while producing only assistant text or a
|
||||
// clarifying question; counting that as a completed generation overstates the
|
||||
// funnel and diverges from the hint. This predicate answers "did this turn
|
||||
// produce a previewable artifact?" from the files produced during the turn.
|
||||
|
||||
// Loosened structural shape so callers can pass their richer `ProjectFile`
|
||||
// objects (which carry `name`) without importing this module's type.
|
||||
export interface ProducedFileLike {
|
||||
name: string;
|
||||
}
|
||||
|
||||
// True when the files produced this turn include a previewable artifact. Kept
|
||||
// in lockstep with `hasPreviewableArtifact` (a produced `.html` file); if that
|
||||
// definition widens, widen this too.
|
||||
export function producedPreviewableArtifact(
|
||||
producedFiles: ReadonlyArray<ProducedFileLike>,
|
||||
): boolean {
|
||||
return producedFiles.some((file) => file.name.toLowerCase().endsWith('.html'));
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Whether the first message a recommendation-started project sent was the
|
||||
// prefilled suggestion, unmodified.
|
||||
//
|
||||
// The Home recommendation seeds the Studio composer with a suggested first
|
||||
// request, but the user stays in control: they can edit it, clear it and send
|
||||
// only attachments, replace it, or swap in a path-scoped starter card before
|
||||
// sending (spec §7.4 / §8.2). The onboarding funnel's `has_prefilled_prompt`
|
||||
// dimension must therefore reflect that real choice — comparing the actually
|
||||
// submitted prompt against the seed captured at prefill time — rather than
|
||||
// hard-coding `true` for every recommendation-started project (which would make
|
||||
// the send-through split unreliable).
|
||||
//
|
||||
// Both sides are compared trimmed; an empty seed or an empty/attachments-only
|
||||
// submission is never a match.
|
||||
export function sentPrefilledPrompt(seedPrompt: string, submittedPrompt: string): boolean {
|
||||
const seed = seedPrompt.trim();
|
||||
return seed.length > 0 && submittedPrompt.trim() === seed;
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
// Session-only carrier for the onboarding entry that started a project.
|
||||
//
|
||||
// The Home recommendation knows the entry context (which product path, which
|
||||
// starter) but the funnel events that measure whether the user followed through
|
||||
// — first prompt sent, first generation completed — fire later, in Studio,
|
||||
// where that context isn't otherwise available. Rather than persist role /
|
||||
// use-case / product_type to the project (the onboarding spec §9.2 forbids
|
||||
// 落库), we hand the context across the Home→Studio navigation through
|
||||
// sessionStorage.
|
||||
//
|
||||
// The slot is keyed by the CREATED project id, not a single global slot. The id
|
||||
// is only known after the create request succeeds, so the stash happens in the
|
||||
// create success path (`App.handleCreateProject`) once the target project is
|
||||
// known, and the next Studio to mount for THAT project consumes it (read-once).
|
||||
// Keying by id removes the race the single global slot had: clicking "进入
|
||||
// Studio" and then opening an unrelated project before the recommended one
|
||||
// finished creating could let the unrelated project steal the personalized
|
||||
// context. It is intentionally lost on refresh — the first-prompt /
|
||||
// first-generation moment happens in the same session immediately after the
|
||||
// click.
|
||||
|
||||
import type { ProductType } from './recommendation';
|
||||
|
||||
export interface OnboardingEntry {
|
||||
source: 'home_recommendation';
|
||||
productType: ProductType;
|
||||
recommendationId: string;
|
||||
// Survey answers that produced the recommendation, echoed on the
|
||||
// `onboarding_prompt_prefilled` funnel event (spec §11.1). Analysis-only —
|
||||
// they live in this session slot, never in project data.
|
||||
role?: string;
|
||||
useCases?: string[];
|
||||
// The prompt the recommendation prefilled into the Studio composer, captured
|
||||
// at create time. Cached WITH the entry so it survives ProjectView remounts:
|
||||
// `project.pendingPrompt` is wiped by `onClearPendingPrompt()` on the first
|
||||
// mount, so a reopen-before-send would otherwise lose the comparison base and
|
||||
// report `has_prefilled_prompt=false` even when the user sends the original
|
||||
// recommended draft unchanged (spec §7.4 / §8.2).
|
||||
seedPrompt?: string;
|
||||
}
|
||||
|
||||
const KEY_PREFIX = 'open-design:onboarding-entry:';
|
||||
|
||||
function keyForProject(projectId: string): string {
|
||||
return `${KEY_PREFIX}${projectId}`;
|
||||
}
|
||||
|
||||
// StrictMode safety: `consumeOnboardingEntryForProject` is a destructive read
|
||||
// (it removes the sessionStorage slot). Under React StrictMode the throwaway
|
||||
// first mount would consume the slot and the committed remount would then see
|
||||
// `null`, silently dropping every recommendation-only behavior in dev (funnel
|
||||
// attribution, path-scoped starter cards, first-artifact hint). We cache the
|
||||
// first successfully parsed entry per project id in this module so repeated
|
||||
// consumes for the SAME id (both mount passes, or any later remount in the same
|
||||
// session) return the same value, while the sessionStorage slot is still
|
||||
// cleared on the first read so it never leaks to an unrelated project. The
|
||||
// cache only holds real entries — a project with no handoff never populates it,
|
||||
// so it stays bounded to the handful of recommendation-started projects a
|
||||
// session actually creates.
|
||||
const parsedEntryCache = new Map<string, OnboardingEntry>();
|
||||
|
||||
// Once-per-project funnel guards, kept beside the entry cache so they share its
|
||||
// exact lifetime. The two onboarding funnel events (`onboarding_first_prompt_sent`,
|
||||
// `onboarding_first_generation_completed`) are meant to fire once per
|
||||
// recommendation-started project, but ProjectView remounts whenever the user
|
||||
// leaves and reopens a project, resetting mount-local refs while the entry keeps
|
||||
// coming back from `parsedEntryCache`. Project-scoped module flags make the
|
||||
// once-only guard survive those remounts. A page refresh clears both the entry
|
||||
// cache and these flags together, but that also clears the (consumed)
|
||||
// sessionStorage slot, so the entry itself is gone and the events can't fire
|
||||
// anyway — the guards only need to hold within a session.
|
||||
const firstPromptSentProjects = new Set<string>();
|
||||
const firstGenerationCompletedProjects = new Set<string>();
|
||||
|
||||
export function hasSentFirstOnboardingPrompt(projectId: string): boolean {
|
||||
return Boolean(projectId) && firstPromptSentProjects.has(projectId);
|
||||
}
|
||||
|
||||
export function markFirstOnboardingPromptSent(projectId: string): void {
|
||||
if (projectId) firstPromptSentProjects.add(projectId);
|
||||
}
|
||||
|
||||
export function hasCompletedFirstOnboardingGeneration(projectId: string): boolean {
|
||||
return Boolean(projectId) && firstGenerationCompletedProjects.has(projectId);
|
||||
}
|
||||
|
||||
export function markFirstOnboardingGenerationCompleted(projectId: string): void {
|
||||
if (projectId) firstGenerationCompletedProjects.add(projectId);
|
||||
}
|
||||
|
||||
// Stash the entry for a specific created project. Called from the create
|
||||
// success path, where the project id is finally known — never before, so an
|
||||
// unrelated project mount cannot consume it.
|
||||
export function stashOnboardingEntryForProject(
|
||||
projectId: string,
|
||||
entry: OnboardingEntry,
|
||||
): void {
|
||||
if (!projectId) return;
|
||||
try {
|
||||
window.sessionStorage.setItem(keyForProject(projectId), JSON.stringify(entry));
|
||||
} catch {
|
||||
// Storage-denied contexts just lose the funnel attribution — never throw.
|
||||
}
|
||||
}
|
||||
|
||||
// Read and remove the entry for a specific project. Returns null when none is
|
||||
// set (the common case: any project not started from a recommendation, or a
|
||||
// concurrent project whose own key was never written).
|
||||
export function consumeOnboardingEntryForProject(
|
||||
projectId: string,
|
||||
): OnboardingEntry | null {
|
||||
if (!projectId) return null;
|
||||
// StrictMode-safe: a value read once (and removed from storage) is served
|
||||
// from the module cache on every later consume for the same id, so the
|
||||
// throwaway StrictMode mount can't strand the committed remount with `null`.
|
||||
const cached = parsedEntryCache.get(projectId);
|
||||
if (cached) return cached;
|
||||
const key = keyForProject(projectId);
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(key);
|
||||
if (!raw) return null;
|
||||
window.sessionStorage.removeItem(key);
|
||||
const parsed = JSON.parse(raw) as Partial<OnboardingEntry>;
|
||||
if (
|
||||
parsed &&
|
||||
parsed.source === 'home_recommendation' &&
|
||||
typeof parsed.productType === 'string' &&
|
||||
typeof parsed.recommendationId === 'string'
|
||||
) {
|
||||
const entry: OnboardingEntry = {
|
||||
source: 'home_recommendation',
|
||||
productType: parsed.productType,
|
||||
recommendationId: parsed.recommendationId,
|
||||
...(typeof parsed.role === 'string' && parsed.role ? { role: parsed.role } : {}),
|
||||
...(Array.isArray(parsed.useCases) &&
|
||||
parsed.useCases.every((u) => typeof u === 'string')
|
||||
? { useCases: parsed.useCases }
|
||||
: {}),
|
||||
...(typeof parsed.seedPrompt === 'string' ? { seedPrompt: parsed.seedPrompt } : {}),
|
||||
};
|
||||
parsedEntryCache.set(projectId, entry);
|
||||
return entry;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
// Personalized first-run recommendation.
|
||||
//
|
||||
// After the first-run questionnaire collects `role` + `use-case`, Home shows a
|
||||
// single recommended starting point instead of the generic empty entry. This
|
||||
// module is the static mapping table that turns the survey answers into that
|
||||
// recommendation. It is intentionally pure and dependency-free:
|
||||
//
|
||||
// - No i18n lookups: it returns a stable starter `id`; the rendering
|
||||
// component maps that id to localized copy via literal i18n keys. This
|
||||
// keeps the mapping unit-testable without a locale bundle and keeps copy
|
||||
// edits out of the logic.
|
||||
// - No React / DOM / analytics: callers own presentation and telemetry.
|
||||
// - No persistence: the recommendation is a value derived per session. The
|
||||
// caller holds it in memory only (it must NOT survive a page refresh, per
|
||||
// the onboarding spec).
|
||||
//
|
||||
// Design rules encoded here (from the onboarding spec §6.3):
|
||||
// 1. use-case takes priority over role — the goal describes intent better
|
||||
// than the identity.
|
||||
// 2. Only one primary starting point per screen. Alternatives within the
|
||||
// same product path are surfaced through "换一个" (see `nextStarter`).
|
||||
// 3. Founder / Student / Other / unknown goals fall back to the general
|
||||
// starting point rather than guessing a narrow path.
|
||||
|
||||
/**
|
||||
* The four product buckets a recommendation can resolve to. Mirrors the
|
||||
* `product_type` analytics dimension so Home telemetry can group on the same
|
||||
* value the mapping produced.
|
||||
*/
|
||||
export type ProductType = 'product_ui' | 'marketing' | 'internal_tool' | 'general';
|
||||
|
||||
/**
|
||||
* One concrete starting point the user can begin from. `id` is stable and
|
||||
* doubles as the analytics `recommendation_id` and the i18n key suffix
|
||||
* (`home.starter.<id>.*`), so it must not change once shipped.
|
||||
*/
|
||||
export interface StarterOption {
|
||||
id: string;
|
||||
productType: ProductType;
|
||||
}
|
||||
|
||||
/**
|
||||
* The resolved recommendation for a session. `primary` is the currently
|
||||
* surfaced option; `options` is the full ordered list within the same product
|
||||
* path that "换一个" cycles through (`primary` is always `options[0]` at build
|
||||
* time). `role` / `useCases` echo the inputs so callers can stamp telemetry.
|
||||
*/
|
||||
export interface Recommendation {
|
||||
productType: ProductType;
|
||||
primary: StarterOption;
|
||||
options: readonly StarterOption[];
|
||||
role: string;
|
||||
useCases: string[];
|
||||
}
|
||||
|
||||
// A path always has at least one starter; the non-empty tuple lets callers
|
||||
// treat `options[0]` as defined.
|
||||
type StarterList = readonly [StarterOption, ...StarterOption[]];
|
||||
|
||||
export interface RecommendationInput {
|
||||
/** Single-select role value from the questionnaire (may be empty). */
|
||||
role?: string | null;
|
||||
/** Multi-select use-case values from the questionnaire (may be empty). */
|
||||
useCases?: readonly string[] | null;
|
||||
}
|
||||
|
||||
// --- Static mapping tables --------------------------------------------------
|
||||
|
||||
// use-case value → product bucket. use-case wins over role, so this table is
|
||||
// consulted first. Values match `useCaseOptions` in EntryShell.tsx.
|
||||
const USE_CASE_TO_PRODUCT: Record<string, ProductType> = {
|
||||
prototype: 'product_ui',
|
||||
product: 'product_ui',
|
||||
'design-system': 'product_ui',
|
||||
engineering: 'product_ui',
|
||||
landing: 'marketing',
|
||||
marketing: 'marketing',
|
||||
ads: 'marketing',
|
||||
agency: 'marketing',
|
||||
dashboard: 'internal_tool',
|
||||
// `deck` has no strong prototype/marketing/internal signal in the spec's
|
||||
// taxonomy, so it falls through to the general starting point.
|
||||
};
|
||||
|
||||
// Canonical questionnaire display order for the multi-select use-case chips
|
||||
// (mirrors `useCaseOptions` in EntryShell.tsx). The multi-select stores values
|
||||
// in the user's click order, so we canonicalize against this order before
|
||||
// resolving — otherwise the same set of answers picked in a different sequence
|
||||
// (e.g. `['product','landing']` vs `['landing','product']`) would resolve to a
|
||||
// different product bucket. Spec §6.3: use-case priority follows questionnaire
|
||||
// order, not selection order.
|
||||
const USE_CASE_DISPLAY_ORDER: readonly string[] = [
|
||||
'product',
|
||||
'design-system',
|
||||
'prototype',
|
||||
'landing',
|
||||
'marketing',
|
||||
'ads',
|
||||
'dashboard',
|
||||
'deck',
|
||||
'engineering',
|
||||
'agency',
|
||||
];
|
||||
|
||||
// role value → product bucket, used only when no use-case signal resolves.
|
||||
// Values match `roleOptions` in EntryShell.tsx. founder / student / other are
|
||||
// deliberately absent so they fall through to `general`.
|
||||
const ROLE_TO_PRODUCT: Record<string, ProductType> = {
|
||||
designer: 'product_ui',
|
||||
pm: 'product_ui',
|
||||
engineer: 'product_ui',
|
||||
marketing: 'marketing',
|
||||
growth: 'marketing',
|
||||
ops: 'internal_tool',
|
||||
};
|
||||
|
||||
// Ordered starter options per product bucket. `options[0]` is the default
|
||||
// primary; the rest are "换一个" alternatives within the same path. Every id is
|
||||
// stable (analytics + i18n key). Copy lives under `home.starter.<id>.*`.
|
||||
const STARTERS_BY_PRODUCT: Record<ProductType, StarterList> = {
|
||||
product_ui: [
|
||||
starter('product_ui_prototype', 'product_ui'),
|
||||
starter('product_ui_component', 'product_ui'),
|
||||
starter('product_ui_lowfi', 'product_ui'),
|
||||
],
|
||||
marketing: [
|
||||
starter('marketing_landing', 'marketing'),
|
||||
starter('marketing_multivariant', 'marketing'),
|
||||
],
|
||||
internal_tool: [
|
||||
starter('internal_dashboard', 'internal_tool'),
|
||||
starter('internal_report', 'internal_tool'),
|
||||
],
|
||||
general: [starter('general_menu', 'general')],
|
||||
};
|
||||
|
||||
function starter(id: string, productType: ProductType): StarterOption {
|
||||
return { id, productType };
|
||||
}
|
||||
|
||||
// --- Public API -------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolve the product bucket for a set of survey answers. use-case is checked
|
||||
* first (in questionnaire display order), then role, then the general
|
||||
* fallback. Exported for tests and callers that only need the bucket.
|
||||
*/
|
||||
export function resolveProductType(input: RecommendationInput): ProductType {
|
||||
const useCases = canonicalizeUseCases(normalizeList(input.useCases));
|
||||
for (const useCase of useCases) {
|
||||
const product = USE_CASE_TO_PRODUCT[useCase];
|
||||
if (product) return product;
|
||||
}
|
||||
const role = normalizeValue(input.role);
|
||||
if (role && ROLE_TO_PRODUCT[role]) return ROLE_TO_PRODUCT[role];
|
||||
return 'general';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the recommendation for a completed questionnaire. Always returns a
|
||||
* value (unknown goals resolve to the general starting point); deciding
|
||||
* *whether* to surface it — only right after the user completes the survey, not
|
||||
* on skip or for returning users — is the caller's responsibility.
|
||||
*/
|
||||
export function buildRecommendation(input: RecommendationInput): Recommendation {
|
||||
// Canonicalize once so both the resolved product bucket AND the echoed
|
||||
// `useCases` (telemetry) are deterministic for a given selection set,
|
||||
// independent of the order the user clicked the chips.
|
||||
const useCases = canonicalizeUseCases(normalizeList(input.useCases));
|
||||
const productType = resolveProductType({ role: input.role, useCases });
|
||||
const options = STARTERS_BY_PRODUCT[productType];
|
||||
return {
|
||||
productType,
|
||||
primary: options[0],
|
||||
options,
|
||||
role: normalizeValue(input.role) ?? '',
|
||||
useCases,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* All starter options for a product path, in catalog order. Used by Studio's
|
||||
* empty-conversation starter templates so a recommendation-started project
|
||||
* offers the same path's alternatives as one-click composer replacements.
|
||||
*/
|
||||
export function startersForProduct(productType: ProductType): readonly StarterOption[] {
|
||||
return STARTERS_BY_PRODUCT[productType];
|
||||
}
|
||||
|
||||
/**
|
||||
* The next starter option to show when the user clicks "换一个". Cycles within
|
||||
* the same product path and wraps around; returns the sole option unchanged
|
||||
* when the path has only one. Pure so the region can stay stateless beyond the
|
||||
* current id.
|
||||
*/
|
||||
export function nextStarter(options: readonly StarterOption[], currentId: string): StarterOption {
|
||||
const first = options[0];
|
||||
if (!first) {
|
||||
throw new Error('nextStarter: options must not be empty');
|
||||
}
|
||||
const index = options.findIndex((option) => option.id === currentId);
|
||||
// Unknown current id → start from the front; otherwise advance and wrap.
|
||||
const nextIndex = index < 0 ? 0 : (index + 1) % options.length;
|
||||
return options[nextIndex] ?? first;
|
||||
}
|
||||
|
||||
// --- Helpers ----------------------------------------------------------------
|
||||
|
||||
function normalizeValue(value: string | null | undefined): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function normalizeList(values: readonly string[] | null | undefined): string[] {
|
||||
if (!values) return [];
|
||||
return values.map((value) => value.trim()).filter((value) => value.length > 0);
|
||||
}
|
||||
|
||||
// Reorder use-case values into canonical questionnaire display order so a set
|
||||
// of answers resolves identically regardless of the click sequence that
|
||||
// produced it. Values not in the catalogue keep their relative order but sort
|
||||
// after every known one (a stable sort keyed on the display index). Input is
|
||||
// assumed already normalized/de-duplicated by `normalizeList`.
|
||||
function canonicalizeUseCases(values: string[]): string[] {
|
||||
const orderOf = (value: string): number => {
|
||||
const index = USE_CASE_DISPLAY_ORDER.indexOf(value);
|
||||
return index < 0 ? Number.MAX_SAFE_INTEGER : index;
|
||||
};
|
||||
return values
|
||||
.map((value, index) => ({ value, index }))
|
||||
.sort((a, b) => orderOf(a.value) - orderOf(b.value) || a.index - b.index)
|
||||
.map((entry) => entry.value);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Starter id → localized copy keys, shared by the Home recommendation strip
|
||||
// and Studio's empty-conversation starter templates. Keys are literal
|
||||
// `keyof Dict`, so a missing translation is a typecheck error rather than a
|
||||
// runtime blank. Keyed by the stable starter id from the recommendation
|
||||
// mapping (`apps/web/src/onboarding/recommendation.ts`).
|
||||
|
||||
import type { Dict } from '../i18n/types';
|
||||
|
||||
export interface StarterCopy {
|
||||
title: keyof Dict;
|
||||
desc: keyof Dict;
|
||||
firstPrompt: keyof Dict;
|
||||
}
|
||||
|
||||
const STARTER_COPY = {
|
||||
product_ui_prototype: {
|
||||
title: 'home.starter.product_ui_prototype.title',
|
||||
desc: 'home.starter.product_ui_prototype.desc',
|
||||
firstPrompt: 'home.starter.product_ui_prototype.firstPrompt',
|
||||
},
|
||||
product_ui_component: {
|
||||
title: 'home.starter.product_ui_component.title',
|
||||
desc: 'home.starter.product_ui_component.desc',
|
||||
firstPrompt: 'home.starter.product_ui_component.firstPrompt',
|
||||
},
|
||||
product_ui_lowfi: {
|
||||
title: 'home.starter.product_ui_lowfi.title',
|
||||
desc: 'home.starter.product_ui_lowfi.desc',
|
||||
firstPrompt: 'home.starter.product_ui_lowfi.firstPrompt',
|
||||
},
|
||||
marketing_landing: {
|
||||
title: 'home.starter.marketing_landing.title',
|
||||
desc: 'home.starter.marketing_landing.desc',
|
||||
firstPrompt: 'home.starter.marketing_landing.firstPrompt',
|
||||
},
|
||||
marketing_multivariant: {
|
||||
title: 'home.starter.marketing_multivariant.title',
|
||||
desc: 'home.starter.marketing_multivariant.desc',
|
||||
firstPrompt: 'home.starter.marketing_multivariant.firstPrompt',
|
||||
},
|
||||
internal_dashboard: {
|
||||
title: 'home.starter.internal_dashboard.title',
|
||||
desc: 'home.starter.internal_dashboard.desc',
|
||||
firstPrompt: 'home.starter.internal_dashboard.firstPrompt',
|
||||
},
|
||||
internal_report: {
|
||||
title: 'home.starter.internal_report.title',
|
||||
desc: 'home.starter.internal_report.desc',
|
||||
firstPrompt: 'home.starter.internal_report.firstPrompt',
|
||||
},
|
||||
general_menu: {
|
||||
title: 'home.starter.general_menu.title',
|
||||
desc: 'home.starter.general_menu.desc',
|
||||
firstPrompt: 'home.starter.general_menu.firstPrompt',
|
||||
},
|
||||
} satisfies Record<string, StarterCopy>;
|
||||
|
||||
// Unknown ids resolve to the general fallback so a future starter id shipped
|
||||
// ahead of its copy can never render blank.
|
||||
export function starterCopyFor(id: string): StarterCopy {
|
||||
return STARTER_COPY[id as keyof typeof STARTER_COPY] ?? STARTER_COPY.general_menu;
|
||||
}
|
||||
@@ -893,11 +893,9 @@
|
||||
font-size: 13px;
|
||||
transition: color 160ms var(--ease-out);
|
||||
}
|
||||
.chat-example:hover .chat-example-cta {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
transform: translateX(2px);
|
||||
}
|
||||
/* No hover state on the ↵ glyph: these cards FILL the composer (they don't
|
||||
send), so an accent-filled circle on hover over-promises the action. The
|
||||
card's own hover carries the affordance. */
|
||||
|
||||
/* Connect-your-repo CTA in the empty chat state. Shares the example cards'
|
||||
sizing and accent system but stands apart with a tinted fill so it reads as
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Regression test for "first-analytics-session marker must be consent-gated"
|
||||
// (PR #5111 review). `isFirstSession()` is a pure read; the pin is written only
|
||||
// once `/api/analytics/config` confirms capture is enabled, which every caller
|
||||
// funnels through `getAnalyticsClient`. The contract pinned here: an install
|
||||
// that first boots with analytics OFF and opts in later still records its real
|
||||
// first *analytics* session as first, instead of leaving the marker unset and
|
||||
// reporting `is_first_session=true` forever.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const FIRST_SESSION_KEY = 'open-design:analytics.first_session_id';
|
||||
|
||||
const posthogState = vi.hoisted(() => ({ initShouldThrow: false }));
|
||||
|
||||
vi.mock('posthog-js', () => {
|
||||
const stub = {
|
||||
init: (_key: string, config: { loaded?: (i: unknown) => void }) => {
|
||||
if (posthogState.initShouldThrow) throw new Error('posthog init failed');
|
||||
config.loaded?.(stub);
|
||||
return stub;
|
||||
},
|
||||
register: () => undefined,
|
||||
setPersonProperties: () => undefined,
|
||||
opt_in_capturing: () => undefined,
|
||||
opt_out_capturing: () => undefined,
|
||||
reset: () => undefined,
|
||||
identify: () => undefined,
|
||||
};
|
||||
return { default: stub };
|
||||
});
|
||||
|
||||
function analyticsConfigResponse(enabled: boolean): Response {
|
||||
const body = enabled
|
||||
? {
|
||||
enabled: true,
|
||||
env: 'local_development',
|
||||
key: 'phc_test_key',
|
||||
host: 'https://us.i.posthog.com',
|
||||
installationId: 'install-123',
|
||||
}
|
||||
: { enabled: false };
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
function createStorageStub(): Storage {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
getItem: (key: string) => (store.has(key) ? store.get(key)! : null),
|
||||
setItem: (key: string, value: string) => {
|
||||
store.set(key, String(value));
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
store.delete(key);
|
||||
},
|
||||
clear: () => store.clear(),
|
||||
key: (i: number) => Array.from(store.keys())[i] ?? null,
|
||||
get length() {
|
||||
return store.size;
|
||||
},
|
||||
} as Storage;
|
||||
}
|
||||
|
||||
describe('first-analytics-session pin is consent-gated', () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let captureEnabled = false;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
// jsdom in this suite does not expose a working Storage, so pin the two
|
||||
// storage surfaces the analytics identity helpers touch to fresh stubs.
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: createStorageStub(),
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(window, 'sessionStorage', {
|
||||
value: createStorageStub(),
|
||||
configurable: true,
|
||||
});
|
||||
captureEnabled = false;
|
||||
posthogState.initShouldThrow = false;
|
||||
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url =
|
||||
typeof input === 'string'
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.href
|
||||
: input.url;
|
||||
if (url.endsWith('/api/analytics/config')) {
|
||||
return analyticsConfigResponse(captureEnabled);
|
||||
}
|
||||
return new Response('not found', { status: 404 });
|
||||
}) as unknown as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const ctx = {
|
||||
anonymousId: 'anon-1',
|
||||
sessionId: 'sess-1',
|
||||
clientType: 'web' as const,
|
||||
locale: 'en',
|
||||
appVersion: '1.2.3',
|
||||
};
|
||||
|
||||
it('does not pin the first session when capture is disabled at boot', async () => {
|
||||
const { getAnalyticsClient } = await import('../src/analytics/client');
|
||||
captureEnabled = false;
|
||||
expect(await getAnalyticsClient(ctx)).toBeNull();
|
||||
// Let getAnalyticsClient reset its cached initPromise after a null result.
|
||||
await Promise.resolve();
|
||||
expect(window.localStorage.getItem(FIRST_SESSION_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('pins the current session once capture is enabled', async () => {
|
||||
const { getAnalyticsClient } = await import('../src/analytics/client');
|
||||
const { getSessionId, isFirstSession } = await import('../src/analytics/identity');
|
||||
captureEnabled = true;
|
||||
expect(await getAnalyticsClient(ctx)).not.toBeNull();
|
||||
expect(window.localStorage.getItem(FIRST_SESSION_KEY)).toBe(getSessionId());
|
||||
// A later browser session (fresh session id, surviving pin) is not first.
|
||||
window.sessionStorage.clear();
|
||||
expect(getSessionId()).not.toBe(window.localStorage.getItem(FIRST_SESSION_KEY));
|
||||
expect(isFirstSession()).toBe(false);
|
||||
});
|
||||
|
||||
it('boots with metrics off, enabled later in the same session, still marks first', async () => {
|
||||
const { getAnalyticsClient } = await import('../src/analytics/client');
|
||||
const { getSessionId, isFirstSession } = await import('../src/analytics/identity');
|
||||
// Boot with metrics off: no client, no pin.
|
||||
captureEnabled = false;
|
||||
expect(await getAnalyticsClient(ctx)).toBeNull();
|
||||
await Promise.resolve();
|
||||
expect(window.localStorage.getItem(FIRST_SESSION_KEY)).toBeNull();
|
||||
const firstSessionId = getSessionId();
|
||||
// The user opts in later in the same tab session.
|
||||
captureEnabled = true;
|
||||
expect(await getAnalyticsClient(ctx)).not.toBeNull();
|
||||
// This session is pinned as the install's first analytics session.
|
||||
expect(window.localStorage.getItem(FIRST_SESSION_KEY)).toBe(firstSessionId);
|
||||
expect(isFirstSession()).toBe(true);
|
||||
});
|
||||
|
||||
it('does not pin when config is enabled but client init fails', async () => {
|
||||
const { getAnalyticsClient } = await import('../src/analytics/client');
|
||||
const { isFirstSession } = await import('../src/analytics/identity');
|
||||
captureEnabled = true;
|
||||
posthogState.initShouldThrow = true;
|
||||
// Init throws → getAnalyticsClient collapses to null, and no analytics
|
||||
// session was ever captured, so the first-session marker must NOT advance.
|
||||
expect(await getAnalyticsClient(ctx)).toBeNull();
|
||||
await Promise.resolve();
|
||||
expect(window.localStorage.getItem(FIRST_SESSION_KEY)).toBeNull();
|
||||
// A later boot that DOES init is still the real first analytics session.
|
||||
vi.resetModules();
|
||||
posthogState.initShouldThrow = false;
|
||||
const { getAnalyticsClient: getAnalyticsClientRetry } = await import('../src/analytics/client');
|
||||
expect(await getAnalyticsClientRetry(ctx)).not.toBeNull();
|
||||
expect(window.localStorage.getItem(FIRST_SESSION_KEY)).not.toBeNull();
|
||||
expect(isFirstSession()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
getSessionId,
|
||||
isFirstSession,
|
||||
pinFirstSessionForCapture,
|
||||
} from '../src/analytics/identity';
|
||||
|
||||
function createStorageStub() {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
getItem: (key: string) => (store.has(key) ? store.get(key)! : null),
|
||||
setItem: (key: string, value: string) => { store.set(key, value); },
|
||||
removeItem: (key: string) => { store.delete(key); },
|
||||
clear: () => { store.clear(); },
|
||||
key: (i: number) => Array.from(store.keys())[i] ?? null,
|
||||
get length() { return store.size; },
|
||||
};
|
||||
}
|
||||
|
||||
describe('isFirstSession', () => {
|
||||
beforeEach(() => {
|
||||
(globalThis as unknown as { window: unknown }).window = {
|
||||
localStorage: createStorageStub(),
|
||||
sessionStorage: createStorageStub(),
|
||||
};
|
||||
});
|
||||
afterEach(() => {
|
||||
delete (globalThis as unknown as { window?: unknown }).window;
|
||||
});
|
||||
|
||||
it('is true for the whole first session (idempotent within it)', () => {
|
||||
expect(isFirstSession()).toBe(true);
|
||||
// Repeat calls in the same tab session keep reporting true — a pure read
|
||||
// that never pins on its own, so it is not a one-shot.
|
||||
expect(isFirstSession()).toBe(true);
|
||||
});
|
||||
|
||||
it('reading before consent does NOT pin, so a later session is still first', () => {
|
||||
// A session that boots with analytics off only ever reads the flag.
|
||||
expect(isFirstSession()).toBe(true);
|
||||
const firstSessionId = getSessionId();
|
||||
// Tab session ends before the user ever opts in: sessionStorage resets,
|
||||
// localStorage survives — but nothing was pinned.
|
||||
(window as unknown as { sessionStorage: Storage }).sessionStorage =
|
||||
createStorageStub() as unknown as Storage;
|
||||
expect(getSessionId()).not.toBe(firstSessionId);
|
||||
// The later session is still the install's first *analytics* session.
|
||||
expect(isFirstSession()).toBe(true);
|
||||
});
|
||||
|
||||
it('is false for a later session once capture pinned the first one', () => {
|
||||
expect(isFirstSession()).toBe(true);
|
||||
const firstSessionId = getSessionId();
|
||||
// Consent-gated capture pins the current (first) session.
|
||||
pinFirstSessionForCapture();
|
||||
// Simulate the tab session ending: sessionStorage resets, the localStorage
|
||||
// pin survives.
|
||||
(window as unknown as { sessionStorage: Storage }).sessionStorage =
|
||||
createStorageStub() as unknown as Storage;
|
||||
expect(getSessionId()).not.toBe(firstSessionId);
|
||||
expect(isFirstSession()).toBe(false);
|
||||
});
|
||||
|
||||
it('pinFirstSessionForCapture is a no-op once a session is already pinned', () => {
|
||||
pinFirstSessionForCapture();
|
||||
const pinnedSessionId = getSessionId();
|
||||
// A second call in a fresh session must not overwrite the original pin.
|
||||
(window as unknown as { sessionStorage: Storage }).sessionStorage =
|
||||
createStorageStub() as unknown as Storage;
|
||||
expect(getSessionId()).not.toBe(pinnedSessionId);
|
||||
pinFirstSessionForCapture();
|
||||
// The pin still points at the ORIGINAL session, not this fresh one — had
|
||||
// the second call overwritten it, this new session would read as first.
|
||||
expect(isFirstSession()).toBe(false);
|
||||
});
|
||||
|
||||
it('reports false without throwing when storage is denied', () => {
|
||||
(globalThis as unknown as { window: unknown }).window = {
|
||||
get localStorage(): Storage {
|
||||
throw new Error('storage denied');
|
||||
},
|
||||
get sessionStorage(): Storage {
|
||||
throw new Error('storage denied');
|
||||
},
|
||||
};
|
||||
expect(isFirstSession()).toBe(false);
|
||||
expect(() => pinFirstSessionForCapture()).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Regression for the hint-impression inflation bug (PR #5111 review). The card
|
||||
// returns `null` until the 600ms layout-settle window elapses, but the
|
||||
// `surface_view` impression used to fire on mount regardless of `settled`. A
|
||||
// mount removed during settle would then record an impression the user never
|
||||
// saw, inflating the `onboarding_first_artifact_hint` denominator. The fix
|
||||
// gates the impression on `settled`.
|
||||
|
||||
import { act, cleanup, render } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
surfaceView: vi.fn(),
|
||||
clickFn: vi.fn(),
|
||||
seen: { value: false },
|
||||
}));
|
||||
|
||||
vi.mock('../../src/analytics/events', () => ({
|
||||
trackStudioOnboardingHintSurfaceView: mocks.surfaceView,
|
||||
trackStudioOnboardingHintClick: mocks.clickFn,
|
||||
}));
|
||||
|
||||
vi.mock('../../src/onboarding/first-artifact-hint', () => ({
|
||||
hasSeenFirstArtifactHint: () => mocks.seen.value,
|
||||
markFirstArtifactHintSeen: () => {
|
||||
mocks.seen.value = true;
|
||||
},
|
||||
}));
|
||||
|
||||
import { I18nProvider } from '../../src/i18n';
|
||||
import { FirstArtifactHint } from '../../src/components/FirstArtifactHint';
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.surfaceView.mockClear();
|
||||
mocks.clickFn.mockClear();
|
||||
mocks.seen.value = false;
|
||||
// motion/react's useReducedMotion reads matchMedia; jsdom omits it.
|
||||
if (!window.matchMedia) {
|
||||
window.matchMedia = ((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
})) as unknown as typeof window.matchMedia;
|
||||
}
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
vi.runOnlyPendingTimers();
|
||||
});
|
||||
vi.useRealTimers();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
function renderHint() {
|
||||
return render(
|
||||
<I18nProvider initial="en">
|
||||
<FirstArtifactHint />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('FirstArtifactHint — surface_view impression', () => {
|
||||
it('does not fire before the settle window elapses', () => {
|
||||
act(() => {
|
||||
renderHint();
|
||||
});
|
||||
expect(mocks.surfaceView).not.toHaveBeenCalled();
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(300);
|
||||
});
|
||||
expect(mocks.surfaceView).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fires exactly once after the card becomes renderable', () => {
|
||||
act(() => {
|
||||
renderHint();
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(600);
|
||||
});
|
||||
expect(mocks.surfaceView).toHaveBeenCalledTimes(1);
|
||||
// Later re-measures / resizes must not re-fire.
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(3000);
|
||||
});
|
||||
expect(mocks.surfaceView).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('never fires for a mount removed during the settle window', () => {
|
||||
let unmount = () => {};
|
||||
act(() => {
|
||||
unmount = renderHint().unmount;
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(300);
|
||||
});
|
||||
act(() => {
|
||||
unmount();
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(600);
|
||||
});
|
||||
expect(mocks.surfaceView).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Regression test for the recommendation handoff race (PR #5111 review). The
|
||||
// Home→Studio onboarding entry used to be stashed in a single global
|
||||
// sessionStorage slot BEFORE the project was created, which let an unrelated
|
||||
// project opened mid-create steal the personalized context. The fix keys the
|
||||
// handoff by the created project id and moves the stash into the create success
|
||||
// path — so `RecommendedStartRegion` must NOT touch sessionStorage itself and
|
||||
// must instead hand the entry to `onStart`, letting the create pipeline stash
|
||||
// it once the target id is known.
|
||||
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { I18nProvider } from '../../src/i18n';
|
||||
import { RecommendedStartRegion } from '../../src/components/RecommendedStartRegion';
|
||||
import { buildRecommendation } from '../../src/onboarding/recommendation';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
window.sessionStorage.clear();
|
||||
});
|
||||
|
||||
type OnStart = (input: {
|
||||
name: string;
|
||||
prompt: string;
|
||||
metadata: unknown;
|
||||
onboardingEntry: unknown;
|
||||
}) => unknown;
|
||||
|
||||
function renderRegion(onStart: OnStart) {
|
||||
const recommendation = buildRecommendation({ role: 'designer', useCases: ['prototype'] });
|
||||
return render(
|
||||
<I18nProvider initial="en">
|
||||
<RecommendedStartRegion
|
||||
recommendation={recommendation}
|
||||
onStart={onStart as never}
|
||||
onDismiss={() => undefined}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('RecommendedStartRegion — Start in Studio handoff', () => {
|
||||
it('hands the onboarding entry to onStart (create pipeline keys it by project id)', async () => {
|
||||
let received: Parameters<OnStart>[0] | null = null;
|
||||
const onStart = vi.fn((input: Parameters<OnStart>[0]) => {
|
||||
received = input;
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
renderRegion(onStart);
|
||||
fireEvent.click(screen.getByTestId('home-recommendation-start'));
|
||||
await waitFor(() => expect(onStart).toHaveBeenCalledTimes(1));
|
||||
expect(received).toMatchObject({
|
||||
onboardingEntry: {
|
||||
source: 'home_recommendation',
|
||||
recommendationId: 'product_ui_prototype',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('never writes the handoff to sessionStorage itself', async () => {
|
||||
const setItem = vi.spyOn(window.sessionStorage, 'setItem');
|
||||
const onStart = vi.fn(async () => true);
|
||||
renderRegion(onStart);
|
||||
fireEvent.click(screen.getByTestId('home-recommendation-start'));
|
||||
await waitFor(() => expect(onStart).toHaveBeenCalledTimes(1));
|
||||
await Promise.resolve();
|
||||
expect(setItem).not.toHaveBeenCalled();
|
||||
setItem.mockRestore();
|
||||
});
|
||||
|
||||
it('re-enables the CTA for retry when the start fails', async () => {
|
||||
// `onStart` (the Home wrapper) surfaces its own visible error and resolves
|
||||
// `false` on failure — it never rejects. The region should drop its pending
|
||||
// state so the user can retry, and never leave a stale sessionStorage slot.
|
||||
const onStart = vi.fn(async () => false);
|
||||
renderRegion(onStart);
|
||||
const cta = screen.getByTestId('home-recommendation-start') as HTMLButtonElement;
|
||||
fireEvent.click(cta);
|
||||
await waitFor(() => expect(onStart).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(cta.disabled).toBe(false));
|
||||
expect(window.sessionStorage.length).toBe(0);
|
||||
// Retry is possible: a second click fires onStart again.
|
||||
fireEvent.click(cta);
|
||||
await waitFor(() => expect(onStart).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
});
|
||||
@@ -42,4 +42,10 @@ const motionHandler: ProxyHandler<object> = {
|
||||
|
||||
const motion = new Proxy({}, motionHandler);
|
||||
|
||||
export { AnimatePresence, MotionConfig, motion };
|
||||
// jsdom has no matchMedia; components under test always see "no reduced
|
||||
// motion" so their full animation props stay assertable.
|
||||
function useReducedMotion(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
export { AnimatePresence, MotionConfig, motion, useReducedMotion };
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
consumeOnboardingEntryForProject,
|
||||
hasCompletedFirstOnboardingGeneration,
|
||||
hasSentFirstOnboardingPrompt,
|
||||
markFirstOnboardingGenerationCompleted,
|
||||
markFirstOnboardingPromptSent,
|
||||
stashOnboardingEntryForProject,
|
||||
} from '../src/onboarding/onboarding-entry';
|
||||
|
||||
function createStorageStub() {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
getItem: (key: string) => (store.has(key) ? store.get(key)! : null),
|
||||
setItem: (key: string, value: string) => { store.set(key, value); },
|
||||
removeItem: (key: string) => { store.delete(key); },
|
||||
clear: () => { store.clear(); },
|
||||
key: (i: number) => Array.from(store.keys())[i] ?? null,
|
||||
get length() { return store.size; },
|
||||
};
|
||||
}
|
||||
|
||||
// The helper keeps a module-level parsed-entry cache (StrictMode safety), so
|
||||
// tests use a unique project id each to stay independent of one another.
|
||||
let seq = 0;
|
||||
function nextProjectId(): string {
|
||||
seq += 1;
|
||||
return `proj-${seq}`;
|
||||
}
|
||||
|
||||
describe('onboarding entry (id-keyed session hand-off)', () => {
|
||||
beforeEach(() => {
|
||||
(globalThis as unknown as { window: unknown }).window = {
|
||||
sessionStorage: createStorageStub(),
|
||||
};
|
||||
});
|
||||
afterEach(() => {
|
||||
delete (globalThis as unknown as { window?: unknown }).window;
|
||||
});
|
||||
|
||||
it('round-trips a stashed entry for its project id', () => {
|
||||
const id = nextProjectId();
|
||||
stashOnboardingEntryForProject(id, {
|
||||
source: 'home_recommendation',
|
||||
productType: 'product_ui',
|
||||
recommendationId: 'product_ui_prototype',
|
||||
});
|
||||
expect(consumeOnboardingEntryForProject(id)).toEqual({
|
||||
source: 'home_recommendation',
|
||||
productType: 'product_ui',
|
||||
recommendationId: 'product_ui_prototype',
|
||||
});
|
||||
});
|
||||
|
||||
// Reopen-before-send regression (PR #5111 review): the seed prompt is cached
|
||||
// WITH the entry, so a later remount of the same project (project.pendingPrompt
|
||||
// already wiped) keeps the original comparison base for has_prefilled_prompt.
|
||||
it('carries the seed prompt across a remount (reopen-before-send)', () => {
|
||||
const id = nextProjectId();
|
||||
stashOnboardingEntryForProject(id, {
|
||||
source: 'home_recommendation',
|
||||
productType: 'product_ui',
|
||||
recommendationId: 'product_ui_prototype',
|
||||
seedPrompt: 'Design a pricing page',
|
||||
});
|
||||
// First mount consumes and caches.
|
||||
expect(consumeOnboardingEntryForProject(id)).toMatchObject({
|
||||
seedPrompt: 'Design a pricing page',
|
||||
});
|
||||
// Reopen (remount): the seed is still present from the cache even though the
|
||||
// sessionStorage slot (and project.pendingPrompt) are gone.
|
||||
expect(consumeOnboardingEntryForProject(id)).toMatchObject({
|
||||
seedPrompt: 'Design a pricing page',
|
||||
});
|
||||
});
|
||||
|
||||
it('carries the survey answers when present', () => {
|
||||
const id = nextProjectId();
|
||||
stashOnboardingEntryForProject(id, {
|
||||
source: 'home_recommendation',
|
||||
productType: 'marketing',
|
||||
recommendationId: 'marketing_landing',
|
||||
role: 'growth',
|
||||
useCases: ['landing', 'ads'],
|
||||
});
|
||||
expect(consumeOnboardingEntryForProject(id)).toEqual({
|
||||
source: 'home_recommendation',
|
||||
productType: 'marketing',
|
||||
recommendationId: 'marketing_landing',
|
||||
role: 'growth',
|
||||
useCases: ['landing', 'ads'],
|
||||
});
|
||||
});
|
||||
|
||||
// StrictMode regression (PR #5111 review): the consume is a destructive read
|
||||
// called from ProjectView render. Under React StrictMode the throwaway first
|
||||
// mount consumes the slot; the committed remount must still see the entry.
|
||||
// The module cache serves the same value on the second consume even though
|
||||
// the sessionStorage slot was already removed by the first.
|
||||
it('survives a StrictMode double-consume for the same project id', () => {
|
||||
const id = nextProjectId();
|
||||
stashOnboardingEntryForProject(id, {
|
||||
source: 'home_recommendation',
|
||||
productType: 'product_ui',
|
||||
recommendationId: 'product_ui_prototype',
|
||||
});
|
||||
// First (throwaway) mount consumes and clears the storage slot.
|
||||
const first = consumeOnboardingEntryForProject(id);
|
||||
expect(first).toMatchObject({ recommendationId: 'product_ui_prototype' });
|
||||
// The storage slot is gone...
|
||||
expect(window.sessionStorage.getItem(`open-design:onboarding-entry:${id}`)).toBeNull();
|
||||
// ...but the committed remount still gets the entry from the cache.
|
||||
expect(consumeOnboardingEntryForProject(id)).toEqual(first);
|
||||
});
|
||||
|
||||
it('returns null when nothing was stashed for that id', () => {
|
||||
expect(consumeOnboardingEntryForProject(nextProjectId())).toBeNull();
|
||||
});
|
||||
|
||||
// The race the id-keyed slot fixes (PR #5111 review): clicking "进入 Studio"
|
||||
// and then opening an UNRELATED project before the recommended one finished
|
||||
// creating must not let that unrelated project steal the personalized context.
|
||||
// The entry is keyed by the created project id, so a concurrent mount for a
|
||||
// different id consumes nothing, and the intended project still gets it.
|
||||
it('is isolated per project id — a concurrent unrelated project cannot steal it', () => {
|
||||
const recommended = nextProjectId();
|
||||
const other = nextProjectId();
|
||||
stashOnboardingEntryForProject(recommended, {
|
||||
source: 'home_recommendation',
|
||||
productType: 'product_ui',
|
||||
recommendationId: 'product_ui_prototype',
|
||||
});
|
||||
// An unrelated project mounts first (mid-create navigation) and reads its
|
||||
// own key — nothing there.
|
||||
expect(consumeOnboardingEntryForProject(other)).toBeNull();
|
||||
// The recommendation-started project still finds its context intact.
|
||||
expect(consumeOnboardingEntryForProject(recommended)).toMatchObject({
|
||||
source: 'home_recommendation',
|
||||
recommendationId: 'product_ui_prototype',
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores a malformed stored value', () => {
|
||||
const id = nextProjectId();
|
||||
window.sessionStorage.setItem(
|
||||
`open-design:onboarding-entry:${id}`,
|
||||
'{"source":"nope"}',
|
||||
);
|
||||
expect(consumeOnboardingEntryForProject(id)).toBeNull();
|
||||
});
|
||||
|
||||
it('treats a missing project id as a no-op', () => {
|
||||
expect(() =>
|
||||
stashOnboardingEntryForProject('', {
|
||||
source: 'home_recommendation',
|
||||
productType: 'general',
|
||||
recommendationId: 'general_menu',
|
||||
}),
|
||||
).not.toThrow();
|
||||
expect(consumeOnboardingEntryForProject('')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// The two onboarding funnel events must fire once per recommendation-started
|
||||
// project. ProjectView remounts on every leave/reopen and the entry survives
|
||||
// via the cache, so mount-local guards would let them re-fire on a later
|
||||
// conversation/run. These project-scoped flags (PR #5111 review) hold across
|
||||
// those remounts.
|
||||
describe('onboarding funnel once-per-project guards', () => {
|
||||
it('first-prompt-sent guard stays set across ProjectView remounts', () => {
|
||||
const id = nextProjectId();
|
||||
expect(hasSentFirstOnboardingPrompt(id)).toBe(false);
|
||||
// First conversation's first send.
|
||||
markFirstOnboardingPromptSent(id);
|
||||
expect(hasSentFirstOnboardingPrompt(id)).toBe(true);
|
||||
// A later remount (fresh conversation on the same project) still sees the
|
||||
// guard set, so the send-through event does not re-fire.
|
||||
expect(hasSentFirstOnboardingPrompt(id)).toBe(true);
|
||||
});
|
||||
|
||||
it('first-generation-completed guard stays set across ProjectView remounts', () => {
|
||||
const id = nextProjectId();
|
||||
expect(hasCompletedFirstOnboardingGeneration(id)).toBe(false);
|
||||
markFirstOnboardingGenerationCompleted(id);
|
||||
expect(hasCompletedFirstOnboardingGeneration(id)).toBe(true);
|
||||
// A later HTML-producing run on a remounted project stays suppressed.
|
||||
expect(hasCompletedFirstOnboardingGeneration(id)).toBe(true);
|
||||
});
|
||||
|
||||
it('guards are isolated per project id', () => {
|
||||
const a = nextProjectId();
|
||||
const b = nextProjectId();
|
||||
markFirstOnboardingPromptSent(a);
|
||||
markFirstOnboardingGenerationCompleted(a);
|
||||
expect(hasSentFirstOnboardingPrompt(b)).toBe(false);
|
||||
expect(hasCompletedFirstOnboardingGeneration(b)).toBe(false);
|
||||
});
|
||||
|
||||
it('treats a missing project id as never-marked', () => {
|
||||
expect(hasSentFirstOnboardingPrompt('')).toBe(false);
|
||||
markFirstOnboardingPromptSent('');
|
||||
expect(hasSentFirstOnboardingPrompt('')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
hasSeenFirstArtifactHint,
|
||||
markFirstArtifactHintSeen,
|
||||
} from '../src/onboarding/first-artifact-hint';
|
||||
|
||||
// Minimal in-memory localStorage stub. Vitest runs in a node env, so we
|
||||
// provide just enough of the Storage interface for the helper's code paths.
|
||||
function createStorageStub() {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
getItem: (key: string) => (store.has(key) ? store.get(key)! : null),
|
||||
setItem: (key: string, value: string) => { store.set(key, value); },
|
||||
removeItem: (key: string) => { store.delete(key); },
|
||||
clear: () => { store.clear(); },
|
||||
key: (i: number) => Array.from(store.keys())[i] ?? null,
|
||||
get length() { return store.size; },
|
||||
};
|
||||
}
|
||||
|
||||
describe('first-artifact-hint seen flag', () => {
|
||||
beforeEach(() => {
|
||||
(globalThis as unknown as { window: unknown }).window = {
|
||||
localStorage: createStorageStub(),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete (globalThis as unknown as { window?: unknown }).window;
|
||||
});
|
||||
|
||||
it('starts unseen and becomes seen once marked', () => {
|
||||
expect(hasSeenFirstArtifactHint()).toBe(false);
|
||||
markFirstArtifactHintSeen();
|
||||
expect(hasSeenFirstArtifactHint()).toBe(true);
|
||||
});
|
||||
|
||||
it('is idempotent — marking twice stays seen', () => {
|
||||
markFirstArtifactHintSeen();
|
||||
markFirstArtifactHintSeen();
|
||||
expect(hasSeenFirstArtifactHint()).toBe(true);
|
||||
});
|
||||
|
||||
it('treats a storage-denied context as not seen and does not throw', () => {
|
||||
(globalThis as unknown as { window: unknown }).window = {
|
||||
get localStorage(): Storage {
|
||||
throw new Error('storage denied');
|
||||
},
|
||||
};
|
||||
expect(hasSeenFirstArtifactHint()).toBe(false);
|
||||
expect(() => markFirstArtifactHintSeen()).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { producedPreviewableArtifact } from '../src/onboarding/first-generation';
|
||||
|
||||
// Regression for the funnel-inflation bug (PR #5111 review):
|
||||
// `onboarding_first_generation_completed` must fire only when a run actually
|
||||
// produced a previewable artifact — mirroring the first-artifact hint — not on
|
||||
// any `succeeded` run. This predicate is the gate; ProjectView applies it to
|
||||
// the files produced during the turn.
|
||||
describe('producedPreviewableArtifact', () => {
|
||||
it('is true when the turn produced an .html artifact', () => {
|
||||
expect(producedPreviewableArtifact([{ name: 'index.html' }])).toBe(true);
|
||||
});
|
||||
|
||||
it('is true regardless of case', () => {
|
||||
expect(producedPreviewableArtifact([{ name: 'Landing.HTML' }])).toBe(true);
|
||||
});
|
||||
|
||||
it('is true when at least one produced file is previewable', () => {
|
||||
expect(
|
||||
producedPreviewableArtifact([
|
||||
{ name: 'notes.md' },
|
||||
{ name: 'data.json' },
|
||||
{ name: 'page.html' },
|
||||
]),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('is false for a succeeded run that produced no artifact (text/question only)', () => {
|
||||
expect(producedPreviewableArtifact([])).toBe(false);
|
||||
});
|
||||
|
||||
it('is false when only non-previewable files were produced', () => {
|
||||
expect(
|
||||
producedPreviewableArtifact([
|
||||
{ name: 'plan.md' },
|
||||
{ name: 'palette.json' },
|
||||
{ name: 'screenshot.png' },
|
||||
]),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('does not treat a filename that merely contains "html" as previewable', () => {
|
||||
expect(producedPreviewableArtifact([{ name: 'html-notes.md' }])).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { sentPrefilledPrompt } from '../src/onboarding/first-prompt';
|
||||
|
||||
describe('sentPrefilledPrompt', () => {
|
||||
const seed = 'Design a mobile onboarding flow';
|
||||
|
||||
it('is true when the prefilled suggestion is sent unmodified', () => {
|
||||
expect(sentPrefilledPrompt(seed, seed)).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores surrounding whitespace on both sides', () => {
|
||||
expect(sentPrefilledPrompt(` ${seed} `, `${seed}\n`)).toBe(true);
|
||||
});
|
||||
|
||||
it('is false when the user edits the suggestion before sending', () => {
|
||||
expect(sentPrefilledPrompt(seed, `${seed} for iOS`)).toBe(false);
|
||||
expect(sentPrefilledPrompt(seed, 'Something completely different')).toBe(false);
|
||||
});
|
||||
|
||||
it('is false when the user clears the seed and sends attachments only', () => {
|
||||
expect(sentPrefilledPrompt(seed, '')).toBe(false);
|
||||
expect(sentPrefilledPrompt(seed, ' ')).toBe(false);
|
||||
});
|
||||
|
||||
it('is false when there was no seed to begin with', () => {
|
||||
expect(sentPrefilledPrompt('', seed)).toBe(false);
|
||||
expect(sentPrefilledPrompt(' ', seed)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildRecommendation,
|
||||
nextStarter,
|
||||
resolveProductType,
|
||||
type StarterOption,
|
||||
} from '../src/onboarding/recommendation';
|
||||
|
||||
describe('resolveProductType', () => {
|
||||
it('maps product-facing use-cases to product_ui', () => {
|
||||
expect(resolveProductType({ useCases: ['prototype'] })).toBe('product_ui');
|
||||
expect(resolveProductType({ useCases: ['design-system'] })).toBe('product_ui');
|
||||
expect(resolveProductType({ useCases: ['engineering'] })).toBe('product_ui');
|
||||
});
|
||||
|
||||
it('maps marketing use-cases to marketing', () => {
|
||||
expect(resolveProductType({ useCases: ['landing'] })).toBe('marketing');
|
||||
expect(resolveProductType({ useCases: ['ads'] })).toBe('marketing');
|
||||
expect(resolveProductType({ useCases: ['agency'] })).toBe('marketing');
|
||||
});
|
||||
|
||||
it('maps dashboard use-case to internal_tool', () => {
|
||||
expect(resolveProductType({ useCases: ['dashboard'] })).toBe('internal_tool');
|
||||
});
|
||||
|
||||
it('prioritizes use-case over role', () => {
|
||||
// Role says ops (internal_tool) but the goal is a landing page (marketing).
|
||||
expect(resolveProductType({ role: 'ops', useCases: ['landing'] })).toBe('marketing');
|
||||
});
|
||||
|
||||
it('honors questionnaire order — first resolvable signal wins', () => {
|
||||
// `deck` has no mapping and is skipped; `landing` resolves.
|
||||
expect(resolveProductType({ useCases: ['deck', 'landing'] })).toBe('marketing');
|
||||
});
|
||||
|
||||
it('is independent of the click sequence (canonical questionnaire order)', () => {
|
||||
// `product` sits before `landing` in the questionnaire, so a set containing
|
||||
// both resolves to product_ui no matter which chip the user tapped first —
|
||||
// otherwise the same answers would flip between product_ui and marketing.
|
||||
expect(resolveProductType({ useCases: ['product', 'landing'] })).toBe('product_ui');
|
||||
expect(resolveProductType({ useCases: ['landing', 'product'] })).toBe('product_ui');
|
||||
// `landing` precedes `dashboard`, so their set always resolves to marketing.
|
||||
expect(resolveProductType({ useCases: ['dashboard', 'landing'] })).toBe('marketing');
|
||||
expect(resolveProductType({ useCases: ['landing', 'dashboard'] })).toBe('marketing');
|
||||
});
|
||||
|
||||
it('falls back to role when no use-case resolves', () => {
|
||||
expect(resolveProductType({ role: 'designer', useCases: ['deck'] })).toBe('product_ui');
|
||||
expect(resolveProductType({ role: 'growth' })).toBe('marketing');
|
||||
expect(resolveProductType({ role: 'ops' })).toBe('internal_tool');
|
||||
});
|
||||
|
||||
it('falls back to general for ambiguous roles and empty input', () => {
|
||||
expect(resolveProductType({ role: 'founder' })).toBe('general');
|
||||
expect(resolveProductType({ role: 'student' })).toBe('general');
|
||||
expect(resolveProductType({ role: 'other' })).toBe('general');
|
||||
expect(resolveProductType({})).toBe('general');
|
||||
expect(resolveProductType({ role: ' ', useCases: ['', ' '] })).toBe('general');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildRecommendation', () => {
|
||||
it('returns a primary that is the first option in the path', () => {
|
||||
const rec = buildRecommendation({ useCases: ['prototype'] });
|
||||
expect(rec.productType).toBe('product_ui');
|
||||
expect(rec.primary).toBe(rec.options[0]);
|
||||
expect(rec.options.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('echoes normalized inputs for telemetry', () => {
|
||||
const rec = buildRecommendation({ role: ' designer ', useCases: ['prototype', ' '] });
|
||||
expect(rec.role).toBe('designer');
|
||||
expect(rec.useCases).toEqual(['prototype']);
|
||||
});
|
||||
|
||||
it('always resolves — unknown goals produce the general starter', () => {
|
||||
const rec = buildRecommendation({});
|
||||
expect(rec.productType).toBe('general');
|
||||
expect(rec.primary.id).toBe('general_menu');
|
||||
expect(rec.options).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('carries a stable primary id per path', () => {
|
||||
expect(buildRecommendation({ useCases: ['landing'] }).primary.id).toBe('marketing_landing');
|
||||
expect(buildRecommendation({ useCases: ['prototype'] }).primary.id).toBe('product_ui_prototype');
|
||||
expect(buildRecommendation({ role: 'ops' }).primary.id).toBe('internal_dashboard');
|
||||
});
|
||||
|
||||
it('is identical for the same answer set in any selection order', () => {
|
||||
const a = buildRecommendation({ role: 'ops', useCases: ['landing', 'product', 'deck'] });
|
||||
const b = buildRecommendation({ role: 'ops', useCases: ['deck', 'product', 'landing'] });
|
||||
expect(a).toEqual(b);
|
||||
// Echoed use-cases are canonicalized (questionnaire order), not click order,
|
||||
// so telemetry for the same survey state is stable too.
|
||||
expect(a.useCases).toEqual(['product', 'landing', 'deck']);
|
||||
expect(a.productType).toBe('product_ui');
|
||||
});
|
||||
});
|
||||
|
||||
describe('nextStarter', () => {
|
||||
// product_ui path is ordered [prototype, component, lowfi].
|
||||
const options: readonly StarterOption[] = buildRecommendation({ useCases: ['prototype'] }).options;
|
||||
|
||||
it('advances to the next option within the path', () => {
|
||||
expect(nextStarter(options, 'product_ui_prototype').id).toBe('product_ui_component');
|
||||
expect(nextStarter(options, 'product_ui_component').id).toBe('product_ui_lowfi');
|
||||
});
|
||||
|
||||
it('wraps around at the end', () => {
|
||||
expect(nextStarter(options, 'product_ui_lowfi').id).toBe('product_ui_prototype');
|
||||
});
|
||||
|
||||
it('starts from the front for an unknown current id', () => {
|
||||
expect(nextStarter(options, 'nope').id).toBe('product_ui_prototype');
|
||||
});
|
||||
|
||||
it('returns the sole option unchanged for a single-option path', () => {
|
||||
const single = buildRecommendation({}).options;
|
||||
expect(single).toHaveLength(1);
|
||||
expect(nextStarter(single, 'general_menu').id).toBe('general_menu');
|
||||
});
|
||||
});
|
||||
@@ -36,6 +36,7 @@ export type AnalyticsEventName =
|
||||
// Artifact
|
||||
| 'artifact_export_result'
|
||||
| 'artifact_deploy_result'
|
||||
| 'file_version_restore_result'
|
||||
// Feedback
|
||||
| 'feedback_submit_result'
|
||||
| 'assistant_feedback_click'
|
||||
@@ -48,12 +49,21 @@ export type AnalyticsEventName =
|
||||
| 'settings_byok_test_result'
|
||||
| 'settings_byok_models_fetch_result'
|
||||
| 'settings_connector_auth_result'
|
||||
// AMR (hosted model) account auth result.
|
||||
| 'amr_auth_result'
|
||||
// Onboarding-only result events. UI clicks + page_views inside the
|
||||
// onboarding flow reuse the generic `ui_click` / `page_view` shapes
|
||||
// with `page_name=onboarding`; the three `onboarding_*` names below
|
||||
// capture lifecycle moments that don't fit a click or a view.
|
||||
| 'onboarding_runtime_scan_result'
|
||||
| 'onboarding_complete_result'
|
||||
// First-generation funnel (spec §11.1). Fire in Studio when the user, having
|
||||
// arrived from the Home recommendation, actually sends their first request
|
||||
// and when that first generation completes — the send-through and
|
||||
// completion rates the onboarding acceptance criteria track.
|
||||
| 'onboarding_prompt_prefilled'
|
||||
| 'onboarding_first_prompt_sent'
|
||||
| 'onboarding_first_generation_completed'
|
||||
// Design-system lifecycle. Clicks + page_views inside DS surfaces
|
||||
// reuse `ui_click` / `page_view`; the five names below capture
|
||||
// ingest / create / review / status / picker-apply moments.
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
* @module analytics/events/event-payload
|
||||
* Discriminated union of all analytics event payloads.
|
||||
*/
|
||||
import type { AnalyticsEventName } from './event-names.js';
|
||||
import type { DesignSystemApplyResultProps, DesignSystemCreateResultProps, DesignSystemEnrichResultProps, DesignSystemReviewResultProps, DesignSystemSourceIngestResultProps, DesignSystemStatusResultProps } from './design-systems.js';
|
||||
import type { OnboardingCompleteResultProps, OnboardingRuntimeScanResultProps } from './onboarding.js';
|
||||
import type { OnboardingCompleteResultProps, OnboardingFirstGenerationCompletedProps, OnboardingFirstPromptSentProps, OnboardingPromptPrefilledProps, OnboardingRuntimeScanResultProps } from './onboarding.js';
|
||||
import type { PageViewProps } from './page-view.js';
|
||||
import type { ArtifactDeployResultProps, ArtifactExportResultProps, AssistantFeedbackClickProps, AssistantFeedbackReasonClickProps, AssistantFeedbackReasonSubmitProps, AssistantFeedbackReasonViewProps, ContextLinkResultProps, FeedbackSubmitResultProps, FileUploadResultProps, FileVersionRestoreResultProps, LangfuseReportResultProps, PackagedRuntimeFailedProps, PluginImportResultProps, PluginReplacementResultProps, ProjectCreateResultProps, RunCreatedProps, RunFinishedProps, RunRetryAttemptedProps, RunRetryFinishedProps, SettingsByokModelsFetchResultProps, SettingsByokTestResultProps, SettingsCliTestResultProps, SettingsConnectorAuthResultProps, SettingsViewProps, SketchExportResultProps, SketchSaveResultProps, UpdateApplyObservedProps, UpdateInstallResultProps } from './result-events.js';
|
||||
import type { SurfaceViewProps } from './surface-view.js';
|
||||
@@ -57,6 +58,12 @@ export type AnalyticsEventPayload =
|
||||
| { event: 'amr_auth_result'; props: AmrAuthResultProps }
|
||||
| { event: 'onboarding_runtime_scan_result'; props: OnboardingRuntimeScanResultProps }
|
||||
| { event: 'onboarding_complete_result'; props: OnboardingCompleteResultProps }
|
||||
| { event: 'onboarding_prompt_prefilled'; props: OnboardingPromptPrefilledProps }
|
||||
| { event: 'onboarding_first_prompt_sent'; props: OnboardingFirstPromptSentProps }
|
||||
| {
|
||||
event: 'onboarding_first_generation_completed';
|
||||
props: OnboardingFirstGenerationCompletedProps;
|
||||
}
|
||||
| {
|
||||
event: 'design_system_source_ingest_result';
|
||||
props: DesignSystemSourceIngestResultProps;
|
||||
@@ -67,3 +74,18 @@ export type AnalyticsEventPayload =
|
||||
| { event: 'design_system_apply_result'; props: DesignSystemApplyResultProps }
|
||||
| { event: 'design_system_enrich_result'; props: DesignSystemEnrichResultProps };
|
||||
|
||||
// Compile-time guard: `AnalyticsEventName` (event-names.ts) and the payload
|
||||
// discriminants above must stay in sync. If a future catalog split drops or
|
||||
// adds an event name on only one side, `AnalyticsEventCatalogDrift` stops being
|
||||
// `never` and the assertion below fails to type-check — reconcile the two files
|
||||
// rather than letting the public contract diverge. Downstream code that
|
||||
// validates or switches on `AnalyticsEventName` relies on this parity.
|
||||
type AnalyticsEventCatalogDrift =
|
||||
| Exclude<AnalyticsEventName, AnalyticsEventPayload['event']>
|
||||
| Exclude<AnalyticsEventPayload['event'], AnalyticsEventName>;
|
||||
// The default type argument must satisfy `extends never`; any drift makes it a
|
||||
// non-`never` union and turns this alias into a type error.
|
||||
type AssertAnalyticsEventCatalogInSync<
|
||||
_Drift extends never = AnalyticsEventCatalogDrift,
|
||||
> = _Drift;
|
||||
|
||||
|
||||
@@ -116,6 +116,17 @@ export type TrackingOnboardingDiscoverySource = string;
|
||||
// force a contract bump.
|
||||
export type TrackingOnboardingRole = string;
|
||||
|
||||
// The product bucket a personalized Home recommendation resolved to. Mirrors
|
||||
// the `ProductType` union produced by the web recommendation mapping
|
||||
// (`apps/web/src/onboarding/recommendation.ts`). Kept as a closed literal
|
||||
// union — unlike the open survey strings above — because these four buckets
|
||||
// are defined by the mapping, not by the user-extensible survey catalogue.
|
||||
export type TrackingOnboardingProductType =
|
||||
| 'product_ui'
|
||||
| 'marketing'
|
||||
| 'internal_tool'
|
||||
| 'general';
|
||||
|
||||
export interface OnboardingPageViewProps {
|
||||
page_name: 'onboarding';
|
||||
area: TrackingOnboardingArea;
|
||||
@@ -248,3 +259,36 @@ export interface OnboardingCompleteResultProps {
|
||||
discovery_source?: TrackingOnboardingDiscoverySource;
|
||||
}
|
||||
|
||||
// Fired once when Studio mounts with the recommended first request pre-filled
|
||||
// in the composer — the "Home → Studio handoff succeeded" step of the funnel,
|
||||
// between the recommendation's `enter_studio` click and
|
||||
// `onboarding_first_prompt_sent`. `role` / `use_cases` echo the survey answers
|
||||
// that produced the recommendation (spec §11.1).
|
||||
export interface OnboardingPromptPrefilledProps {
|
||||
entry_source: 'home_recommendation';
|
||||
product_type: TrackingOnboardingProductType;
|
||||
recommendation_id: string;
|
||||
role?: TrackingOnboardingRole;
|
||||
use_cases?: TrackingOnboardingUseCase[];
|
||||
}
|
||||
|
||||
// Fired once, in Studio, when the user sends the first request in a project
|
||||
// they started from the Home recommendation. `has_prefilled_prompt` records
|
||||
// whether the composer was still carrying the recommended text at send time
|
||||
// (vs the user having cleared/rewritten it). Together with the recommendation
|
||||
// `enter_studio` click this gives the send-through rate.
|
||||
export interface OnboardingFirstPromptSentProps {
|
||||
entry_source: 'home_recommendation';
|
||||
product_type: TrackingOnboardingProductType;
|
||||
recommendation_id: string;
|
||||
has_prefilled_prompt: boolean;
|
||||
}
|
||||
|
||||
// Fired once when that first generation completes successfully — the
|
||||
// completion rate the acceptance criteria track.
|
||||
export interface OnboardingFirstGenerationCompletedProps {
|
||||
entry_source: 'home_recommendation';
|
||||
product_type: TrackingOnboardingProductType;
|
||||
recommendation_id: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* @module analytics/events/surface-view
|
||||
* surface_view event prop types and their union.
|
||||
*/
|
||||
import type { TrackingOnboardingProductType, TrackingOnboardingRole, TrackingOnboardingUseCase } from './onboarding.js';
|
||||
import type { TrackingArtifactKind, TrackingNewProjectTab, TrackingProjectKind } from './shared-enums.js';
|
||||
import type { DesignSystemsPresetBrandPickerSurfaceViewProps } from './ui-click.js';
|
||||
// ---- surface_view --------------------------------------------------------
|
||||
@@ -149,8 +150,29 @@ export interface FileVersionModalSurfaceViewProps {
|
||||
artifact_kind: TrackingArtifactKind;
|
||||
}
|
||||
|
||||
// Impression of the personalized first-run recommendation card on Home. Fires
|
||||
// once per exposure so the funnel can divide `enter_studio` / `change` /
|
||||
// `browse_all` clicks by how often the recommendation was actually seen.
|
||||
export interface HomeRecommendationSurfaceViewProps {
|
||||
page_name: 'home';
|
||||
area: 'onboarding_recommendation';
|
||||
product_type: TrackingOnboardingProductType;
|
||||
recommendation_id: string;
|
||||
role?: TrackingOnboardingRole;
|
||||
use_cases?: TrackingOnboardingUseCase[];
|
||||
}
|
||||
|
||||
// Impression of the one-time first-generation hint in Studio (spec §8.3).
|
||||
export interface StudioOnboardingHintSurfaceViewProps {
|
||||
page_name: 'chat_panel';
|
||||
area: 'onboarding_first_artifact_hint';
|
||||
hint_type: 'view_artifact';
|
||||
}
|
||||
|
||||
export type SurfaceViewProps =
|
||||
| RunFailedToastSurfaceViewProps
|
||||
| HomeRecommendationSurfaceViewProps
|
||||
| StudioOnboardingHintSurfaceViewProps
|
||||
| HelpPopoverSurfaceViewProps
|
||||
| SettingsPopoverSurfaceViewProps
|
||||
| NewProjectModalSurfaceViewProps
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
import type { DesignSystemEnrichClickProps, TrackingDesignSystemEditSurface } from './design-systems.js';
|
||||
import type { TrackingPageName, TrackingSettingsPage } from './event-names.js';
|
||||
import type { OnboardingClickProps } from './onboarding.js';
|
||||
import type { OnboardingClickProps, TrackingOnboardingProductType, TrackingOnboardingRole, TrackingOnboardingUseCase } from './onboarding.js';
|
||||
import type { TrackingAmrEntrySource, TrackingArtifactKind, TrackingByokProviderId, TrackingCliProviderId, TrackingExecutionMode, TrackingExportFormat, TrackingFeedbackProviderId, TrackingNewProjectTab, TrackingProjectKind, TrackingProjectSource } from './shared-enums.js';
|
||||
// ---- ui_click ------------------------------------------------------------
|
||||
//
|
||||
@@ -196,6 +196,34 @@ export interface HomeChatComposerClickProps {
|
||||
plugin_type?: string;
|
||||
}
|
||||
|
||||
// Personalized first-run recommendation on Home (spec §7). One card, three
|
||||
// actions: `enter_studio` (accept → open Studio with the first request
|
||||
// pre-filled), `change` (换一个 → cycle to another starter in the same path),
|
||||
// `browse_all` (浏览全部类型 → abandon the recommendation for the generic
|
||||
// entry). `recommendation_id` is the stable starter id; `role` / `use_cases`
|
||||
// echo the survey answers that produced the recommendation.
|
||||
export interface HomeRecommendationClickProps {
|
||||
page_name: 'home';
|
||||
area: 'onboarding_recommendation';
|
||||
element: 'enter_studio' | 'change' | 'browse_all';
|
||||
product_type: TrackingOnboardingProductType;
|
||||
recommendation_id: string;
|
||||
role?: TrackingOnboardingRole;
|
||||
use_cases?: TrackingOnboardingUseCase[];
|
||||
}
|
||||
|
||||
// One-time first-generation hint in Studio (spec §8.3). A single lightweight
|
||||
// note shown when a new user's first previewable artifact appears, pointing
|
||||
// them at view / edit / export. `open_artifact` = user acted on it; `dismiss` =
|
||||
// user closed it. `hint_type` is fixed today but kept as a field so a future
|
||||
// first-run hint can reuse the shape.
|
||||
export interface StudioOnboardingHintClickProps {
|
||||
page_name: 'chat_panel';
|
||||
area: 'onboarding_first_artifact_hint';
|
||||
element: 'open_artifact' | 'dismiss';
|
||||
hint_type: 'view_artifact';
|
||||
}
|
||||
|
||||
export interface UpdateIndicatorClickProps {
|
||||
page_name: 'home';
|
||||
area: 'update_indicator' | 'update_prompt';
|
||||
@@ -1451,6 +1479,8 @@ export type UiClickProps =
|
||||
| ExecutionSettingsPopoverClickProps
|
||||
| SettingsPopoverClickProps
|
||||
| HomeChatComposerClickProps
|
||||
| HomeRecommendationClickProps
|
||||
| StudioOnboardingHintClickProps
|
||||
| UpdateIndicatorClickProps
|
||||
| NewProjectModalTabClickProps
|
||||
| NewProjectModalElementClickProps
|
||||
|
||||
Reference in New Issue
Block a user