mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: add landing testimonial wall, unblock the onboarding survey (#639)
Testimonial wall: new landing section between Feature Highlights and Pricing. Two rows drifting in opposite directions, CSS-only to keep the zero-runtime rule. 22 quotes, all verbatim and traceable to a public URL or a feedback_submitted event. In-app quotes ship unattributed because the feedback dialog only ever promised "You can contact me about this feedback". Marquee traps documented in the CSS: a track gap also sits between the last original and the first clone, so the -50% translate jumped half a gap per loop; and under dir="rtl" the flex track drifted itself off-screen while "@amn-96" bidi-reordered to "amn-96@". Landing stats: DOCKER_FALLBACK read 104,000 against a real 233,057, but the stale constant was the symptom. Both fetchers swallowed failures in a bare catch, so a degraded build never announced itself. That warning then exposed the real bug: getStarCount runs from Navbar and TrustSignals on all 798 pages, firing ~800 unauthenticated GitHub calls per build and 403ing partway through, so early pages carried the live count and later pages the fallback. Both fetchers now memoize the promise. Onboarding survey: the shipped gate has no activity condition, so it fires on first admin login; 1,105 of 1,287 surveyed instances never processed a file. The opaque fixed inset-0 aria-modal with a focus trap and no Escape becomes a corner card at 12% of the screen, Escape closes, and the optional questions stay collapsed until the one required answer. Its title was an h1, which RouteAnnouncer focuses and announces on every route change, so navigating anywhere announced the survey instead of the page. Now an h2.
This commit is contained in:
@@ -94,10 +94,12 @@ describe("UsageSurveyOverlay", () => {
|
||||
renderOverlay();
|
||||
|
||||
expect(await screen.findByText("How are you using SnapOtter?")).toBeDefined();
|
||||
expect(screen.getByText("What were you using before?")).toBeDefined();
|
||||
expect(screen.getByText("Why self-host it?")).toBeDefined();
|
||||
expect(screen.getByRole("radio", { name: /Just me/ })).toBeDefined();
|
||||
expect(screen.getByRole("button", { name: "Continue" })).toBeDisabled();
|
||||
// Opening ask is one question. The optional three stay collapsed until the
|
||||
// required one is answered, so the first impression is not a wall of 20.
|
||||
expect(screen.queryByText("What were you using before?")).toBeNull();
|
||||
expect(screen.queryByText(/Why self-host it/)).toBeNull();
|
||||
await waitFor(() => expect(trackFeedbackPromptShown).toHaveBeenCalledWith("onboarding"));
|
||||
});
|
||||
|
||||
@@ -115,7 +117,7 @@ describe("UsageSurveyOverlay", () => {
|
||||
expect(submitFeedback).toHaveBeenCalledWith({
|
||||
source: "onboarding",
|
||||
surveyId: "onboarding-usage-v1",
|
||||
promptVariant: "onboarding-overlay-v1",
|
||||
promptVariant: "onboarding-card-v1",
|
||||
usageType: "team_internal",
|
||||
});
|
||||
});
|
||||
@@ -132,6 +134,8 @@ describe("UsageSurveyOverlay", () => {
|
||||
await screen.findByText("How are you using SnapOtter?");
|
||||
|
||||
fireEvent.click(screen.getByRole("radio", { name: /Just me/ }));
|
||||
// Answering the required question reveals the optional follow-ups.
|
||||
expect(await screen.findByText("What were you using before?")).toBeDefined();
|
||||
fireEvent.click(screen.getByRole("radio", { name: /Command line/ }));
|
||||
fireEvent.click(screen.getByRole("radio", { name: /Privacy and data control/ }));
|
||||
fireEvent.change(screen.getByLabelText(/How did you hear about us/), {
|
||||
@@ -143,7 +147,7 @@ describe("UsageSurveyOverlay", () => {
|
||||
expect(submitFeedback).toHaveBeenCalledWith({
|
||||
source: "onboarding",
|
||||
surveyId: "onboarding-usage-v1",
|
||||
promptVariant: "onboarding-overlay-v1",
|
||||
promptVariant: "onboarding-card-v1",
|
||||
usageType: "personal",
|
||||
priorTool: "command_line",
|
||||
selfHostMotivation: "privacy_control",
|
||||
@@ -239,6 +243,86 @@ describe("UsageSurveyOverlay", () => {
|
||||
expect(apiPut).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// The prompt used to be an opaque full-screen takeover with aria-modal, a
|
||||
// focus trap, and no Escape handler, so the only exits were answering the
|
||||
// required question or finding a text-xs grey link. These pin the friction fix.
|
||||
describe("does not block the app", () => {
|
||||
it("is not a modal and does not cover the screen", async () => {
|
||||
useAuth.mockReturnValue({ role: "admin", mustChangePassword: false });
|
||||
apiGet.mockResolvedValue({ settings: PROCESSED });
|
||||
|
||||
renderOverlay();
|
||||
await screen.findByText("How are you using SnapOtter?");
|
||||
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(dialog).not.toHaveAttribute("aria-modal", "true");
|
||||
expect(dialog.className).not.toContain("inset-0");
|
||||
});
|
||||
|
||||
it("closes on Escape and records the dismissal", async () => {
|
||||
useAuth.mockReturnValue({ role: "admin", mustChangePassword: false });
|
||||
apiGet.mockResolvedValue({ settings: PROCESSED });
|
||||
|
||||
renderOverlay();
|
||||
await screen.findByText("How are you using SnapOtter?");
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiPut).toHaveBeenCalledWith("/v1/settings", {
|
||||
"onboarding.usageSurvey.dismissedAt": expect.any(String),
|
||||
});
|
||||
});
|
||||
expect(trackFeedbackPromptDismissed).toHaveBeenCalledWith("onboarding", "close");
|
||||
expect(submitFeedback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("offers a visible close control, not just a faint text link", async () => {
|
||||
useAuth.mockReturnValue({ role: "admin", mustChangePassword: false });
|
||||
apiGet.mockResolvedValue({ settings: PROCESSED });
|
||||
|
||||
renderOverlay();
|
||||
await screen.findByText("How are you using SnapOtter?");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close feedback dialog" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiPut).toHaveBeenCalledWith("/v1/settings", {
|
||||
"onboarding.usageSurvey.dismissedAt": expect.any(String),
|
||||
});
|
||||
});
|
||||
expect(trackFeedbackPromptDismissed).toHaveBeenCalledWith("onboarding", "close");
|
||||
});
|
||||
|
||||
// RouteAnnouncer focuses and announces document.querySelector("h1") on every
|
||||
// route change. While this prompt's title was an h1 it stole focus the moment
|
||||
// the card appeared and made every later navigation announce the survey
|
||||
// instead of the page the user opened.
|
||||
it("does not claim the page's h1", async () => {
|
||||
useAuth.mockReturnValue({ role: "admin", mustChangePassword: false });
|
||||
apiGet.mockResolvedValue({ settings: PROCESSED });
|
||||
|
||||
renderOverlay();
|
||||
const title = await screen.findByText("How are you using SnapOtter?");
|
||||
|
||||
expect(title.tagName).not.toBe("H1");
|
||||
expect(screen.queryByRole("heading", { level: 1 })).toBeNull();
|
||||
// Still the dialog's accessible name.
|
||||
expect(screen.getByRole("dialog")).toHaveAttribute("aria-labelledby", title.id);
|
||||
});
|
||||
|
||||
it("can be sent after a single click, without touching the optional questions", async () => {
|
||||
useAuth.mockReturnValue({ role: "admin", mustChangePassword: false });
|
||||
apiGet.mockResolvedValue({ settings: PROCESSED });
|
||||
|
||||
renderOverlay();
|
||||
await screen.findByText("How are you using SnapOtter?");
|
||||
|
||||
fireEvent.click(screen.getByRole("radio", { name: /Just me/ }));
|
||||
expect(screen.getByRole("button", { name: "Continue" })).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders nothing when the admin must still change their password", async () => {
|
||||
useAuth.mockReturnValue({ role: "admin", mustChangePassword: true });
|
||||
apiGet.mockResolvedValue({ settings: PROCESSED });
|
||||
|
||||
Reference in New Issue
Block a user