Skip to content

Commit aad5c8c

Browse files
open-design-release-bot[bot]open-design-crew[bot]elifive555555Eliclaude
authored
feat(web): ask the experience survey after a delivered artifact, and add an "other" answer (#7117) (#7127)
* feat(web): add an "other" answer with a free-text field to the survey The eight choices cover what users already tell us most often, but they cannot cover what we have not heard yet — and the people whose problem is not on the list are exactly the ones worth hearing from. Picking "Something else" opens a text field instead of finishing the survey. The cost is paid only by the people who need it: everyone whose answer is on the list still finishes in one tap. This is the open-ended question we cut earlier, put back behind a door rather than in everyone way. Wire shape follows PostHog open-choice convention — the response recorded is the text they typed. Picking "Something else" and typing nothing still reports the choice itself, because "none of these fit" is an answer and dropping it would turn those people into non-responders. Skip disappears once the choice is made; it would only have been a second way to submit. * style(web): use a neutral focus border on the survey text field The green ring competed with the brand fill on the selected chip directly above it — two green things one under the other, neither reading as primary. A neutral border marks focus without joining that fight, and the inset fill still separates the field from the card. * fix(web): keep the survey submit button on the footer right edge Submit replaces Skip in the footer, but only Skip carried the margin-left:auto that pushes it right — so the moment the text field opened, the primary action collapsed back against the step counter and jumped from the right edge to the left. Measured from the card edge, both now sit at the same 17px gap. * feat(web): ask the experience survey after a delivered artifact, not an export The post-export survey could only reach people who exported. Over 30 days ~13k users exported while ~37k produced an artifact, so roughly two thirds of the people who got real work out of the product were never asked — and the ones who tried it, got something, and left without exporting are the ones worth hearing from most. The card is now armed by a delivered design run (resolveDesignDeliveryOutcome === 'delivered'), from the second such delivery on. The first delivery measures a first impression rather than an experience, and lands at the one moment the user most wants to look at what they just got; waiting for the second costs almost no reach because deliveries repeat (~13 per user per 30 days). Because a delivery is usually mid-session rather than end-of-session, the 3s delay now bails out if the user starts writing their next prompt. 'beforeinput' rather than 'keydown' so IME composition counts as typing. A dropped chance is taken by the next delivery, so the card lands after the turn the user actually stopped on. Export no longer arms anything; the three FileViewer call sites are gone. Every survey event carries trigger: 'post_generation' so the two regimes stay distinguishable in the events table. * feat(web): arm the experience survey on the first delivered artifact Drops the two-delivery threshold to one. Waiting for a second run bought a more considered opinion at the cost of everyone who produces one artifact and leaves — the users we understand least, and the ones this survey was widened to reach in the first place. Asking on the first delivery is affordable because the card is not an interruption: the component still waits out the delay and still drops the chance entirely if the user starts typing, so an early ask that lands badly costs a dropped chance rather than a derailed session. The delivery counter stays. It is the seam the policy is made of, and it is what makes an unwritable store read as "not yet qualified" — a store that cannot write cannot record a dismissal either, so without it the card would come back after every run with no way to stop it. * fix(web): retire the experience survey when it is shown, not when it is answered One ask per user was the intent, but `retireSurvey()` only ran from `finish` and `close`. Ignoring the card — the most common way to decline a prompt — left the flag unset, so the card returned after the user's next artifact, and the one after that. `exposedRef` did not cover this: it is a ref, so it resets on the next page load. Retiring on exposure makes the rule what it claims to be: a user sees this card at most once, whatever they do with it. A chance dropped by the typing bail-out is still retried, because a card the user never saw did not spend their one ask. * refactor(web): drop the typing bail-out from the experience survey The card no longer cancels itself when the user starts typing during the three-second delay. With one ask per user, a dropped chance is the whole ask for anyone whose habit is to type straight into the next prompt — and that habit belongs to the engaged users this survey most wants to hear from. Removes the `beforeinput` listener and the IME reasoning that went with it. The delay stays: it keeps the card from animating in on top of the artifact, which was always its other job. * fix(web): give the survey's free-text field an accessible name Review finding from #7074, folded in with that branch. The textarea is auto-focused the instant "Something else" is picked, so its accessible name is the only thing telling a screen-reader user what the field is for — and it had none. A placeholder is not a label and disappears on the first keystroke. Reuses the placeholder string rather than adding a twentieth translation of the same sentence, so the visible and announced names cannot drift. * chore: ensure PR CI --------- (cherry picked from commit 713caf0) Co-authored-by: open-design-crew[bot] <299007234+open-design-crew[bot]@users.noreply.github.com> Co-authored-by: elifive555555 <296440099+elifive555555@users.noreply.github.com> Co-authored-by: Eli <qiongyu1999@gmail.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: open-design-crew[bot] <4197780+open-design-crew[bot]@users.noreply.github.com>
1 parent cbe22ad commit aad5c8c

30 files changed

Lines changed: 578 additions & 43 deletions

apps/web/src/analytics/events.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -193,8 +193,10 @@ type Track = (
193193
import {
194194
EXPERIENCE_SURVEY_ID,
195195
EXPERIENCE_SURVEY_IMPROVEMENT_CHOICES,
196+
EXPERIENCE_SURVEY_IMPROVEMENT_OTHER,
196197
EXPERIENCE_SURVEY_QUESTION_IDS,
197198
EXPERIENCE_SURVEY_QUESTION_TEXT,
199+
EXPERIENCE_SURVEY_TRIGGER,
198200
} from './experience-survey-contract';
199201

200202
function send<T extends object>(
@@ -1456,11 +1458,17 @@ export function trackWhatsNewPopupClick(
14561458
// PostHog events rather than the v2 schema's own.
14571459

14581460
export function trackExperienceSurveyShown(track: Track): void {
1459-
send(track, 'survey shown', { $survey_id: EXPERIENCE_SURVEY_ID });
1461+
send(track, 'survey shown', {
1462+
$survey_id: EXPERIENCE_SURVEY_ID,
1463+
trigger: EXPERIENCE_SURVEY_TRIGGER,
1464+
});
14601465
}
14611466

14621467
export function trackExperienceSurveyDismissed(track: Track): void {
1463-
send(track, 'survey dismissed', { $survey_id: EXPERIENCE_SURVEY_ID });
1468+
send(track, 'survey dismissed', {
1469+
$survey_id: EXPERIENCE_SURVEY_ID,
1470+
trigger: EXPERIENCE_SURVEY_TRIGGER,
1471+
});
14641472
}
14651473

14661474
/**
@@ -1470,7 +1478,7 @@ export function trackExperienceSurveyDismissed(track: Track): void {
14701478
*/
14711479
export function trackExperienceSurveySent(
14721480
track: Track,
1473-
answers: { recommendation: number; improvement?: number },
1481+
answers: { recommendation: number; improvement?: number; improvementOther?: string },
14741482
): void {
14751483
const ids = EXPERIENCE_SURVEY_QUESTION_IDS;
14761484
const text = EXPERIENCE_SURVEY_QUESTION_TEXT;
@@ -1483,13 +1491,23 @@ export function trackExperienceSurveySent(
14831491
};
14841492

14851493
add(ids.recommendation, text.recommendation, answers.recommendation);
1486-
if (typeof answers.improvement === 'number') {
1494+
if (typeof answers.improvementOther === 'string') {
1495+
// PostHog's open-choice convention: the response is what they typed. An
1496+
// empty field still reports the choice itself, so "none of these fit"
1497+
// survives instead of looking like the question was skipped.
1498+
add(
1499+
ids.improvement,
1500+
text.improvement,
1501+
answers.improvementOther.trim() || EXPERIENCE_SURVEY_IMPROVEMENT_OTHER,
1502+
);
1503+
} else if (typeof answers.improvement === 'number') {
14871504
const choice = EXPERIENCE_SURVEY_IMPROVEMENT_CHOICES[answers.improvement];
14881505
if (choice) add(ids.improvement, text.improvement, choice);
14891506
}
14901507

14911508
send(track, 'survey sent', {
14921509
$survey_id: EXPERIENCE_SURVEY_ID,
1510+
trigger: EXPERIENCE_SURVEY_TRIGGER,
14931511
$survey_questions: answered,
14941512
...responses,
14951513
});

apps/web/src/analytics/experience-survey-contract.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,15 @@
1717

1818
export const EXPERIENCE_SURVEY_ID = '01a00fd1-ed7e-0000-d38e-63bce21fb816';
1919

20+
/**
21+
* What armed the card. PostHog's survey analytics ignores extra properties, so
22+
* this rides along purely for our own segmentation. It exists because the
23+
* trigger moved from a successful export to a delivered artifact: without it,
24+
* responses from the two regimes are indistinguishable in the events table and
25+
* any before/after read of the score is guesswork.
26+
*/
27+
export const EXPERIENCE_SURVEY_TRIGGER = 'post_generation';
28+
2029
export const EXPERIENCE_SURVEY_QUESTION_IDS = {
2130
recommendation: '146fefc0-9c11-4003-9869-1fd81be1650f',
2231
improvement: 'e487f41a-8111-4a87-8795-1358c9a11b55',
@@ -47,3 +56,13 @@ export const EXPERIENCE_SURVEY_IMPROVEMENT_CHOICES = [
4756
"Doesn't look good",
4857
'Breaks other things',
4958
] as const;
59+
60+
/**
61+
* The escape hatch at the end of the choices. PostHog models this as the
62+
* question's open choice: when a respondent picks it, the response recorded is
63+
* the text they typed, not the word "Other". This constant is only what we
64+
* report when they pick it and type nothing — "none of these fit" is itself an
65+
* answer worth keeping, and dropping it would silently turn those people into
66+
* non-responders.
67+
*/
68+
export const EXPERIENCE_SURVEY_IMPROVEMENT_OTHER = 'Other';

apps/web/src/components/ExperienceSurvey.module.css

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -262,13 +262,13 @@
262262
color: var(--text-faint, #bdbdbd);
263263
}
264264

265-
/* Keep the inset fill on focus. Switching it to --bg made the field the same
266-
white as the card behind it, so the input dissolved into the panel and only
267-
a floating green outline was left. The ring carries the focus cue instead. */
265+
/* Keep the inset fill on focus — switching it to --bg made the field the same
266+
white as the card behind it and the input dissolved into the panel. The
267+
border alone carries the focus cue: neutral, because green here competed
268+
with the brand fill on the selected chip right above it. */
268269
.textarea:focus {
269270
outline: none;
270-
border-color: var(--brand-weak, #d0ffb5);
271-
box-shadow: 0 0 0 3px var(--brand-soft, #ebffde);
271+
border-color: var(--text-strong, #202020);
272272
}
273273

274274
.foot {
@@ -312,6 +312,10 @@
312312
/* Sizing only — colour, hover and focus come from the shared Button primitive
313313
so this stays in step with every other primary action in the app. */
314314
.submit {
315+
/* Same push as .skip. Submit replaces Skip in the footer, so without this it
316+
collapsed back against the counter and the primary action jumped from the
317+
right edge to the left the moment the field opened. */
318+
margin-left: auto;
315319
font-size: var(--font-size-12, 12px);
316320
padding: 6px 14px;
317321
border-radius: 999px;

apps/web/src/components/ExperienceSurvey.tsx

Lines changed: 93 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
// Experience survey (NPS). Armed by a successful export, rendered globally
2-
// from App.tsx so it survives the project → home navigation, and retired
3-
// permanently the moment the user answers or closes it.
1+
// Experience survey (NPS). Armed by a delivered artifact — any design run that
2+
// actually produces one — rendered globally from App.tsx so it survives the
3+
// project → home navigation, and retired permanently the moment it is shown.
44
//
55
// Two questions. The score is the metric and costs one tap; the follow-up asks
66
// what to fix first and can be skipped. Anything longer was cut deliberately —
@@ -9,12 +9,13 @@ import { useCallback, useEffect, useRef, useState } from 'react';
99
import { createPortal } from 'react-dom';
1010
import { AnimatePresence, motion } from 'motion/react';
1111
import type { Variants } from 'motion/react';
12+
import { Button } from '@open-design/components';
1213
import { useT } from '../i18n';
1314
import styles from './ExperienceSurvey.module.css';
1415
import {
1516
SURVEY_DELAY_MS,
1617
isSurveyRetired,
17-
onExportSucceeded,
18+
onArtifactDelivered,
1819
retireSurvey,
1920
} from './experience-survey-trigger';
2021

@@ -24,6 +25,11 @@ export interface ExperienceSurveyAnswers {
2425
recommendation: number;
2526
/** Index into the improvement options, in the order rendered. */
2627
improvement?: number;
28+
/**
29+
* Set when the respondent picked "Other". Empty string means they picked it
30+
* and typed nothing, which still says none of the listed choices fit.
31+
*/
32+
improvementOther?: string;
2733
}
2834

2935
interface Props {
@@ -72,6 +78,15 @@ const IMPROVEMENT_KEYS = [
7278
'experienceSurvey.improvement.regression',
7379
] as const;
7480

81+
/**
82+
* "Other" sits after the eight choices and behaves differently: instead of
83+
* finishing the survey it opens a text field. The eight are what users already
84+
* tell us most often, so most people never reach for this — but the ones who
85+
* do are the ones whose problem we have not named yet, and they are worth the
86+
* extra tap.
87+
*/
88+
const OTHER_INDEX = IMPROVEMENT_KEYS.length;
89+
7590
type Step = 'recommendation' | 'improvement' | 'thanks';
7691

7792
const STEP_ORDER: Step[] = ['recommendation', 'improvement'];
@@ -99,6 +114,7 @@ export function ExperienceSurvey({
99114
const [visible, setVisible] = useState(false);
100115
const [step, setStep] = useState<Step>('recommendation');
101116
const [picked, setPicked] = useState<number | null>(null);
117+
const [otherText, setOtherText] = useState('');
102118
const answersRef = useRef<Partial<ExperienceSurveyAnswers>>({});
103119
const bodyRef = useRef<HTMLDivElement | null>(null);
104120
// Tallest step seen so far. The card is pinned to the bottom-right corner,
@@ -139,18 +155,28 @@ export function ExperienceSurvey({
139155
};
140156
}, []);
141157

142-
// Arm on export. The delay gives the user a beat to see their export land
143-
// before anything else asks for attention.
158+
// Arm on a delivered artifact. The delay gives the user a beat to look at
159+
// what the run just produced before anything else asks for attention. Once
160+
// armed, the card is shown — nothing the user does during the delay calls it
161+
// off, because the survey is asked once per user and dropping a chance is
162+
// how a user ends up never being asked at all.
144163
useEffect(() => {
145164
if (!metricsConsent) return;
146165
let armTimer: number | null = null;
147166
let modalWatcher: MutationObserver | null = null;
148167

168+
const clearArm = () => {
169+
if (armTimer !== null) {
170+
window.clearTimeout(armTimer);
171+
armTimer = null;
172+
}
173+
};
174+
149175
const reveal = () => {
150176
if (isSurveyRetired()) return;
151177
if (isModalOpen()) {
152178
// Stay armed and wait the dialog out rather than dropping the chance:
153-
// the export already happened, and this is the only one we get.
179+
// the artifact is already delivered and the dialog will close.
154180
modalWatcher?.disconnect();
155181
modalWatcher = new MutationObserver(() => {
156182
if (isModalOpen()) return;
@@ -167,24 +193,36 @@ export function ExperienceSurvey({
167193
setVisible(true);
168194
};
169195

170-
const unsubscribe = onExportSucceeded(() => {
196+
const unsubscribe = onArtifactDelivered(() => {
171197
if (isSurveyRetired() || exposedRef.current || armTimer !== null) return;
198+
// The delay is the only thing between the artifact landing and the card
199+
// arriving. It is short on purpose: long enough that the two do not
200+
// animate on top of each other, short enough that the card still reads
201+
// as being about the run that just finished.
172202
armTimer = window.setTimeout(() => {
173-
armTimer = null;
203+
clearArm();
174204
reveal();
175205
}, SURVEY_DELAY_MS);
176206
});
177207

178208
return () => {
179209
unsubscribe();
180210
modalWatcher?.disconnect();
181-
if (armTimer !== null) window.clearTimeout(armTimer);
211+
clearArm();
182212
};
183213
}, [metricsConsent]);
184214

215+
// Being shown is what spends the one ask this user gets. Retiring here
216+
// rather than in `finish`/`close` is the difference between "asked once" and
217+
// "asked until you engage": a user who reads the card and neither answers
218+
// nor closes it — the most common way to respond to an unwanted prompt — was
219+
// otherwise asked again after their next artifact, and again after the one
220+
// after that. `exposedRef` alone could not prevent that; it is a ref, so it
221+
// resets on the next page load.
185222
useEffect(() => {
186223
if (!visible || exposedRef.current) return;
187224
exposedRef.current = true;
225+
retireSurvey();
188226
onExposure?.();
189227
}, [visible, onExposure]);
190228

@@ -216,6 +254,17 @@ export function ExperienceSurvey({
216254
if (step !== 'thanks') onDismiss?.(answersRef.current);
217255
}, [onDismiss, step]);
218256

257+
/** Opens the free-text field rather than finishing the survey. */
258+
const pickOther = useCallback(() => {
259+
if (picked !== null) return;
260+
setPicked(OTHER_INDEX);
261+
}, [picked]);
262+
263+
const submitOther = useCallback(() => {
264+
answersRef.current.improvementOther = otherText.trim();
265+
finish();
266+
}, [finish, otherText]);
267+
219268
/** Lights the tapped choice, then advances — the tap needs a receipt. */
220269
const pick = useCallback(
221270
(value: number, apply: (value: number) => void, next: Step | 'finish') => {
@@ -303,12 +352,43 @@ export function ExperienceSurvey({
303352
{t(key)}
304353
</button>
305354
))}
355+
<button
356+
type="button"
357+
className={`${styles.option} ${picked === OTHER_INDEX ? styles.picked : ''}`}
358+
onClick={pickOther}
359+
>
360+
{t('experienceSurvey.improvement.other')}
361+
</button>
306362
</div>
363+
{picked === OTHER_INDEX ? (
364+
<textarea
365+
className={styles.textarea}
366+
value={otherText}
367+
onChange={(event) => setOtherText(event.target.value)}
368+
placeholder={t('experienceSurvey.otherPlaceholder')}
369+
// The field is auto-focused, so its name is announced immediately
370+
// and is the only thing telling a screen-reader user what to type.
371+
// A placeholder cannot carry that: it is not a label, and it
372+
// disappears on the first keystroke. Reusing the same string keeps
373+
// the two in step without a 20-locale key for the same sentence.
374+
aria-label={t('experienceSurvey.otherPlaceholder')}
375+
autoFocus
376+
/>
377+
) : null}
307378
<div className={styles.foot}>
308379
{counter}
309-
<button type="button" className={styles.skip} onClick={finish}>
310-
{t('experienceSurvey.skip')}
311-
</button>
380+
{/* Once "Other" is picked the question is already answered, so Skip
381+
would only be a second way to submit. Submit replaces it, and an
382+
empty field still reports "none of these fit". */}
383+
{picked === OTHER_INDEX ? (
384+
<Button variant="primary" className={styles.submit} onClick={submitOther}>
385+
{t('experienceSurvey.submit')}
386+
</Button>
387+
) : (
388+
<button type="button" className={styles.skip} onClick={finish}>
389+
{t('experienceSurvey.skip')}
390+
</button>
391+
)}
312392
</div>
313393
</>
314394
);

apps/web/src/components/FileViewer.tsx

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,6 @@ import {
4646
type PreviewTransportDocumentState,
4747
type PreviewTransportRecoverySignal,
4848
} from '../observability/iframe-error';
49-
import { notifyExportSucceeded } from './experience-survey-trigger';
5049
import {
5150
trackArtifactExportResult,
5251
trackArtifactDeployResult,
@@ -7527,7 +7526,6 @@ function HtmlViewer({
75277526
const originPromise = resolveArtifactExportOrigin(context)
75287527
.catch(() => unknownExportOrigin());
75297528
const finish = async (result: 'success' | 'failed' | 'cancelled', errorCode?: string) => {
7530-
if (result === 'success') notifyExportSucceeded();
75317529
const originProps = await originPromise;
75327530
trackArtifactExportResult(
75337531
analytics.track,
@@ -12939,7 +12937,6 @@ function HtmlViewer({
1293912937
const started = templateExportStartedRef.current || performance.now();
1294012938
const originPromise = templateExportOriginPromiseRef.current
1294112939
?? resolveArtifactExportOrigin().catch(() => unknownExportOrigin());
12942-
if (result === 'success') notifyExportSucceeded();
1294312940
void originPromise.then((originProps) => {
1294412941
trackArtifactExportResult(
1294512942
analytics.track,
@@ -14121,7 +14118,6 @@ function HtmlViewer({
1412114118
const started = imageExportStartedRef.current || performance.now();
1412214119
const originPromise = imageExportOriginPromiseRef.current
1412314120
?? resolveArtifactExportOrigin().catch(() => unknownExportOrigin());
14124-
if (result === 'success') notifyExportSucceeded();
1412514121
void originPromise.then((originProps) => {
1412614122
trackArtifactExportResult(
1412714123
analytics.track,

apps/web/src/components/ProjectView.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ import {
137137
resolveDesignDeliveryOutcome,
138138
type DesignDeliveryOutcome,
139139
} from '../runtime/design-delivery';
140+
import { notifyArtifactDelivered } from './experience-survey-trigger';
140141
import { RESUME_CONTINUE_PROMPT } from '../runtime/resume';
141142
import {
142143
amrBalanceGateScopeForWorkspaceContext,
@@ -7522,6 +7523,13 @@ export function ProjectView({
75227523
artifactPersistenceError,
75237524
);
75247525
latestAssistantMsg = finalized;
7526+
// Only the live completion path arms the experience survey. The
7527+
// reattach and artifact-recovery paths below also settle on
7528+
// `delivered`, but they do so while replaying a run that
7529+
// finished before this page load — "how was that?" about work
7530+
// the user cannot remember finishing is a worse question than
7531+
// one not asked.
7532+
if (deliveryOutcome === 'delivered') notifyArtifactDelivered();
75257533
setMessages((curr) => {
75267534
const updated = curr.map((m) =>
75277535
m.id === assistantId

0 commit comments

Comments
 (0)