diff --git a/README.md b/README.md index ed88f65..5d37da5 100644 --- a/README.md +++ b/README.md @@ -1368,16 +1368,18 @@ Things I chose while building, with the reasoning: tree. Reduced motion skips the spring and the swap still lands via `onAnimationComplete`. The phase-2 suite asserts the painted-face invariant at rest, flipped and flipped back. -52. **Reporting a question is local-first, with a server copy for the operator.** The red "!" in - the feed chrome records the question in this browser — that local list is the skip state, so the - card leaves study, review, mistakes and exam sessions, and Undo in the toast brings it back - (the server file is append-only). It also POSTs a copy to `/api/reports`, which appends one JSON - line to `shared/freezmo/reports/reports.jsonl`. The app has no auth, so the route is write-only: - the question text is readable on the server (that is the point — prompt tuning) but never over - HTTP. A reported card counts as "not attempted" in the results rather than wrong, and a deck - where everything is reported stays studiable so it can be regenerated. Verified by the phase-1 - suite (append, validation, no GET) and the phase-2 suite (button, toast, server POST, local - copy, skip, hidden next session, Undo, exam pool). +52. **Reporting a question is local-first, with a server copy for the operator.** The red "!" under + the card (bottom-right of the exercise — it used to sit in the top chrome, where it read as + chrome and was easy to mis-tap) records the question in this browser — that local list is the + skip state, so the card leaves study, review, mistakes and exam sessions, and Undo in the toast + brings it back (the server file is append-only). It also POSTs a copy to `/api/reports`, which + appends one JSON line to `shared/freezmo/reports/reports.jsonl`. The app has no auth, so the + route is write-only: the question text is readable on the server (that is the point — prompt + tuning) but never over HTTP. A reported card counts as "not attempted" in the results rather + than wrong, and a deck where everything is reported stays studiable so it can be regenerated. + Verified by the phase-1 suite (append, validation, no GET) and the phase-2 suite (button + placement under the card with a 44px target, toast, server POST, local copy, skip, hidden next + session, Undo, exam pool). 51. **Cards with repeated wording never ship.** The model sometimes pads a matching card with one definition copied across several terms (a Czech literature deck arrived on a phone with three diff --git a/components/ReportQuestionButton.tsx b/components/ReportQuestionButton.tsx index cd29538..b26686a 100644 --- a/components/ReportQuestionButton.tsx +++ b/components/ReportQuestionButton.tsx @@ -7,8 +7,9 @@ import type { Activity, Deck } from "@/lib/schemas"; import { showToast } from "@/lib/toast"; /** - * The red "!" report control in the feed chrome: records the question, sends - * the operator a copy and skips past it. Undo lives in the toast. + * The red "!" report control that sits under the card (bottom-right of the + * exercise): records the question, sends the operator a copy and skips past + * it. Undo lives in the toast. 44px, because it is a thumb target. */ export function ReportQuestionButton({ deck, @@ -36,7 +37,7 @@ export function ReportQuestionButton({ }} aria-label={t("feed.report")} title={t("feed.report")} - className="grid size-9 shrink-0 place-items-center rounded-xl border-2 border-rose-400/80 text-[15px] font-black leading-none text-rose-600 transition hover:bg-rose-50 dark:border-rose-400/50 dark:text-rose-300 dark:hover:bg-rose-400/10" + className="grid size-11 shrink-0 place-items-center rounded-xl border-2 border-rose-400/80 text-base font-black leading-none text-rose-600 transition hover:bg-rose-50 dark:border-rose-400/50 dark:text-rose-300 dark:hover:bg-rose-400/10" > ! diff --git a/components/SessionFeed.tsx b/components/SessionFeed.tsx index 451803f..279dba6 100644 --- a/components/SessionFeed.tsx +++ b/components/SessionFeed.tsx @@ -174,16 +174,6 @@ export function SessionFeed({ {formatClock(remaining)} ) : null} - {!finished && items[index] ? ( - { - setSkipped((current) => [...current, items[index].activity.id]); - goTo(index + 1); - }} - /> - ) : null}
{title} @@ -217,7 +207,26 @@ export function SessionFeed({ isLast={itemIndex === total - 1} nextLabel={itemIndex === total - 1 ? t("review.seeResults") : undefined} /> - + {/* Under the card: disclaimer centred, report control in the + bottom-right corner (it used to sit in the top chrome). */} + {!finished && index === itemIndex ? ( +
+ + +
+ { + setSkipped((current) => [...current, item.activity.id]); + goTo(itemIndex + 1); + }} + /> +
+
+ ) : ( + + )}
))} diff --git a/components/StudyFeed.tsx b/components/StudyFeed.tsx index 811ebb8..a5ac405 100644 --- a/components/StudyFeed.tsx +++ b/components/StudyFeed.tsx @@ -279,16 +279,6 @@ export function StudyFeed({ deck }: { deck: Deck }) { {xp} - {!finished && queue[index] ? ( - { - setSkipped((current) => [...current, queue[index].id]); - goTo(index + 1); - }} - /> - ) : null}
@@ -327,7 +317,26 @@ export function StudyFeed({ deck }: { deck: Deck }) { onAnswered={(correct, meta) => handleAnswered(activity, correct, meta)} onNext={() => goTo(activityIndex + 1)} /> - + {/* Under the card: disclaimer centred, report control in the + bottom-right corner (it used to sit in the top chrome). */} + {!finished && index === activityIndex ? ( +
+ + +
+ { + setSkipped((current) => [...current, activity.id]); + goTo(activityIndex + 1); + }} + /> +
+
+ ) : ( + + )}
))} diff --git a/public/sw.js b/public/sw.js index ccd1133..aa3a537 100644 --- a/public/sw.js +++ b/public/sw.js @@ -9,7 +9,7 @@ * * Bumping CACHE_VERSION is how a new deploy invalidates old assets. */ -const CACHE_VERSION = "freezmo-v42"; +const CACHE_VERSION = "freezmo-v43"; const SHELL_CACHE = `${CACHE_VERSION}-shell`; const ASSET_CACHE = `${CACHE_VERSION}-assets`; diff --git a/tests/phase2-test.mjs b/tests/phase2-test.mjs index cec0e41..88c234a 100644 --- a/tests/phase2-test.mjs +++ b/tests/phase2-test.mjs @@ -142,7 +142,37 @@ const browser = await launchBrowser(); await page.waitForTimeout(1000); const report = page.getByRole("button", { name: "Report question" }); - check("report: button sits in the feed chrome", await report.isVisible()); + check("report: button visible", await report.isVisible()); + // It belongs under the card now: bottom-right corner of the exercise, not + // in the top chrome where it was easy to mis-tap. + const placement = await page.evaluate(() => { + const prompt = [...document.querySelectorAll("p")].find( + (el) => el.textContent === "What colour is a clear daytime sky?", + ); + const card = prompt?.closest(".glass-solid"); + const button = document.querySelector('[aria-label="Report question"]'); + if (!card || !button) return null; + const c = card.getBoundingClientRect(); + const b = button.getBoundingClientRect(); + return { + inChrome: Boolean(button.closest("header")), + belowCard: b.top >= c.bottom - 6, + rightAligned: Math.abs(b.right - c.right) <= 10, + rightSide: b.left + b.width / 2 > c.left + c.width / 2, + size: [Math.round(b.width), Math.round(b.height)], + }; + }); + check( + "report: sits bottom-right under the card, 44px", + Boolean(placement) && + !placement.inChrome && + placement.belowCard && + placement.rightAligned && + placement.rightSide && + placement.size[0] >= 43.5 && + placement.size[1] >= 43.5, + JSON.stringify(placement), + ); await report.click(); await page.waitForTimeout(700); check("report: toast with Undo", await page.getByText("Question reported").isVisible());