Fix the clipped answer field + the doubled AI note; ordering only where it fits

UI bug from a phone screenshot (short answer, Czech):
- The textarea was flex-shrunk inside the slide's column (58px instead of
  ~82px), clipping the typed answer mid-line. Every direct child of a
  study/review/guide slide is now shrink-0 and the slide scrolls instead.
- Two AI disclaimers were visible at once: the activity's own after the
  grading box plus the feed's row. The activity-level copy is gone; the
  feed/guide row (which also carries the report control) is the single one.
  The phase-2 check now asserts the note sits below the graded feedback.
- The mobile suite's backdrop-tap check is deterministic too (the spring
  animation made the measured box mid-flight).

Ordering activities: the deck prompt now allows them only when the notes
hold a real sequence (chronological events, process stages, procedure steps,
a lifecycle, a ranked scale) and points at matching / multiple choice /
true-false when they do not - "sort these" was landing on decks with no
order at all.

Version 0.2.4, SW -> freezmo-v57.
This commit is contained in:
Martin Dvořák 2026-10-13 07:27:39 +00:00
commit 9b0354fb11
9 changed files with 34 additions and 15 deletions

View file

@ -1398,6 +1398,21 @@ Things I chose while building, with the reasoning:
phase-1 suite (43 checks, including the real route mapping against a stub Ollama returning a phase-1 suite (43 checks, including the real route mapping against a stub Ollama returning a
monthly-limit body, a two-device sync round-trip, and the generation guards below). monthly-limit body, a two-device sync round-trip, and the generation guards below).
65. **Slide content never flex-shrinks, and the AI note appears once.** A long typed answer in a
short-answer activity shrank the textarea inside the slide's flex column (58px instead of its
~82px), clipping the user's text mid-line on a phone; every direct child of a study/review/guide
slide is now `shrink-0` and the slide scrolls instead. The same screenshot showed **two** AI
disclaimers — the activity's own after the grading box plus the feed's row. The activity-level
copy is gone: the feed/guide row (which also carries the report control) is the single one, and
the phase-2 suite asserts the note sits below the graded feedback rather than right after it.
64. **Ordering cards only where an order exists.** "Sort these" was landing on decks whose notes
have no sequence at all (a list of works, authors or terms), which the learner cannot reason
about. The deck prompt now allows `ordering` only for real sequences — chronological events, the
stages of a process, steps of a procedure, a lifecycle, a ranked scale — and points at matching,
multiple choice or true/false when there is none, with one or two ordering cards per deck as the
usual ceiling.
63. **Frutiger Aero is the third theme.** Settings → Theme offers Light, Dark and Frutiger Aero. 63. **Frutiger Aero is the third theme.** Settings → Theme offers Light, Dark and Frutiger Aero.
Aero is not a dark/light variant — it replaces the palette and the scene: the wallpaper is Aero is not a dark/light variant — it replaces the palette and the scene: the wallpaper is
*“The S Aesthetic That You Definitely Recognize Frutiger Aero”* via WallpaperSafari *“The S Aesthetic That You Definitely Recognize Frutiger Aero”* via WallpaperSafari

View file

@ -202,7 +202,7 @@ export function SessionFeed({
aria-label={t("feed.activityOf", { n: itemIndex + 1, total })} aria-label={t("feed.activityOf", { n: itemIndex + 1, total })}
className="flex h-full snap-start snap-always items-stretch justify-center overflow-y-auto overscroll-y-contain" className="flex h-full snap-start snap-always items-stretch justify-center overflow-y-auto overscroll-y-contain"
> >
<div className="mx-auto flex min-h-full w-full max-w-xl flex-col gap-4 px-4 pb-24 pt-24 [justify-content:safe_center]"> <div className="mx-auto flex min-h-full w-full max-w-xl flex-col gap-4 px-4 pb-24 pt-24 [justify-content:safe_center] [&>*]:shrink-0">
<ActivityRenderer <ActivityRenderer
activity={item.activity} activity={item.activity}
active={index === itemIndex} active={index === itemIndex}
@ -242,7 +242,7 @@ export function SessionFeed({
aria-label={t("review.results")} aria-label={t("review.results")}
className="flex h-full snap-start snap-always items-stretch justify-center overflow-y-auto overscroll-y-contain" className="flex h-full snap-start snap-always items-stretch justify-center overflow-y-auto overscroll-y-contain"
> >
<div className="mx-auto flex min-h-full w-full max-w-xl flex-col gap-4 px-4 pb-24 pt-24 [justify-content:safe_center]"> <div className="mx-auto flex min-h-full w-full max-w-xl flex-col gap-4 px-4 pb-24 pt-24 [justify-content:safe_center] [&>*]:shrink-0">
{finished {finished
? ( ? (
<> <>

View file

@ -315,7 +315,7 @@ export function StudyFeed({ deck }: { deck: Deck }) {
aria-label={t("feed.activityOf", { n: activityIndex + 1, total })} aria-label={t("feed.activityOf", { n: activityIndex + 1, total })}
className="flex h-full snap-start snap-always items-stretch justify-center overflow-y-auto overscroll-y-contain" className="flex h-full snap-start snap-always items-stretch justify-center overflow-y-auto overscroll-y-contain"
> >
<div className="mx-auto flex min-h-full w-full max-w-xl flex-col gap-4 px-4 pb-24 pt-24 [justify-content:safe_center]"> <div className="mx-auto flex min-h-full w-full max-w-xl flex-col gap-4 px-4 pb-24 pt-24 [justify-content:safe_center] [&>*]:shrink-0">
<ActivityRenderer <ActivityRenderer
activity={activity} activity={activity}
active={index === activityIndex} active={index === activityIndex}
@ -353,7 +353,7 @@ export function StudyFeed({ deck }: { deck: Deck }) {
aria-label={t("feed.results")} aria-label={t("feed.results")}
className="h-full snap-start snap-always overflow-y-auto overscroll-y-contain" className="h-full snap-start snap-always overflow-y-auto overscroll-y-contain"
> >
<div className="mx-auto flex min-h-full w-full max-w-xl flex-col gap-4 px-4 pb-24 pt-24 [justify-content:safe_center]"> <div className="mx-auto flex min-h-full w-full max-w-xl flex-col gap-4 px-4 pb-24 pt-24 [justify-content:safe_center] [&>*]:shrink-0">
<ResultsScreen <ResultsScreen
deck={currentDeck} deck={currentDeck}
xp={xp} xp={xp}

View file

@ -7,7 +7,6 @@ import { useI18n } from "@/lib/i18n";
import { modelErrorCopy } from "@/lib/model-errors"; import { modelErrorCopy } from "@/lib/model-errors";
import type { Activity } from "@/lib/schemas"; import type { Activity } from "@/lib/schemas";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { AiDisclaimer } from "@/components/AiDisclaimer";
import { Button, Spinner } from "@/components/ui"; import { Button, Spinner } from "@/components/ui";
import { ModelErrorActions } from "@/components/ModelErrorActions"; import { ModelErrorActions } from "@/components/ModelErrorActions";
@ -186,8 +185,6 @@ export function ShortAnswer({ activity, active, language, onAnswered, onNext, is
) : null} ) : null}
</div> </div>
<AiDisclaimer />
<FeedbackPanel <FeedbackPanel
correct={state.correct} correct={state.correct}
explanation={activity.explanation} explanation={activity.explanation}

View file

@ -187,7 +187,7 @@ export function HomeworkGuide({
aria-label={t("homework.guide.stepOf", { n: stepIndex + 1, total: steps.length })} aria-label={t("homework.guide.stepOf", { n: stepIndex + 1, total: steps.length })}
className="flex h-full snap-start snap-always items-stretch justify-center overflow-y-auto overscroll-y-contain" className="flex h-full snap-start snap-always items-stretch justify-center overflow-y-auto overscroll-y-contain"
> >
<div className="mx-auto flex min-h-full w-full max-w-xl flex-col gap-4 px-4 pb-24 pt-24 [justify-content:safe_center]"> <div className="mx-auto flex min-h-full w-full max-w-xl flex-col gap-4 px-4 pb-24 pt-24 [justify-content:safe_center] [&>*]:shrink-0">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="tint-sky inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-semibold ring-1 ring-white/50 backdrop-blur-sm"> <span className="tint-sky inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-semibold ring-1 ring-white/50 backdrop-blur-sm">
{t("homework.guide.stepOf", { n: stepIndex + 1, total: steps.length })} {t("homework.guide.stepOf", { n: stepIndex + 1, total: steps.length })}
@ -291,7 +291,7 @@ export function HomeworkGuide({
aria-label={t("homework.guide.solution")} aria-label={t("homework.guide.solution")}
className="h-full snap-start snap-always overflow-y-auto overscroll-y-contain" className="h-full snap-start snap-always overflow-y-auto overscroll-y-contain"
> >
<div className="mx-auto flex min-h-full w-full max-w-xl flex-col gap-4 px-4 pb-24 pt-24 [justify-content:safe_center]"> <div className="mx-auto flex min-h-full w-full max-w-xl flex-col gap-4 px-4 pb-24 pt-24 [justify-content:safe_center] [&>*]:shrink-0">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="tint-mint inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-semibold ring-1 ring-white/50 backdrop-blur-sm"> <span className="tint-mint inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-semibold ring-1 ring-white/50 backdrop-blur-sm">
{t("homework.guide.solution")} {t("homework.guide.solution")}

View file

@ -86,6 +86,11 @@ export const ACTIVITY_JSON_SPEC = `Each activity is one JSON object with a "type
{ "type": "matching", "topic": "short label", "instruction": "Match each term to its definition.", "pairs": [{ "term": "Osmosis", "definition": "diffusion of water across a membrane" }], "explanation": "..." } { "type": "matching", "topic": "short label", "instruction": "Match each term to its definition.", "pairs": [{ "term": "Osmosis", "definition": "diffusion of water across a membrane" }], "explanation": "..." }
6. ordering - 3 to 7 items given in the CORRECT order (the app shuffles them) 6. ordering - 3 to 7 items given in the CORRECT order (the app shuffles them)
Use it ONLY when the notes contain a real sequence the learner can reason about: historical or
chronological events, the stages of a process, steps of a procedure, a lifecycle, a ranked scale.
If the notes only list unrelated things (works, authors, terms, places, values), do NOT invent an
order for them — use matching, multiple choice or true/false instead. One or two ordering cards
per deck is usually plenty.
Items must NEVER contain dates, years, clock times, step numbers or ordinals ("first", "step 2") — Items must NEVER contain dates, years, clock times, step numbers or ordinals ("first", "step 2") —
the learner has to work out the order from what each item says, not read it off. the learner has to work out the order from what each item says, not read it off.
{ "type": "ordering", "topic": "short label", "instruction": "Put the events in the order they happened.", "items": ["The Allies land in Normandy", "Paris is liberated", "Germany surrenders"], "explanation": "..." } { "type": "ordering", "topic": "short label", "instruction": "Put the events in the order they happened.", "items": ["The Allies land in Normandy", "Paris is liberated", "Germany surrenders"], "explanation": "..." }

View file

@ -1,6 +1,6 @@
{ {
"name": "freezmo", "name": "freezmo",
"version": "0.2.2", "version": "0.2.4",
"private": true, "private": true,
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"scripts": { "scripts": {

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-v56"; const CACHE_VERSION = "freezmo-v57";
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,13 +142,15 @@ const browser = await launchBrowser();
await page.getByRole("button", { name: "Submit answer" }).click(); await page.getByRole("button", { name: "Submit answer" }).click();
await page.waitForTimeout(800); await page.waitForTimeout(800);
check("grading: AI feedback rendered", await page.getByText("Nice — scattering does the work.").isVisible()); check("grading: AI feedback rendered", await page.getByText("Nice — scattering does the work.").isVisible());
// Scope it to the placement: the disclaimer directly under the graded box. // The disclaimer sits below the graded feedback (the feed's row carries it;
// the activity no longer duplicates it right after the grading box).
const feedbackDisclaimer = await page.evaluate(() => { const feedbackDisclaimer = await page.evaluate(() => {
const feedback = [...document.querySelectorAll("p")].find((el) => el.textContent.includes("Nice — scattering does the work.")); const feedback = [...document.querySelectorAll("p")].find((el) => el.textContent.includes("Nice — scattering does the work."));
const next = feedback?.closest("div")?.nextElementSibling; const note = [...document.querySelectorAll("p")].find((el) => el.textContent.includes("Freezmo uses AI"));
return Boolean(next?.textContent?.includes("Freezmo uses AI")); if (!feedback || !note) return false;
return note.getBoundingClientRect().top > feedback.getBoundingClientRect().bottom - 4;
}); });
check("grading: AI disclaimer under the feedback", feedbackDisclaimer); check("grading: AI disclaimer below the feedback", feedbackDisclaimer);
await context.close(); await context.close();
} }