mirror of
https://github.com/nexu-io/open-design.git
synced 2026-09-20 06:15:06 +08:00
713caf0110
* 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. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * 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. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * 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. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * 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. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore: ensure PR CI --------- 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>
137 lines
4.4 KiB
TypeScript
137 lines
4.4 KiB
TypeScript
// The survey's whole value is that it is asked once, after real work. Both
|
|
// halves are enforced here rather than in the component: the component can
|
|
// only render what the trigger arms, so a leak past `retired` or a count that
|
|
// silently stops advancing is invisible until the card either follows a user
|
|
// around forever or never shows up at all.
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import {
|
|
SURVEY_MIN_DELIVERIES,
|
|
deliveredCount,
|
|
isSurveyRetired,
|
|
notifyArtifactDelivered,
|
|
onArtifactDelivered,
|
|
retireSurvey,
|
|
} from '../src/components/experience-survey-trigger';
|
|
|
|
// Minimal in-memory localStorage stub. Vitest runs in a node env, so we
|
|
// provide just enough of the Storage interface for the module'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; },
|
|
};
|
|
}
|
|
|
|
function useStorage(storage: unknown) {
|
|
(globalThis as unknown as { window: unknown }).window = { localStorage: storage };
|
|
}
|
|
|
|
/** Subscribes a spy for the duration of one test. */
|
|
function listen() {
|
|
const listener = vi.fn();
|
|
const unsubscribe = onArtifactDelivered(listener);
|
|
unsubscribers.push(unsubscribe);
|
|
return listener;
|
|
}
|
|
|
|
let unsubscribers: Array<() => void> = [];
|
|
|
|
beforeEach(() => {
|
|
useStorage(createStorageStub());
|
|
});
|
|
|
|
afterEach(() => {
|
|
for (const unsubscribe of unsubscribers) unsubscribe();
|
|
unsubscribers = [];
|
|
delete (globalThis as unknown as { window?: unknown }).window;
|
|
});
|
|
|
|
describe('experience survey delivery trigger', () => {
|
|
it('arms on the very first delivery', () => {
|
|
const listener = listen();
|
|
|
|
notifyArtifactDelivered();
|
|
|
|
expect(listener).toHaveBeenCalledTimes(1);
|
|
expect(deliveredCount()).toBe(SURVEY_MIN_DELIVERIES);
|
|
});
|
|
|
|
it('keeps arming on later deliveries so a lost arm is not the last chance', () => {
|
|
const listener = listen();
|
|
|
|
notifyArtifactDelivered();
|
|
notifyArtifactDelivered();
|
|
notifyArtifactDelivered();
|
|
|
|
// Arming is not showing: an arm can be lost to a navigation mid-delay, so
|
|
// every delivery has to offer the component another one.
|
|
expect(listener).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it('counts deliveries across reloads', () => {
|
|
const storage = createStorageStub();
|
|
useStorage(storage);
|
|
notifyArtifactDelivered();
|
|
|
|
// Same store, new page load. The count is what makes an unwritable store
|
|
// read as "not yet qualified", so it has to survive the reload even though
|
|
// today's threshold is met on the first delivery.
|
|
useStorage(storage);
|
|
notifyArtifactDelivered();
|
|
|
|
expect(deliveredCount()).toBe(2);
|
|
});
|
|
|
|
it('never arms again once retired, and stops counting', () => {
|
|
const listener = listen();
|
|
retireSurvey();
|
|
|
|
notifyArtifactDelivered();
|
|
notifyArtifactDelivered();
|
|
notifyArtifactDelivered();
|
|
|
|
expect(listener).not.toHaveBeenCalled();
|
|
expect(isSurveyRetired()).toBe(true);
|
|
expect(deliveredCount()).toBe(0);
|
|
});
|
|
|
|
it('never arms when the store is unwritable', () => {
|
|
// Fail-closed, and the reason the count still exists: a store that cannot
|
|
// write cannot record a dismissal either, so a card shown here would come
|
|
// back after every run with no way for the user to stop it.
|
|
useStorage({
|
|
getItem: () => null,
|
|
setItem: () => { throw new Error('QuotaExceededError'); },
|
|
removeItem: () => {},
|
|
clear: () => {},
|
|
key: () => null,
|
|
length: 0,
|
|
});
|
|
const listener = listen();
|
|
|
|
for (let i = 0; i < 5; i += 1) notifyArtifactDelivered();
|
|
|
|
expect(listener).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('restarts the count from zero when the stored value is corrupted', () => {
|
|
const storage = createStorageStub();
|
|
storage.setItem('open-design:experience-survey:v1:deliveries', 'not-a-number');
|
|
useStorage(storage);
|
|
const listener = listen();
|
|
|
|
notifyArtifactDelivered();
|
|
|
|
// Garbage in the store must not poison the count into NaN, which would
|
|
// compare false against the threshold forever.
|
|
expect(listener).toHaveBeenCalledTimes(1);
|
|
expect(deliveredCount()).toBe(1);
|
|
});
|
|
});
|