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:
SnapOtter
2026-07-25 18:45:38 +08:00
committed by GitHub
parent 025851beef
commit 5cc0a850c6
10 changed files with 767 additions and 119 deletions
@@ -0,0 +1,88 @@
---
// biome-ignore-all lint/correctness/noUnusedImports: Astro template consumes component imports.
// biome-ignore-all lint/correctness/noUnusedVariables: Astro template consumes frontmatter values.
import { TESTIMONIAL_ROWS } from "@/data/testimonials";
import { t } from "@/i18n";
import SectionHeading from "./SectionHeading.astro";
interface Props {
locale?: string;
}
const { locale = "en" } = Astro.props;
// Each row is rendered twice so translating the track by -50% lands exactly on
// the start of the copy, which is what makes the loop seamless. The second pass
// is aria-hidden so screen readers and crawlers see each quote once.
const rows = TESTIMONIAL_ROWS.map((items, i) => ({
items,
reverse: i % 2 === 1,
// Duration scales with item count so both rows move at the same visual speed.
duration: `${items.length * 9}s`,
}));
---
<section class="overflow-hidden bg-primary-subtle px-6 py-20 md:py-28" id="testimonials">
<SectionHeading
title={t(locale, "home.testimonials.title")}
subtitle={t(locale, "home.testimonials.subtitle")}
/>
<!--
The strip is pinned to LTR in every locale. Quotes are user-submitted and
never translated, so they are always Latin script; under dir="rtl" the flex
track laid out from the right and drifted itself off-screen, and handles like
"@amn-96" bidi-reordered into "amn-96@". The heading above stays RTL.
-->
<div class="reveal marquee -mx-6 mt-4 flex flex-col gap-5" dir="ltr">
{
rows.map((row) => (
<div class="marquee-viewport">
<ul
class:list={["marquee-track", row.reverse && "marquee-track-reverse"]}
style={`--marquee-duration: ${row.duration}`}
>
{[false, true].map((isClone) =>
row.items.map((item) => (
<li class="marquee-item" aria-hidden={isClone ? "true" : undefined}>
<figure class="flex h-full flex-col gap-3 rounded-2xl border border-border bg-surface p-6 shadow-[0_1px_2px_rgba(26,24,20,0.04)]">
<div class="flex gap-0.5 text-primary" aria-hidden="true">
{Array.from({ length: 5 }).map(() => (
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path d="M10 1.5l2.47 5.16 5.68.78-4.12 3.95 1.02 5.61L10 14.35l-5.05 2.65 1.02-5.61L1.85 7.44l5.68-.78L10 1.5z" />
</svg>
))}
</div>
<blockquote
class="grow text-sm leading-relaxed text-foreground"
lang={item.lang}
>
{item.quote}
</blockquote>
<figcaption class="flex items-baseline justify-between gap-3 text-xs">
<span class="font-semibold text-foreground">{item.author}</span>
{item.url ? (
<a
href={item.url}
target="_blank"
rel="noopener noreferrer nofollow"
class="shrink-0 font-medium text-primary-ink underline-offset-2 hover:text-primary-ink-strong hover:underline"
tabindex={isClone ? -1 : undefined}
>
{item.context}
</a>
) : (
<span class="shrink-0 text-muted">{item.context}</span>
)}
</figcaption>
</figure>
</li>
))
)}
</ul>
</div>
))
}
</div>
</section>
+180
View File
@@ -0,0 +1,180 @@
// Real user feedback, quoted verbatim.
//
// RULES FOR EDITING THIS FILE:
// 1. Never write a quote nobody actually said. Every entry traces to a public
// URL or a `feedback_submitted` PostHog event.
// 2. Keep the author's typos and phrasing. "Painless proces" is not a bug.
// 3. Square brackets mark the only words we changed; "..." marks a cut. Both
// stay visible to the reader.
// 4. `context: "Shared via in-app feedback"` quotes came through the feedback
// dialog, which only ever promised "You can contact me about this feedback."
// They are published unattributed for that reason. Do not attach names to
// them without asking the author first.
//
// Deliberately EXCLUDED, so nobody re-adds them later:
// - Four r/selfhosted comments that read as astroturf (two sit at negative
// score, one trails off mid-sentence). Polished, hollow, and not worth the
// credibility risk.
// - Anything from the launch thread, which is dominated by the Stirling naming
// dispute. Quoting it points readers straight at that argument.
export interface Testimonial {
/** Displayed text. Verbatim apart from [bracketed] edits and "..." cuts. */
quote: string;
/** Person's name or handle, or a neutral descriptor for anonymous in-app feedback. */
author: string;
/** Where it was said. Doubles as the link label when `url` is set. */
context: string;
/** Public source we can link back to. Absent for in-app feedback. */
url?: string;
/** BCP-47 tag when the quote is not in English, for correct screen-reader pronunciation. */
lang?: string;
}
export const TESTIMONIALS: Testimonial[] = [
{
quote:
"This self-hosted tool gave me control. My photos and the light editing I need to do are no longer tied to a subscription.",
author: "Dhruv Bhutani",
context: "XDA Developers",
url: "https://www.xda-developers.com/i-ditched-lightroom-subscription-for-a-self-hosted-tool/",
},
{
quote:
"Fantastic software! I managed to replace [my old converter] and even got more features with SnapOtter.",
author: "Self-hosted admin",
context: "Shared via in-app feedback",
},
{
quote:
"If you're already running a home server, SnapOtter is a no-brainer addition. The Docker command takes less than a minute...",
author: "Yadullah Abidi",
context: "MakeUseOf",
url: "https://www.makeuseof.com/stopped-using-cloud-image-editors-found-self-hosted-alternative/",
},
{
quote: "I have a 24/7 server that I run SnapOtter on (which is working perfectly!)",
author: "@amn-96",
context: "GitHub",
url: "https://github.com/snapotter-hq/SnapOtter/issues/189",
},
{
quote: "Holy shit this is great!",
author: "u/Big_Wave9732",
context: "r/selfhosted",
url: "https://www.reddit.com/comments/oinueb0",
},
{
quote: "Installed via UnRAID store. Painless proces, quick and easy. Kudos!",
author: "Unraid user",
context: "Shared via in-app feedback",
},
{
quote: "das perfekte Schweizer Taschenmesser für eure Dateien",
author: "Deployn",
context: "YouTube",
url: "https://www.youtube.com/watch?v=UonUAfkSoqM",
lang: "de",
},
{
quote: "Your software is incredible ... thanks for all hard work",
author: "@arturbacilla",
context: "GitHub",
url: "https://github.com/snapotter-hq/SnapOtter/issues/189#issuecomment-4771081169",
},
{
quote: "Thank you for your hard work. This is an excellent endeavor.",
author: "@Wbbdlr",
context: "GitHub",
url: "https://github.com/snapotter-hq/SnapOtter/issues/106#issuecomment-4354796398",
},
{
quote: "In any case, it's a great program! I really like it.",
author: "Homelab user",
context: "Shared via in-app feedback",
},
{
quote: "Looks like a very helpful app, especially removing EXIF easily from a browser UI.",
author: "u/xilex",
context: "r/selfhosted",
url: "https://www.reddit.com/comments/ojmrskw",
},
{
quote:
"first of all, thank you for creating Snapotter. I really like the concept and the workflow so far.",
author: "@JamDaBam",
context: "GitHub",
url: "https://github.com/snapotter-hq/SnapOtter/discussions/357",
},
{
quote: "Thank you for your help! PS It's a great program.",
author: "@mptpro",
context: "GitHub",
url: "https://github.com/snapotter-hq/SnapOtter/issues/214#issuecomment-4697181441",
},
{
quote: "Love the work you guys have put into this! Great job!",
author: "Docker user",
context: "Shared via in-app feedback",
},
{
quote: "SnapOtter has excellent Smart Crop (subject/face/trim) and Split Image tools",
author: "@MrCoala",
context: "GitHub",
url: "https://github.com/snapotter-hq/SnapOtter/discussions/609",
},
{
quote: "Thanks for creating this! Very helpful on linux systems especially",
author: "u/sidcode",
context: "r/selfhosted",
url: "https://www.reddit.com/comments/ojh9ym4",
},
{
quote: "Hi, awesome tool.",
author: "@luxmara",
context: "GitHub",
url: "https://github.com/snapotter-hq/SnapOtter/issues/16",
},
{
quote: "Super Unraid Template! Prima gemacht :)",
author: "Unraid user",
context: "Shared via in-app feedback",
lang: "de",
},
{
quote: "I can confirm this worked. Thank you for the quick reply and fix. The tool works great",
author: "u/joshrj45",
context: "r/homelab",
url: "https://www.reddit.com/comments/ot97w9z",
},
{
quote:
"the app looks great, and this is something I really need for my team and I as we spend all day merchandising product listings on amazon and other ecommerce sites.",
author: "@regalen",
context: "GitHub",
url: "https://github.com/snapotter-hq/SnapOtter/issues/7",
},
{
quote: "Awesome, thank you for the fast fix!",
author: "@Jisagi",
context: "GitHub",
url: "https://github.com/snapotter-hq/SnapOtter/issues/98#issuecomment-4320370719",
},
{
quote:
"If you're looking for a self-hosted alternative to online image editing services, SnapOtter is a great app.",
author: "Akash Jain",
context: "YouTube",
url: "https://www.youtube.com/watch?v=HWC3jX8-tiw",
},
];
/**
* Split into two rows that scroll in opposite directions.
* Alternating spreads the strongest quotes across both rows instead of stacking
* them all in the top one.
*/
export const TESTIMONIAL_ROWS: Testimonial[][] = [
TESTIMONIALS.filter((_, i) => i % 2 === 0),
TESTIMONIALS.filter((_, i) => i % 2 === 1),
];
+2
View File
@@ -356,6 +356,8 @@
"home.stats.languages.value": "20+",
"home.stats.languages.label": "Languages",
"home.stats.languages.sublabel": "Speaks your language",
"home.testimonials.title": "Straight from the people running it",
"home.testimonials.subtitle": "Unedited quotes from reviews, Reddit, GitHub, and the in-app feedback box. Typos and all.",
"home.toolGrid.title": "One platform. Every file workflow.",
"home.toolGrid.subtitle": "Search 200+ self-hosted tools by task, format, modality, or workflow.",
"home.toolGrid.filter.all": "All",
+72 -14
View File
@@ -4,18 +4,45 @@
// fetchers degrade to a maintained constant if the upstream API is unreachable
// (or rate-limited), so a build never ships an empty number.
// ghcr.io exposes no public pull-count API, so the GitHub Container Registry
// portion is a manually maintained estimate. Update it as it grows.
const GHCR_ESTIMATE = 36_000;
// ghcr.io exposes no pull-count API (`gh api orgs/snapotter-hq/packages/
// container/snapotter` 404s), but the count IS visible on the package page at
// github.com/orgs/snapotter-hq/packages. So this is read off by hand and cannot
// be fetched at build time like the Docker Hub figure below.
//
// OBSERVED 2026-07-25: 77,000. The previous value sat at 36,000 long enough to
// understate the real number by more than half, so re-read the package page
// whenever you touch this file and update the date with it.
const GHCR_ESTIMATE = 77_000;
// Fallbacks used when an upstream fetch fails. Keep roughly current so a
// degraded build still shows a believable figure.
const STAR_FALLBACK = 1720;
const DOCKER_FALLBACK = 104_000;
// Fallbacks for when an upstream fetch fails. These are a safety net, not a
// source of truth: a successful build overwrites them with live values, and the
// scheduled rebuild keeps that fresh. Because formatPulls rounds DOWN and adds
// "+", a stale constant understates rather than overstates, so a degraded build
// is never a false claim, just a quieter one.
//
// REFRESHED 2026-07-25 against the live APIs. They had drifted badly before
// that (104K against a real 232K, understating pulls by ~55%), because a failed
// fetch degraded silently and nothing ever surfaced the gap. `warnStale` below
// now puts it in the build log. Re-check these whenever you touch this file.
const STAR_FALLBACK = 2_080; // live 2026-07-25: 2,086
const DOCKER_FALLBACK = 232_000; // live 2026-07-25: 232,478
const GITHUB_REPO = "snapotter-hq/SnapOtter";
const DOCKERHUB_REPO = "snapotter/snapotter";
/**
* Announce that a build is shipping a hardcoded constant instead of a live
* figure. The fetches used to swallow every failure, so a rate-limited or down
* upstream produced a quietly wrong number with nothing in the log to show for
* it. That is how the fallbacks drifted ~55% out of date unnoticed.
*/
function warnStale(source: string, reason: string, value: number): void {
console.warn(
`[stats] ${source} unavailable (${reason}); falling back to the hardcoded ${value.toLocaleString()}. ` +
"This figure is probably stale; refresh the constant in apps/landing/src/lib/stats.ts.",
);
}
/** Compact integer formatting: 1720 -> "1.7k", 2_300_000 -> "2.3M". */
export function formatCompact(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
@@ -33,12 +60,27 @@ export function formatPulls(total: number): string {
return `${Math.floor(total / 10_000) * 10}K+`;
}
// Both stats are read from Astro frontmatter, and Navbar/TrustSignals render on
// every page, so an un-memoized fetch fires once PER PAGE: ~800 GitHub calls per
// full build. That blows through the unauthenticated 60 req/hr limit almost
// immediately, and GitHub starts returning 403, so early pages got the live
// count while every later page silently baked in the fallback and the site
// shipped two different star numbers. Caching the promise (not the value) means
// concurrent page renders share one in-flight request per build.
let starCountPromise: Promise<number> | undefined;
let imagePullsPromise: Promise<{ total: number; display: string }> | undefined;
/**
* GitHub star count, fetched at build time. Sends an Authorization header when
* GitHub star count, fetched once per build. Sends an Authorization header when
* GITHUB_TOKEN is set (CI), lifting the unauthenticated 60 req/hr limit that
* otherwise pins the count to the fallback. Returns STAR_FALLBACK on failure.
*/
export async function getStarCount(): Promise<number> {
export function getStarCount(): Promise<number> {
starCountPromise ??= fetchStarCount();
return starCountPromise;
}
async function fetchStarCount(): Promise<number> {
try {
const token = process.env.GITHUB_TOKEN;
const res = await fetch(`https://api.github.com/repos/${GITHUB_REPO}`, {
@@ -51,9 +93,12 @@ export async function getStarCount(): Promise<number> {
if (res.ok) {
const data = await res.json();
if (typeof data.stargazers_count === "number") return data.stargazers_count;
warnStale("GitHub stars", "response missing stargazers_count", STAR_FALLBACK);
} else {
warnStale("GitHub stars", `HTTP ${res.status}`, STAR_FALLBACK);
}
} catch {
// Network/JSON failure: fall through to the fallback below.
} catch (err) {
warnStale("GitHub stars", err instanceof Error ? err.message : "fetch threw", STAR_FALLBACK);
}
return STAR_FALLBACK;
}
@@ -63,7 +108,12 @@ export async function getStarCount(): Promise<number> {
* Returns the raw total and a display string. Docker Hub degrades to
* DOCKER_FALLBACK if the API is unreachable.
*/
export async function getImagePulls(): Promise<{ total: number; display: string }> {
export function getImagePulls(): Promise<{ total: number; display: string }> {
imagePullsPromise ??= fetchImagePulls();
return imagePullsPromise;
}
async function fetchImagePulls(): Promise<{ total: number; display: string }> {
let dockerPulls = DOCKER_FALLBACK;
try {
const res = await fetch(`https://hub.docker.com/v2/repositories/${DOCKERHUB_REPO}/`);
@@ -71,10 +121,18 @@ export async function getImagePulls(): Promise<{ total: number; display: string
const data = await res.json();
if (typeof data.pull_count === "number" && data.pull_count > 0) {
dockerPulls = data.pull_count;
} else {
warnStale("Docker Hub pulls", "response missing pull_count", DOCKER_FALLBACK);
}
} else {
warnStale("Docker Hub pulls", `HTTP ${res.status}`, DOCKER_FALLBACK);
}
} catch {
// Network/JSON failure: keep the Docker fallback.
} catch (err) {
warnStale(
"Docker Hub pulls",
err instanceof Error ? err.message : "fetch threw",
DOCKER_FALLBACK,
);
}
const total = dockerPulls + GHCR_ESTIMATE;
return { total, display: formatPulls(total) };
@@ -9,6 +9,7 @@ import JsonLd from "@/components/JsonLd.astro";
import Navbar from "@/components/Navbar.astro";
import OpenSource from "@/components/OpenSource.astro";
import Pricing from "@/components/Pricing.astro";
import Testimonials from "@/components/Testimonials.astro";
import ToolGrid from "@/components/ToolGrid.astro";
import { LANDING_LOCALES } from "@/i18n";
import Base from "@/layouts/Base.astro";
@@ -133,6 +134,7 @@ const navSchema = {
<ToolGrid locale={locale} />
<EnterpriseSection locale={locale} />
<FeatureHighlights locale={locale} />
<Testimonials locale={locale} />
<Pricing locale={locale} />
<OpenSource locale={locale} />
</main>
+55
View File
@@ -246,6 +246,61 @@ code, pre, kbd {
margin-left: 2px;
}
/* ─── TESTIMONIAL MARQUEE ─── */
/* Two rows drift in opposite directions. Each row's markup is duplicated, so
translating the track by exactly -50% lands on the start of the copy and the
loop reads as continuous. */
@keyframes marquee-drift {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
.marquee-viewport {
overflow: hidden;
/* Fade both edges so cards enter and leave instead of being clipped. */
mask-image: linear-gradient(to right, transparent, #000 6%, #000 94%, transparent);
-webkit-mask-image: linear-gradient(to right, transparent, #000 6%, #000 94%, transparent);
}
.marquee-track {
display: flex;
width: max-content;
animation: marquee-drift var(--marquee-duration, 60s) linear infinite;
}
.marquee-track-reverse {
animation-direction: reverse;
}
/* The spacing lives on the item, not as a track `gap`. A gap would also sit
between the last original and the first clone, so half the track would be
(N items + N-0.5 gaps) and the -50% translate would jump by half a gap on
every loop. Per-item margin keeps both halves exactly equal. */
.marquee-item {
display: flex;
width: 20rem;
flex-shrink: 0;
margin-inline-end: 1.25rem;
}
/* Hold still while someone is reading a card or tabbing through the links. */
.marquee:hover .marquee-track,
.marquee:focus-within .marquee-track {
animation-play-state: paused;
}
/* Reduced motion: no drift at all. The row becomes a plain swipeable strip so
the quotes stay reachable rather than being cut off at the fold. */
@media (prefers-reduced-motion: reduce) {
.marquee-track {
animation: none;
}
.marquee-viewport {
overflow-x: auto;
scrollbar-width: thin;
}
}
/* ─── TEXTURE OVERLAYS ─── */
.bg-noise::after {
content: "";
@@ -3,16 +3,16 @@ import {
FEEDBACK_PRIOR_TOOL_VALUES,
FEEDBACK_SELFHOST_MOTIVATION_VALUES,
} from "@snapotter/shared";
import { Building2, GraduationCap, Search, User, Users } from "lucide-react";
import { Building2, GraduationCap, Search, User, Users, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useLocation } from "react-router-dom";
import { useTranslation } from "@/contexts/i18n-context";
import { useAuth } from "@/hooks/use-auth";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { apiGet, apiPut } from "@/lib/api";
import { AUTH_GUARD_UNGATED_PATHS } from "@/lib/auth-routes";
import {
type FeedbackDiscoverySource,
type FeedbackDismissKind,
type FeedbackPriorTool,
type FeedbackSelfHostMotivation,
type FeedbackUsageType,
@@ -92,7 +92,17 @@ export function UsageSurveyOverlay() {
analyticsEnabled: Boolean(analyticsConfig?.enabled),
});
useFocusTrap(containerRef, visible);
// A non-modal prompt must not trap focus (that is what made the old overlay
// inescapable), but it should still honour Escape, which the modal version
// never did.
useEffect(() => {
if (!visible) return;
function onKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") void handleDismiss("close");
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
});
// Record the impression once the overlay first becomes visible, so the survey's
// completion and skip rates have a denominator (submissions alone can't measure
@@ -143,10 +153,10 @@ export function UsageSurveyOverlay() {
}
}
async function handleDismiss() {
async function handleDismiss(kind: FeedbackDismissKind = "dont_ask_again") {
if (busy) return;
setDismissing(true);
trackFeedbackPromptDismissed("onboarding", "dont_ask_again");
trackFeedbackPromptDismissed("onboarding", kind);
try {
await recordSettingsKey("onboarding.usageSurvey.dismissedAt");
} catch {
@@ -161,24 +171,44 @@ export function UsageSurveyOverlay() {
if (!visible) return null;
return (
// Deliberately NOT a modal. This used to be an opaque full-screen takeover
// with a focus trap and no Escape, which meant the only ways out were to
// answer or to find a tiny grey link. It is now a corner card: the app stays
// visible and usable behind it, Escape closes it, and the only required
// question is the first one.
<div
ref={containerRef}
role="dialog"
aria-modal="true"
aria-labelledby="usage-survey-title"
className="fixed inset-0 z-50 flex items-center justify-center bg-background p-4"
className="fixed bottom-4 end-4 z-50 w-[calc(100vw-2rem)] max-w-sm max-h-[calc(100dvh-2rem)] overflow-y-auto rounded-xl border border-border bg-background p-5 shadow-lg space-y-4"
>
<div className="w-full max-w-md space-y-6 max-h-[calc(100dvh-2rem)] overflow-y-auto">
<div className="flex flex-col items-center text-center gap-3">
<div className="space-y-4">
<div className="flex items-start gap-3">
<div
aria-hidden="true"
className="h-11 w-11 rounded-full bg-primary flex items-center justify-center text-xl"
className="h-8 w-8 shrink-0 rounded-full bg-primary flex items-center justify-center text-base"
>
🦦
</div>
<h1 id="usage-survey-title" className="text-lg font-semibold text-foreground">
{/*
An h2, not an h1. RouteAnnouncer focuses and announces
document.querySelector("h1") on every route change, so while this
prompt claimed the page's h1 it stole focus on appear and made every
subsequent navigation announce "How are you using SnapOtter?"
instead of the page the user actually opened.
*/}
<h2 id="usage-survey-title" className="grow text-sm font-semibold text-foreground">
{t.onboarding.usageSurveyTitle}
</h1>
</h2>
<button
type="button"
onClick={() => handleDismiss("close")}
disabled={busy}
aria-label={t.feedback.closeLabel}
className="-me-1 -mt-1 shrink-0 rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-50"
>
<X aria-hidden="true" className="h-4 w-4" />
</button>
</div>
<div
@@ -208,94 +238,107 @@ export function UsageSurveyOverlay() {
))}
</div>
<div className="space-y-2">
<p id="usage-survey-prior-label" className="text-sm font-medium text-foreground">
{t.onboarding.priorToolLabel}
</p>
<div
role="radiogroup"
aria-labelledby="usage-survey-prior-label"
className="grid grid-cols-1 gap-2"
>
{FEEDBACK_PRIOR_TOOL_VALUES.map((value) => (
// biome-ignore lint/a11y/useSemanticElements: styled button acting as an ARIA radio, not a native input
<button
key={value}
type="button"
role="radio"
aria-checked={priorTool === value}
onClick={() => setPriorTool((current) => (current === value ? null : value))}
className={cn(
"rounded-lg border px-3 py-2.5 text-sm font-medium text-start transition-colors",
priorTool === value
? "border-primary bg-primary/10 text-primary-ink"
: "border-border text-foreground hover:bg-muted",
)}
{/*
Progressive disclosure. The optional questions stay out of the way
until the one required answer is given, so the opening ask is a single
click rather than a wall of 20 choices. Send is enabled the moment the
first question is answered, so nobody is obliged to reach these.
*/}
{usageType !== null && (
<>
<div className="space-y-2">
<p id="usage-survey-prior-label" className="text-sm font-medium text-foreground">
{t.onboarding.priorToolLabel}{" "}
<span className="text-muted-foreground font-normal">
{t.onboarding.optionalHint}
</span>
</p>
<div
role="radiogroup"
aria-labelledby="usage-survey-prior-label"
className="grid grid-cols-1 gap-2"
>
{t.feedback.priorTools[value]}
</button>
))}
</div>
</div>
{FEEDBACK_PRIOR_TOOL_VALUES.map((value) => (
// biome-ignore lint/a11y/useSemanticElements: styled button acting as an ARIA radio, not a native input
<button
key={value}
type="button"
role="radio"
aria-checked={priorTool === value}
onClick={() => setPriorTool((current) => (current === value ? null : value))}
className={cn(
"rounded-lg border px-3 py-2.5 text-sm font-medium text-start transition-colors",
priorTool === value
? "border-primary bg-primary/10 text-primary-ink"
: "border-border text-foreground hover:bg-muted",
)}
>
{t.feedback.priorTools[value]}
</button>
))}
</div>
</div>
<div className="space-y-2">
<p id="usage-survey-motivation-label" className="text-sm font-medium text-foreground">
{t.onboarding.selfHostMotivationLabel}
</p>
<div
role="radiogroup"
aria-labelledby="usage-survey-motivation-label"
className="grid grid-cols-1 gap-2"
>
{FEEDBACK_SELFHOST_MOTIVATION_VALUES.map((value) => (
// biome-ignore lint/a11y/useSemanticElements: styled button acting as an ARIA radio, not a native input
<button
key={value}
type="button"
role="radio"
aria-checked={selfHostMotivation === value}
onClick={() =>
setSelfHostMotivation((current) => (current === value ? null : value))
<div className="space-y-2">
<p id="usage-survey-motivation-label" className="text-sm font-medium text-foreground">
{t.onboarding.selfHostMotivationLabel}
</p>
<div
role="radiogroup"
aria-labelledby="usage-survey-motivation-label"
className="grid grid-cols-1 gap-2"
>
{FEEDBACK_SELFHOST_MOTIVATION_VALUES.map((value) => (
// biome-ignore lint/a11y/useSemanticElements: styled button acting as an ARIA radio, not a native input
<button
key={value}
type="button"
role="radio"
aria-checked={selfHostMotivation === value}
onClick={() =>
setSelfHostMotivation((current) => (current === value ? null : value))
}
className={cn(
"rounded-lg border px-3 py-2.5 text-sm font-medium text-start transition-colors",
selfHostMotivation === value
? "border-primary bg-primary/10 text-primary-ink"
: "border-border text-foreground hover:bg-muted",
)}
>
{t.feedback.selfHostMotivations[value]}
</button>
))}
</div>
</div>
<div className="space-y-2">
<label
htmlFor="usage-survey-discovery-source"
className="block text-sm font-medium text-foreground"
>
{t.onboarding.discoverySourceLabel}{" "}
<span className="text-xs font-normal text-muted-foreground">
{t.onboarding.optionalHint}
</span>
</label>
<select
id="usage-survey-discovery-source"
value={discoverySource ?? ""}
onChange={(event) =>
setDiscoverySource((event.target.value || null) as FeedbackDiscoverySource | null)
}
className={cn(
"rounded-lg border px-3 py-2.5 text-sm font-medium text-start transition-colors",
selfHostMotivation === value
? "border-primary bg-primary/10 text-primary-ink"
: "border-border text-foreground hover:bg-muted",
)}
className="w-full rounded-lg border border-border bg-background px-3 py-2.5 text-sm text-foreground"
>
{t.feedback.selfHostMotivations[value]}
</button>
))}
</div>
</div>
<div className="space-y-2">
<label
htmlFor="usage-survey-discovery-source"
className="block text-sm font-medium text-foreground"
>
{t.onboarding.discoverySourceLabel}{" "}
<span className="text-xs font-normal text-muted-foreground">
{t.onboarding.optionalHint}
</span>
</label>
<select
id="usage-survey-discovery-source"
value={discoverySource ?? ""}
onChange={(event) =>
setDiscoverySource((event.target.value || null) as FeedbackDiscoverySource | null)
}
className="w-full rounded-lg border border-border bg-background px-3 py-2.5 text-sm text-foreground"
>
<option value="" />
{FEEDBACK_DISCOVERY_SOURCE_VALUES.map((value) => (
<option key={value} value={value}>
{t.feedback.discoverySources[value]}
</option>
))}
</select>
</div>
<option value="" />
{FEEDBACK_DISCOVERY_SOURCE_VALUES.map((value) => (
<option key={value} value={value}>
{t.feedback.discoverySources[value]}
</option>
))}
</select>
</div>
</>
)}
<div className="space-y-3">
<button
@@ -308,9 +351,11 @@ export function UsageSurveyOverlay() {
</button>
<button
type="button"
onClick={handleDismiss}
// Wrapped, not passed by reference: a bare handler would hand the
// click event in as the dismiss kind.
onClick={() => handleDismiss("dont_ask_again")}
disabled={busy}
className="w-full text-center text-xs text-muted-foreground hover:text-foreground hover:underline"
className="w-full text-center text-xs text-muted-foreground hover:text-foreground hover:underline disabled:opacity-50"
>
{t.feedback.dontAskAgain}
</button>
+5 -2
View File
@@ -38,7 +38,10 @@ export type FeedbackPromptVariant =
| "settings-card-v1"
| "search-empty-v1"
| "search-results-v1"
| "onboarding-overlay-v1";
// onboarding-overlay-v1 was the blocking full-screen version. It is retained
// so historical events stay typed; the live prompt is the non-blocking card.
| "onboarding-overlay-v1"
| "onboarding-card-v1";
export interface FeedbackPayload {
source: FeedbackSource;
@@ -107,7 +110,7 @@ export function promptVariantForSource(source: FeedbackSource): FeedbackPromptVa
case "global":
return "nav-v1";
case "onboarding":
return "onboarding-overlay-v1";
return "onboarding-card-v1";
}
}
+132 -1
View File
@@ -1,5 +1,20 @@
import { formatCompact, formatPulls } from "@landing/lib/stats";
import { describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// getStarCount/getImagePulls memoize per module instance so a build fetches once
// rather than once per page. Tests therefore need a FRESH module each time, or
// the first test's cached result leaks into every later assertion.
async function freshStats() {
vi.resetModules();
return import("@landing/lib/stats");
}
// Match the exact host, not a substring. `url.includes("hub.docker.com")` would
// also match hub.docker.com.evil.test, which is the incomplete-URL-sanitization
// pattern CodeQL flags, and it is worth not teaching that shape in test code.
function isDockerHub(url: string): boolean {
return new URL(url).hostname === "hub.docker.com";
}
describe("formatCompact", () => {
it("formats thousands with one decimal", () => {
@@ -40,3 +55,119 @@ describe("formatPulls", () => {
expect(formatPulls(1_250_000)).toBe("1.2M+");
});
});
// These fetchers used to swallow every upstream failure without a word, which
// let the hardcoded fallbacks drift ~55% out of date unnoticed. The point of
// these tests is not the constants themselves (they move); it is that a
// degraded build stays conservative AND says so out loud.
describe("stat fetchers when upstream is unavailable", () => {
let warn: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
warn = vi.spyOn(console, "warn").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("falls back and warns when the fetch throws", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => {
throw new Error("simulated outage");
}),
);
const stats = await freshStats();
const stars = await stats.getStarCount();
const pulls = await stats.getImagePulls();
expect(stars).toBeGreaterThan(0);
expect(pulls.total).toBeGreaterThan(0);
expect(warn).toHaveBeenCalledTimes(2);
expect(warn.mock.calls.flat().join(" ")).toContain("simulated outage");
});
it("falls back and warns on a non-ok response", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ ok: false, status: 503, json: async () => ({}) })),
);
const stats = await freshStats();
await stats.getStarCount();
await stats.getImagePulls();
expect(warn).toHaveBeenCalledTimes(2);
expect(warn.mock.calls.flat().join(" ")).toContain("HTTP 503");
});
it("warns when the response parses but omits the field it needs", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ ok: true, status: 200, json: async () => ({}) })),
);
const stats = await freshStats();
await stats.getStarCount();
await stats.getImagePulls();
expect(warn).toHaveBeenCalledTimes(2);
expect(warn.mock.calls.flat().join(" ")).toContain("missing");
});
it("uses live values and stays quiet when upstream responds", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) =>
isDockerHub(url)
? { ok: true, status: 200, json: async () => ({ pull_count: 500_000 }) }
: { ok: true, status: 200, json: async () => ({ stargazers_count: 4242 }) },
),
);
const stats = await freshStats();
expect(await stats.getStarCount()).toBe(4242);
// Live Docker Hub count plus the GHCR estimate, so assert the floor, not equality.
expect((await stats.getImagePulls()).total).toBeGreaterThanOrEqual(500_000);
expect(warn).not.toHaveBeenCalled();
});
// Regression guard. Navbar and TrustSignals render on every one of ~800 built
// pages, so an un-memoized fetch meant ~800 unauthenticated GitHub calls per
// build. GitHub 403s after 60, so early pages baked in the live count and
// later ones baked in the fallback: one site, two different star numbers.
it("fetches once per build no matter how many pages ask", async () => {
const fetchSpy = vi.fn(async (url: string) =>
isDockerHub(url)
? { ok: true, status: 200, json: async () => ({ pull_count: 500_000 }) }
: { ok: true, status: 200, json: async () => ({ stargazers_count: 4242 }) },
);
vi.stubGlobal("fetch", fetchSpy);
const stats = await freshStats();
// Simulate many pages rendering concurrently, as Astro does.
const stars = await Promise.all(Array.from({ length: 50 }, () => stats.getStarCount()));
const pulls = await Promise.all(Array.from({ length: 50 }, () => stats.getImagePulls()));
expect(new Set(stars)).toEqual(new Set([4242]));
expect(new Set(pulls.map((p) => p.display)).size).toBe(1);
// One call for GitHub, one for Docker Hub. Not 100.
expect(fetchSpy).toHaveBeenCalledTimes(2);
});
it("keeps the degraded figure conservative rather than inflated", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => {
throw new Error("down");
}),
);
const { total, display } = await (await freshStats()).getImagePulls();
// formatPulls rounds down and appends "+", so a stale build understates.
expect(Number(display.replace(/[^\d.]/g, "")) * 1000).toBeLessThanOrEqual(total);
});
});
+88 -4
View File
@@ -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 });