Report button moves under the exercise, bottom-right

The red "!" left the top chrome (it read as chrome and was easy to
mis-tap) and now sits in a row under the exercise: the AI disclaimer stays
centred, the report control is right-aligned with the card edge and is a
44px target. Applies to study, Smart Review and exam sessions; the results
slides keep just the disclaimer.

phase-2 suite now asserts the placement (below the card, right-aligned,
not in the header, >=44px) — 60 checks.

README: decision 52 updated. SW -> freezmo-v43.
This commit is contained in:
Martin Dvořák 2026-10-12 12:29:59 +00:00
commit 33c0ad1ed0
6 changed files with 88 additions and 37 deletions

View file

@ -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 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. 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 52. **Reporting a question is local-first, with a server copy for the operator.** The red "!" under
the feed chrome records the question in this browser — that local list is the skip state, so the the card (bottom-right of the exercise — it used to sit in the top chrome, where it read as
card leaves study, review, mistakes and exam sessions, and Undo in the toast brings it back chrome and was easy to mis-tap) records the question in this browser — that local list is the
(the server file is append-only). It also POSTs a copy to `/api/reports`, which appends one JSON skip state, so the card leaves study, review, mistakes and exam sessions, and Undo in the toast
line to `shared/freezmo/reports/reports.jsonl`. The app has no auth, so the route is write-only: brings it back (the server file is append-only). It also POSTs a copy to `/api/reports`, which
the question text is readable on the server (that is the point — prompt tuning) but never over appends one JSON line to `shared/freezmo/reports/reports.jsonl`. The app has no auth, so the
HTTP. A reported card counts as "not attempted" in the results rather than wrong, and a deck route is write-only: the question text is readable on the server (that is the point — prompt
where everything is reported stays studiable so it can be regenerated. Verified by the phase-1 tuning) but never over HTTP. A reported card counts as "not attempted" in the results rather
suite (append, validation, no GET) and the phase-2 suite (button, toast, server POST, local than wrong, and a deck where everything is reported stays studiable so it can be regenerated.
copy, skip, hidden next session, Undo, exam pool). 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 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 definition copied across several terms (a Czech literature deck arrived on a phone with three

View file

@ -7,8 +7,9 @@ import type { Activity, Deck } from "@/lib/schemas";
import { showToast } from "@/lib/toast"; import { showToast } from "@/lib/toast";
/** /**
* The red "!" report control in the feed chrome: records the question, sends * The red "!" report control that sits under the card (bottom-right of the
* the operator a copy and skips past it. Undo lives in the toast. * 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({ export function ReportQuestionButton({
deck, deck,
@ -36,7 +37,7 @@ export function ReportQuestionButton({
}} }}
aria-label={t("feed.report")} aria-label={t("feed.report")}
title={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"
> >
<span aria-hidden>!</span> <span aria-hidden>!</span>
</button> </button>

View file

@ -174,16 +174,6 @@ export function SessionFeed({
{formatClock(remaining)} {formatClock(remaining)}
</span> </span>
) : null} ) : null}
{!finished && items[index] ? (
<ReportQuestionButton
deck={items[index].deck}
activity={items[index].activity}
onSkip={() => {
setSkipped((current) => [...current, items[index].activity.id]);
goTo(index + 1);
}}
/>
) : null}
</div> </div>
<div className="flex items-center justify-between gap-2 text-[11px] font-semibold text-[var(--text-muted)]"> <div className="flex items-center justify-between gap-2 text-[11px] font-semibold text-[var(--text-muted)]">
<span className="min-w-0 truncate">{title}</span> <span className="min-w-0 truncate">{title}</span>
@ -217,7 +207,26 @@ export function SessionFeed({
isLast={itemIndex === total - 1} isLast={itemIndex === total - 1}
nextLabel={itemIndex === total - 1 ? t("review.seeResults") : undefined} nextLabel={itemIndex === total - 1 ? t("review.seeResults") : undefined}
/> />
<AiDisclaimer /> {/* Under the card: disclaimer centred, report control in the
bottom-right corner (it used to sit in the top chrome). */}
{!finished && index === itemIndex ? (
<div className="grid grid-cols-[2.75rem_1fr_2.75rem] items-center gap-3">
<span aria-hidden />
<AiDisclaimer />
<div className="flex justify-end">
<ReportQuestionButton
deck={item.deck}
activity={item.activity}
onSkip={() => {
setSkipped((current) => [...current, item.activity.id]);
goTo(itemIndex + 1);
}}
/>
</div>
</div>
) : (
<AiDisclaimer />
)}
</div> </div>
</section> </section>
))} ))}

View file

@ -279,16 +279,6 @@ export function StudyFeed({ deck }: { deck: Deck }) {
<BoltIcon className="size-3.5" /> <BoltIcon className="size-3.5" />
{xp} {xp}
</span> </span>
{!finished && queue[index] ? (
<ReportQuestionButton
deck={currentDeck}
activity={queue[index]}
onSkip={() => {
setSkipped((current) => [...current, queue[index].id]);
goTo(index + 1);
}}
/>
) : null}
</div> </div>
</div> </div>
<div className="flex items-center justify-between gap-2 text-[11px] font-semibold text-[var(--text-muted)]"> <div className="flex items-center justify-between gap-2 text-[11px] font-semibold text-[var(--text-muted)]">
@ -327,7 +317,26 @@ export function StudyFeed({ deck }: { deck: Deck }) {
onAnswered={(correct, meta) => handleAnswered(activity, correct, meta)} onAnswered={(correct, meta) => handleAnswered(activity, correct, meta)}
onNext={() => goTo(activityIndex + 1)} onNext={() => goTo(activityIndex + 1)}
/> />
<AiDisclaimer /> {/* Under the card: disclaimer centred, report control in the
bottom-right corner (it used to sit in the top chrome). */}
{!finished && index === activityIndex ? (
<div className="grid grid-cols-[2.75rem_1fr_2.75rem] items-center gap-3">
<span aria-hidden />
<AiDisclaimer />
<div className="flex justify-end">
<ReportQuestionButton
deck={currentDeck}
activity={activity}
onSkip={() => {
setSkipped((current) => [...current, activity.id]);
goTo(activityIndex + 1);
}}
/>
</div>
</div>
) : (
<AiDisclaimer />
)}
</div> </div>
</section> </section>
))} ))}

View file

@ -9,7 +9,7 @@
* *
* Bumping CACHE_VERSION is how a new deploy invalidates old assets. * 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 SHELL_CACHE = `${CACHE_VERSION}-shell`;
const ASSET_CACHE = `${CACHE_VERSION}-assets`; const ASSET_CACHE = `${CACHE_VERSION}-assets`;

View file

@ -142,7 +142,37 @@ const browser = await launchBrowser();
await page.waitForTimeout(1000); await page.waitForTimeout(1000);
const report = page.getByRole("button", { name: "Report question" }); 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 report.click();
await page.waitForTimeout(700); await page.waitForTimeout(700);
check("report: toast with Undo", await page.getByText("Question reported").isVisible()); check("report: toast with Undo", await page.getByText("Question reported").isVisible());