mirror of
https://github.com/nexu-io/open-design.git
synced 2026-09-20 06:15:06 +08:00
4f5bdeed1d
* 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>
91 lines
3.5 KiB
TypeScript
91 lines
3.5 KiB
TypeScript
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();
|
|
});
|
|
});
|