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:
parent
43dd4e4ec9
commit
33c0ad1ed0
6 changed files with 88 additions and 37 deletions
22
README.md
22
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
|
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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -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`;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue