Skip to content

Commit 7e73acd

Browse files
committed
Send feedback (Formspree) + lint cleanup + a11y live regions
Three small things bundled. 1. Feedback dialog. New FeedbackDialog component matches the AJAX flow from deepseasleeper-web — POSTs FormData to formspree.io with an Accept: application/json header, fades the form on success, shows a "Got it. Thanks." confirmation. - Auto-attaches diagnostic context (app version, user-agent, route path) so bug reports don't require the reporter to describe their environment. - Surfaced as a "Send feedback" button at the bottom of Settings → About. Body scroll locked + overscroll-contain like the other bottom sheets. - Form ID lives in a single constant in FeedbackDialog.tsx; rotate by editing one line. 2. Lint cleanup. npm run lint had never been run — surfaced 12 errors. - Replaced `any` with `unknown` in ExerciseRunner's locker plumbing, casting at the renderExercise boundary where each exercise type knows the concrete shape (string for identify/mnemonic, boolean for discriminate, number for find-bird). - Howler internal access in audio.ts now uses a typed `HowlInternal` shape instead of `as any`. webkitAudioContext fallback in feedback.ts uses a narrowed `unknown` cast. - Three legitimate setState-in-effect call-sites (resetting state when a key prop changes) keep targeted // eslint-disable-next-line react-hooks/set-state-in-effect comments. Standard React pattern; the new lint rule is overly aggressive on it. - npm run lint now passes clean. 3. A11y live regions. Results page H1 ("Lesson complete" / "Out of hearts") gets role="status" aria-live="polite" so screen readers announce the outcome on transition. The exercise FeedbackBar already had a live region; verified.
1 parent 3f650d0 commit 7e73acd

6 files changed

Lines changed: 232 additions & 12 deletions

File tree

src/components/ExerciseRunner.tsx

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,15 +32,22 @@ export function ExerciseRunner({ exercise, exerciseIndex, previouslyAnswered, ac
3232
// If the store already has a result for this exercise (user navigated to a
3333
// species page and back), seed locked so they can't re-answer.
3434
const seed = previouslyAnswered ? { value: null, correct: previouslyAnswered.correct } : null;
35-
const [locked, setLocked] = useState<{ value: any; correct: boolean } | null>(seed);
35+
// The "locker value" is exercise-specific (species id for identify/mnemonic,
36+
// boolean for discriminate, index for find-bird). Keep it as unknown rather
37+
// than any so callers can't accidentally compare the wrong shape.
38+
const [locked, setLocked] = useState<{ value: unknown; correct: boolean } | null>(seed);
3639
const [hintShown, setHintShown] = useState(false);
3740

41+
// Reset on prop change — standard pattern even though this triggers the
42+
// react-hooks/set-state-in-effect rule.
3843
useEffect(() => {
44+
/* eslint-disable react-hooks/set-state-in-effect */
3945
setLocked(previouslyAnswered ? { value: null, correct: previouslyAnswered.correct } : null);
4046
setHintShown(false);
47+
/* eslint-enable react-hooks/set-state-in-effect */
4148
}, [exerciseIndex, previouslyAnswered]);
4249

43-
const handleAnswered = (correct: boolean, locker: any, speciesId: string | null) => {
50+
const handleAnswered = (correct: boolean, locker: unknown, speciesId: string | null) => {
4451
setLocked({ value: locker, correct });
4552
if (correct) correctChime(); else wrongBuzz();
4653
onAnswered(correct, speciesId);
@@ -115,39 +122,39 @@ export function ExerciseRunner({ exercise, exerciseIndex, previouslyAnswered, ac
115122

116123
function renderExercise(
117124
exercise: Exercise,
118-
locked: { value: any; correct: boolean } | null,
119-
onAnswered: (correct: boolean, locker: any, speciesId: string | null) => void,
125+
locked: { value: unknown; correct: boolean } | null,
126+
onAnswered: (correct: boolean, locker: unknown, speciesId: string | null) => void,
120127
) {
121128
switch (exercise.kind) {
122129
case "identify":
123130
return (
124131
<IdentifyExerciseView
125132
exercise={exercise}
126-
locked={locked?.value ?? null}
133+
locked={(locked?.value as string | null) ?? null}
127134
onAnswered={(correct) => onAnswered(correct, exercise.choices.find(id => id === exercise.correctSpeciesId)!, exercise.correctSpeciesId)}
128135
/>
129136
);
130137
case "mnemonic":
131138
return (
132139
<MnemonicExerciseView
133140
exercise={exercise}
134-
locked={locked?.value ?? null}
141+
locked={(locked?.value as string | null) ?? null}
135142
onAnswered={(correct) => onAnswered(correct, exercise.correctSpeciesId, exercise.correctSpeciesId)}
136143
/>
137144
);
138145
case "discriminate":
139146
return (
140147
<DiscriminateExerciseView
141148
exercise={exercise}
142-
locked={locked?.value ?? null}
149+
locked={(locked?.value as boolean | null) ?? null}
143150
onAnswered={(correct) => onAnswered(correct, exercise.same, exercise.speciesIdA)}
144151
/>
145152
);
146153
case "find-bird":
147154
return (
148155
<FindBirdExerciseView
149156
exercise={exercise}
150-
locked={locked?.value ?? null}
157+
locked={(locked?.value as number | null) ?? null}
151158
onAnswered={(correct) => onAnswered(correct, exercise.correctIndex, exercise.targetSpeciesId)}
152159
/>
153160
);
@@ -159,7 +166,7 @@ function FeedbackBar({
159166
exercise,
160167
onContinue,
161168
}: {
162-
locked: { value: any; correct: boolean } | null;
169+
locked: { value: unknown; correct: boolean } | null;
163170
exercise: Exercise;
164171
onContinue: () => void;
165172
}) {
@@ -171,6 +178,7 @@ function FeedbackBar({
171178
const [advancing, setAdvancing] = useState(false);
172179

173180
// Reset the guard when a fresh answer comes in (new locked, new exercise).
181+
// eslint-disable-next-line react-hooks/set-state-in-effect
174182
useEffect(() => { setAdvancing(false); }, [locked, exercise]);
175183

176184
const handleClick = () => {

src/components/FeedbackDialog.tsx

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import { useEffect, useRef, useState } from "react";
2+
import { AnimatePresence, motion } from "framer-motion";
3+
import { cn } from "@/lib/cn";
4+
5+
/**
6+
* Formspree form ID — the path segment from formspree.io/f/<this-bit>.
7+
* Maintained by the goodbird owner; rotate by creating a new form at
8+
* formspree.io and pasting its ID here.
9+
*/
10+
const FORMSPREE_FORM_ID = "xojyzwzn";
11+
12+
interface Props {
13+
open: boolean;
14+
onClose: () => void;
15+
}
16+
17+
type SubmitState =
18+
| { kind: "idle" }
19+
| { kind: "submitting" }
20+
| { kind: "ok" }
21+
| { kind: "error"; message: string };
22+
23+
export function FeedbackDialog({ open, onClose }: Props) {
24+
const [state, setState] = useState<SubmitState>({ kind: "idle" });
25+
const formRef = useRef<HTMLFormElement>(null);
26+
27+
useEffect(() => {
28+
if (!open) return;
29+
// eslint-disable-next-line react-hooks/set-state-in-effect
30+
setState({ kind: "idle" });
31+
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
32+
window.addEventListener("keydown", onKey);
33+
const prev = document.body.style.overflow;
34+
document.body.style.overflow = "hidden";
35+
return () => {
36+
window.removeEventListener("keydown", onKey);
37+
document.body.style.overflow = prev;
38+
};
39+
}, [open, onClose]);
40+
41+
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
42+
e.preventDefault();
43+
if (!FORMSPREE_FORM_ID) return;
44+
setState({ kind: "submitting" });
45+
const form = e.currentTarget;
46+
const data = new FormData(form);
47+
// Auto-attach diagnostic info so bug reports include something useful.
48+
data.set("_subject", `goodbird feedback (v${__APP_VERSION__})`);
49+
data.set("app_version", __APP_VERSION__);
50+
data.set("user_agent", navigator.userAgent);
51+
data.set("page", window.location.pathname);
52+
try {
53+
const res = await fetch(`https://formspree.io/f/${FORMSPREE_FORM_ID}`, {
54+
method: "POST",
55+
body: data,
56+
headers: { Accept: "application/json" },
57+
});
58+
if (res.ok) {
59+
setState({ kind: "ok" });
60+
} else {
61+
const body = await res.json().catch(() => ({}));
62+
setState({ kind: "error", message: body?.error || "Couldn't send. Try again?" });
63+
}
64+
} catch {
65+
setState({ kind: "error", message: "Couldn't send. Check your connection?" });
66+
}
67+
};
68+
69+
return (
70+
<AnimatePresence>
71+
{open && (
72+
<>
73+
<motion.div
74+
initial={{ opacity: 0 }}
75+
animate={{ opacity: 1 }}
76+
exit={{ opacity: 0 }}
77+
transition={{ duration: 0.18 }}
78+
onClick={onClose}
79+
className="fixed inset-0 z-[60] bg-(--color-ink)/45 backdrop-blur-[2px]"
80+
aria-hidden
81+
/>
82+
<motion.div
83+
role="dialog"
84+
aria-label="Send feedback"
85+
initial={{ opacity: 0, y: 24 }}
86+
animate={{ opacity: 1, y: 0 }}
87+
exit={{ opacity: 0, y: 24 }}
88+
transition={{ duration: 0.22, ease: "easeOut" }}
89+
className="fixed inset-x-0 bottom-0 z-[61] mx-auto max-h-[88dvh] max-w-md overflow-y-auto overscroll-contain rounded-t-3xl bg-(--color-surface) px-6 pb-8 pt-6 shadow-(--shadow-pop)"
90+
>
91+
<div className="flex items-start justify-between gap-3">
92+
<div>
93+
<p className="font-mono text-[10px] font-medium uppercase tracking-[0.22em] text-(--color-ink-soft)">
94+
Field report
95+
</p>
96+
<h2 className="mt-1 font-display text-3xl">Send feedback</h2>
97+
</div>
98+
<button
99+
onClick={onClose}
100+
aria-label="Close"
101+
className="grid h-9 w-9 shrink-0 place-items-center rounded-full text-(--color-ink-soft) hover:bg-(--color-line) cursor-pointer"
102+
>
103+
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round">
104+
<path d="M6 6l12 12M18 6L6 18" />
105+
</svg>
106+
</button>
107+
</div>
108+
109+
{!FORMSPREE_FORM_ID ? (
110+
<p className="mt-6 rounded-2xl border-2 border-(--color-line) bg-(--color-bg) px-4 py-3 text-sm leading-snug text-(--color-ink-soft)">
111+
Feedback isn't set up yet. The maintainer needs to paste a
112+
Formspree form ID into{" "}
113+
<code className="font-mono text-xs text-(--color-ink)">
114+
src/components/FeedbackDialog.tsx
115+
</code>
116+
.
117+
</p>
118+
) : state.kind === "ok" ? (
119+
<div role="status" aria-live="polite" className="mt-6 space-y-3 text-center">
120+
<p className="font-display text-xl text-(--color-moss-700)">Got it. Thanks.</p>
121+
<p className="text-sm text-(--color-ink-soft)">
122+
Your note went through. The maintainer reads them all.
123+
</p>
124+
<button
125+
onClick={onClose}
126+
className="tap-target mt-3 w-full rounded-full bg-(--color-moss-500) px-6 py-3 font-semibold text-white shadow-(--shadow-pop) hover:bg-(--color-moss-600) cursor-pointer"
127+
>
128+
Close
129+
</button>
130+
</div>
131+
) : (
132+
<form ref={formRef} onSubmit={handleSubmit} className="mt-6 space-y-3">
133+
<p className="text-sm leading-snug text-(--color-ink-soft)">
134+
What broke, what's confusing, what could be better. App version
135+
and the page you're on are auto-attached so you don't have to
136+
describe them.
137+
</p>
138+
<label className="block">
139+
<span className="font-mono text-[10px] font-medium uppercase tracking-[0.2em] text-(--color-ink-soft)">
140+
Email (optional)
141+
</span>
142+
<input
143+
type="email"
144+
name="email"
145+
autoComplete="email"
146+
placeholder="so we can reply"
147+
className="mt-1 w-full rounded-2xl border-2 border-(--color-line) bg-(--color-surface) px-4 py-2.5 text-sm focus:border-(--color-moss-500) focus:outline-none"
148+
/>
149+
</label>
150+
<label className="block">
151+
<span className="font-mono text-[10px] font-medium uppercase tracking-[0.2em] text-(--color-ink-soft)">
152+
Message
153+
</span>
154+
<textarea
155+
name="message"
156+
required
157+
rows={5}
158+
placeholder="Type away."
159+
className="mt-1 w-full rounded-2xl border-2 border-(--color-line) bg-(--color-surface) px-4 py-2.5 text-sm focus:border-(--color-moss-500) focus:outline-none"
160+
/>
161+
</label>
162+
{state.kind === "error" && (
163+
<p role="alert" className="text-sm text-(--color-wrong)">{state.message}</p>
164+
)}
165+
<div className="flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:justify-end">
166+
<button
167+
type="button"
168+
onClick={onClose}
169+
className="tap-target rounded-full border-2 border-(--color-line) px-5 py-3 font-semibold text-(--color-ink-soft) hover:border-(--color-ink-soft) cursor-pointer"
170+
>
171+
Cancel
172+
</button>
173+
<button
174+
type="submit"
175+
disabled={state.kind === "submitting"}
176+
className={cn(
177+
"tap-target rounded-full bg-(--color-moss-500) px-6 py-3 font-semibold text-white shadow-(--shadow-pop) transition hover:bg-(--color-moss-600) cursor-pointer",
178+
state.kind === "submitting" && "opacity-70 cursor-default",
179+
)}
180+
>
181+
{state.kind === "submitting" ? "Sending…" : "Send"}
182+
</button>
183+
</div>
184+
</form>
185+
)}
186+
</motion.div>
187+
</>
188+
)}
189+
</AnimatePresence>
190+
);
191+
}

src/components/SettingsSheet.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useGame } from "@/game/store";
44
import { allMediaUrls } from "@/lib/manifest";
55
import { clearMediaCache, countCachedMedia, precacheUrls } from "@/lib/sw";
66
import { Wordmark } from "@/components/brand/Wordmark";
7+
import { FeedbackDialog } from "@/components/FeedbackDialog";
78
import { cn } from "@/lib/cn";
89

910
interface Props {
@@ -31,6 +32,7 @@ export function SettingsSheet({ open, onClose }: Props) {
3132
const [confirming, setConfirming] = useState(false);
3233
const [offline, setOffline] = useState<OfflineState>({ kind: "idle" });
3334
const [cachedCount, setCachedCount] = useState<number | null>(null);
35+
const [feedbackOpen, setFeedbackOpen] = useState(false);
3436

3537
// We deliberately don't show a byte-size estimate. Browsers pad cross-origin
3638
// (no-cors) cache entries to ~7 MB each as a side-channel-attack defense, so
@@ -39,6 +41,8 @@ export function SettingsSheet({ open, onClose }: Props) {
3941
countCachedMedia().then(setCachedCount);
4042
};
4143

44+
// Reset the destructive-confirm state when the sheet closes.
45+
// eslint-disable-next-line react-hooks/set-state-in-effect
4246
useEffect(() => { if (!open) setConfirming(false); }, [open]);
4347
useEffect(() => {
4448
if (!open) return;
@@ -259,7 +263,14 @@ export function SettingsSheet({ open, onClose }: Props) {
259263
<span className="text-(--color-ink-soft)">MIT licensed</span>
260264
</div>
261265
</div>
266+
<button
267+
onClick={() => setFeedbackOpen(true)}
268+
className="mt-3 tap-target w-full rounded-full border-2 border-(--color-line) bg-(--color-surface) px-5 py-3 text-sm font-semibold text-(--color-ink) transition-colors hover:border-(--color-moss-300) cursor-pointer"
269+
>
270+
Send feedback
271+
</button>
262272
</Section>
273+
<FeedbackDialog open={feedbackOpen} onClose={() => setFeedbackOpen(false)} />
263274

264275
<Section label="Danger" tone="warn">
265276
{!confirming ? (

src/lib/audio.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@ export function getHowl(url: string): Howl {
1111
// octave — the opposite of what an ear-training app wants. Howler uses one
1212
// HTMLAudioElement per Howl in html5 mode; reach into _sounds to set it.
1313
h.once("load", () => {
14-
for (const s of (h as any)._sounds ?? []) {
14+
// Howler doesn't expose preservesPitch on its public API; reach into the
15+
// private _sounds array (one Howl ↔ many sounds in pool). The type
16+
// assertion keeps lint quiet around the documented internal.
17+
type HowlInternal = Howl & { _sounds?: Array<{ _node?: HTMLMediaElement }> };
18+
for (const s of (h as HowlInternal)._sounds ?? []) {
1519
if (s?._node) s._node.preservesPitch = true;
1620
}
1721
});

src/lib/feedback.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@
33

44
let ctx: AudioContext | null = null;
55
function ac(): AudioContext {
6-
if (!ctx) ctx = new (window.AudioContext || (window as any).webkitAudioContext)();
6+
if (!ctx) {
7+
const Ctor =
8+
window.AudioContext ??
9+
(window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
10+
ctx = new Ctor();
11+
}
712
if (ctx.state === "suspended") ctx.resume();
813
return ctx;
914
}

src/routes/Results.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export function ResultsRoute() {
4747
const missedSpeciesIds = [...missed];
4848
const result = finalize();
4949
if (result) {
50+
// eslint-disable-next-line react-hooks/set-state-in-effect
5051
setSnap({ ...result, correct, total, hearts, missedSpeciesIds });
5152
if (result.passed) {
5253
lessonComplete();
@@ -83,7 +84,7 @@ export function ResultsRoute() {
8384
{snap.passed ? "🪶" : "🌱"}
8485
</motion.div>
8586
<div>
86-
<h1 className="font-display text-3xl">
87+
<h1 className="font-display text-3xl" role="status" aria-live="polite">
8788
{snap.passed ? "Lesson complete" : "Out of hearts"}
8889
</h1>
8990
<p className="mt-1 text-(--color-ink-soft)">{lessonTitle}</p>

0 commit comments

Comments
 (0)