Accessibility pass, report control for homework exercises, clearer rail actions

- axe is clean across every screen in both themes, phone and desktop. Fixed:
  the hidden file inputs (aria-hidden + tabIndex -1 — they are opened by
  labelled buttons, so they should not be in the accessibility tree), the
  heading order (deck/homework/smart-review cards are h2 under the page h1;
  LearningSection takes a level so it stays h2 on the library and h3 inside
  the settings dialog) and landmarks around the study and review feeds.
- tests/contrast.mjs (new diagnostic): samples rendered pixels, because axe
  cannot judge text on blurred glass — it reports those nodes as incomplete.
  It skips disabled controls (1.4.3 exempts them) and content behind an open
  sheet. Result: no below-minimum text on any scanned screen.
- The report control exists in the homework guide too: under each "Try it
  yourself" exercise, labelled "Report exercise", without a skip (there is no
  queue to skip). lib/question-reports now takes deckId/deckTitle directly.
- The rail's two creation actions are buttons now (.nav-action) rather than
  quiet nav rows — once the library is full they were the only way to start
  something on desktop and read as just more navigation. "Make my homework"
  is "New homework" wherever it is an action (rail, empty state, share
  chooser, manifest shortcut); the page title and mode switch keep their
  wording.

Version 0.1.6 (three task bumps), SW -> freezmo-v52.
This commit is contained in:
Martin Dvořák 2026-10-12 21:15:56 +00:00
commit df34337b4a
27 changed files with 337 additions and 75 deletions

View file

@ -72,7 +72,10 @@ export default function DeckPage() {
return (
<ApiKeyGate>
{/* The feed is a fixed overlay, but its content still needs a landmark. */}
<main>
<StudyFeed key={deck.id} deck={deck} />
</main>
</ApiKeyGate>
);
}

View file

@ -647,6 +647,28 @@ body:has(.overlay-open) :is(.app-header, .segmented, .tabbar) {
background: var(--hover);
color: var(--text);
}
/* The rail's two creation actions ("New deck", "New homework") are buttons,
not nav rows: once the library is full the empty-state CTAs are long gone,
and on desktop the rail is the only place left to start something. */
.nav-action {
display: flex;
align-items: center;
gap: 0.625rem;
width: 100%;
border-radius: 0.75rem;
padding: 0.5rem 0.625rem;
font-size: 13.5px;
font-weight: 600;
color: var(--text);
background: var(--surface-strong);
border: 1px solid var(--glass-border);
transition: background-color 160ms ease, border-color 160ms ease;
}
.nav-action:hover {
background: var(--hover);
border-color: var(--nav-active-border);
}
.nav-row[aria-current="page"],
.nav-row[aria-pressed="true"] {
background: var(--nav-active-bg);

View file

@ -55,7 +55,7 @@ export default function manifest(): MetadataRoute.Manifest {
],
shortcuts: [
{ name: "New deck", short_name: "New", url: "/create" },
{ name: "Make my homework", short_name: "Homework", url: "/homework" },
{ name: "New homework", short_name: "Homework", url: "/homework" },
{ name: "Library", short_name: "Library", url: "/" },
],
/* Share text and links from other apps (GET). Files are POSTed to the

View file

@ -78,6 +78,8 @@ export function DataSection() {
<input
ref={fileRef}
type="file"
aria-hidden="true"
tabIndex={-1}
accept="application/json,.json"
className="hidden"
onChange={(event) => void pick(event.target.files?.[0])}

View file

@ -182,12 +182,12 @@ export function DeckCard({
</Button>
</div>
) : (
<h3
<h2
className="line-clamp-2 text-[15px] font-semibold leading-snug tracking-[-0.01em] text-[var(--text)]"
title={deck.title}
>
{truncate(deck.title, 90)}
</h3>
</h2>
)}
{/* Phones: one compact meta line. */}
<p className="mt-1 text-[12.5px] text-[var(--text-subtle)] sm:hidden">

View file

@ -62,12 +62,12 @@ export function HomeworkCard({
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<h3
<h2
className="line-clamp-2 text-[15px] font-semibold leading-snug tracking-[-0.01em] text-[var(--text)]"
title={record.title}
>
{truncate(record.title, 90)}
</h3>
</h2>
<p className="mt-1 flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-[12.5px] text-[var(--text-subtle)]">
<span className="inline-flex items-center gap-1">
{record.mode === "make" ? <BoltIcon className="size-3.5" /> : <PlayIcon className="size-3.5" />}

View file

@ -25,7 +25,12 @@ function urlBase64ToUint8Array(base64: string): Uint8Array {
* the server has VAPID keys configured. Without them the reminder block is
* hidden completely, exactly as if the feature did not exist.
*/
export function LearningSection() {
/**
* `level` keeps the heading order right in both places it is used: h2 on the
* library page (under its h1) and h3 inside the settings dialog (under its h2
* title).
*/
export function LearningSection({ level = 2 }: { level?: 2 | 3 } = {}) {
const { t } = useI18n();
const dailyGoal = useSyncExternalStore(
settingsStore.subscribe,
@ -154,7 +159,11 @@ export function LearningSection() {
return (
<section className="mb-4 space-y-3 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
{level === 3 ? (
<h3 className="text-sm font-semibold text-frost-950 dark:text-frost-50">{t("settings.learning")}</h3>
) : (
<h2 className="text-sm font-semibold text-frost-950 dark:text-frost-50">{t("settings.learning")}</h2>
)}
<div className="space-y-1.5">
<p className="text-[12px] font-semibold text-[var(--text)]">{t("settings.dailyGoal")}</p>

View file

@ -220,6 +220,8 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
<input
ref={importRef}
type="file"
aria-hidden="true"
tabIndex={-1}
accept="application/json,.json"
className="sr-only"
onChange={(event) => {

View file

@ -3,22 +3,31 @@
import { haptic } from "@/lib/haptics";
import { useI18n } from "@/lib/i18n";
import { reportQuestion, unreportQuestion } from "@/lib/question-reports";
import type { Activity, Deck } from "@/lib/schemas";
import type { Activity } from "@/lib/schemas";
import { showToast } from "@/lib/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.
* exercise): records the question, sends the operator a copy and — in the
* study feeds — skips past it. Undo lives in the toast. 44px, because it is a
* thumb target.
*
* `deckId` is a deck id in the feeds and the homework record's id for a
* homework exercise; `onSkip` is omitted where there is no queue to skip.
*/
export function ReportQuestionButton({
deck,
deckId,
deckTitle,
activity,
onSkip,
labelKey = "feed.report",
}: {
deck: Deck;
deckId: string;
deckTitle?: string;
activity: Activity;
onSkip: () => void;
onSkip?: () => void;
/** i18n key for the accessible name ("Report question" / "Report exercise"). */
labelKey?: string;
}) {
const { t } = useI18n();
@ -27,16 +36,16 @@ export function ReportQuestionButton({
type="button"
onClick={() => {
haptic("tap");
reportQuestion({ deck, activity });
reportQuestion({ deckId, deckTitle, activity });
showToast({
message: t("feed.reported"),
actionLabel: t("toast.undo"),
onAction: () => unreportQuestion(deck.id, activity.id),
onAction: () => unreportQuestion(deckId, activity.id),
});
onSkip();
onSkip?.();
}}
aria-label={t("feed.report")}
title={t("feed.report")}
aria-label={t(labelKey)}
title={t(labelKey)}
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>

View file

@ -81,6 +81,8 @@ export function ReviewClient({ mistakes, deckId }: { mistakes: boolean; deckId?:
const sessionItems: SessionItem[] = session.map(({ deck, activity }) => ({ deck, activity }));
return (
// The feed is a fixed overlay, but its content still needs a landmark.
<main>
<SessionFeed
title={title}
items={sessionItems}
@ -112,5 +114,6 @@ export function ReviewClient({ mistakes, deckId }: { mistakes: boolean; deckId?:
</div>
)}
/>
</main>
);
}

View file

@ -221,7 +221,8 @@ export function SessionFeed({
<AiDisclaimer />
<div className="flex justify-end">
<ReportQuestionButton
deck={item.deck}
deckId={item.deck.id}
deckTitle={item.deck.title}
activity={item.activity}
onSkip={() => {
setSkipped((current) => [...current, item.activity.id]);

View file

@ -407,7 +407,7 @@ export function SettingsDialog({
</section>
{/* ------------------------------------------- data & sync */}
<LearningSection />
<LearningSection level={3} />
<DataSection />
<SyncSection />
@ -746,7 +746,7 @@ export function SettingsDialog({
</section>
{/* ------------------------------------------- data & sync */}
<LearningSection />
<LearningSection level={3} />
<DataSection />
<SyncSection />

View file

@ -137,23 +137,24 @@ function NavContent({ collapsed = false }: { collapsed?: boolean }) {
{!collapsed ? <span className="flex-1 truncate text-left">{t("sidebar.collapse")}</span> : null}
</button>
{/* new deck: a quiet row, not a competing primary button ---------- */}
{/* new deck: a button, not a nav row — it stays visible when the
library is already full (see .nav-action in globals.css) --------- */}
<Link
href="/create"
className={cn("nav-row mb-3", collapsed && "justify-center px-2")}
className={cn("nav-action mb-2.5", collapsed && "justify-center px-2")}
title={t("common.newDeck")}
>
<PlusIcon className="size-4 shrink-0" />
<PlusIcon className="size-4 shrink-0 text-accent-600 dark:text-accent-200" />
{!collapsed ? <span className="flex-1 truncate text-left">{t("common.newDeck")}</span> : null}
</Link>
{/* make my homework: same quiet row --------------------------------- */}
{/* new homework: the same button ------------------------------------ */}
<Link
href="/homework"
className={cn("nav-row mb-3", collapsed && "justify-center px-2")}
className={cn("nav-action mb-3", collapsed && "justify-center px-2")}
title={t("homework.nav")}
>
<SparklesIcon className="size-4 shrink-0" />
<SparklesIcon className="size-4 shrink-0 text-accent-600 dark:text-accent-200" />
{!collapsed ? <span className="flex-1 truncate text-left">{t("homework.nav")}</span> : null}
</Link>

View file

@ -332,7 +332,8 @@ export function StudyFeed({ deck }: { deck: Deck }) {
<AiDisclaimer />
<div className="flex justify-end">
<ReportQuestionButton
deck={currentDeck}
deckId={currentDeck.id}
deckTitle={currentDeck.title}
activity={activity}
onSkip={() => {
setSkipped((current) => [...current, activity.id]);

View file

@ -650,6 +650,8 @@ export function UploadScreen() {
<input
ref={fileInputRef}
type="file"
aria-hidden="true"
tabIndex={-1}
accept={`${ACCEPTED_TYPES.join(",")},${DOCUMENT_ACCEPT_ATTR}`}
multiple
className="sr-only"
@ -663,6 +665,8 @@ export function UploadScreen() {
<input
ref={cameraInputRef}
type="file"
aria-hidden="true"
tabIndex={-1}
accept="image/*"
capture="environment"
className="sr-only"
@ -1128,6 +1132,8 @@ export function UploadScreen() {
<input
ref={fileInputRef}
type="file"
aria-hidden="true"
tabIndex={-1}
accept={`${ACCEPTED_TYPES.join(",")},${DOCUMENT_ACCEPT_ATTR}`}
multiple
className="sr-only"
@ -1139,6 +1145,8 @@ export function UploadScreen() {
<input
ref={cameraInputRef}
type="file"
aria-hidden="true"
tabIndex={-1}
accept="image/*"
capture="environment"
className="sr-only"

View file

@ -105,6 +105,8 @@ export function Composer({
<input
ref={fileInput}
type="file"
aria-hidden="true"
tabIndex={-1}
accept="image/*,application/pdf,.docx,.pptx,.odt,.txt,.md,.csv"
className="sr-only"
onChange={(event) => {

View file

@ -6,6 +6,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ActivityRenderer } from "@/components/activities";
import { PromptCard, QuestionText } from "@/components/activities/shared";
import { AiDisclaimer } from "@/components/AiDisclaimer";
import { ReportQuestionButton } from "@/components/ReportQuestionButton";
import { XIcon } from "@/components/icons";
import { Button, ProgressBar } from "@/components/ui";
import { AskButton } from "@/components/chat/AskButton";
@ -246,7 +247,7 @@ export function HomeworkGuide({
{/* checkpoint or a plain next button */}
{step.checkpoint ? (
<div className="space-y-2">
<div className="space-y-3">
<p className="text-[11px] font-semibold uppercase tracking-wider text-[var(--text-subtle)]">
{t("homework.guide.tryIt")}
</p>
@ -263,6 +264,16 @@ export function HomeworkGuide({
}}
onNext={() => goTo(stepIndex + 1)}
/>
{/* Same report control as the study feeds, under the
exercise (the homework record is the "deck" here). */}
<div className="flex justify-end">
<ReportQuestionButton
deckId={record.id}
deckTitle={record.title}
activity={step.checkpoint}
labelKey="homework.report"
/>
</div>
</div>
) : (
<Button className="touch-target self-start" onClick={() => goTo(stepIndex + 1)}>

View file

@ -246,6 +246,8 @@ export function HomeworkInput({
<input
ref={solutionFileRef}
type="file"
aria-hidden="true"
tabIndex={-1}
accept={`${ACCEPTED_TYPES.join(",")},${DOCUMENT_ACCEPT_ATTR}`}
multiple
className="sr-only"
@ -257,6 +259,8 @@ export function HomeworkInput({
<input
ref={solutionCameraRef}
type="file"
aria-hidden="true"
tabIndex={-1}
accept="image/*"
capture="environment"
className="sr-only"
@ -378,6 +382,8 @@ export function HomeworkInput({
<input
ref={fileInputRef}
type="file"
aria-hidden="true"
tabIndex={-1}
accept={`${ACCEPTED_TYPES.join(",")},${DOCUMENT_ACCEPT_ATTR}`}
multiple
className="sr-only"
@ -391,6 +397,8 @@ export function HomeworkInput({
<input
ref={cameraInputRef}
type="file"
aria-hidden="true"
tabIndex={-1}
accept="image/*"
capture="environment"
className="sr-only"
@ -756,6 +764,8 @@ export function HomeworkInput({
<input
ref={fileInputRef}
type="file"
aria-hidden="true"
tabIndex={-1}
accept={`${ACCEPTED_TYPES.join(",")},${DOCUMENT_ACCEPT_ATTR}`}
multiple
className="sr-only"
@ -767,6 +777,8 @@ export function HomeworkInput({
<input
ref={cameraInputRef}
type="file"
aria-hidden="true"
tabIndex={-1}
accept="image/*"
capture="environment"
className="sr-only"

View file

@ -270,7 +270,7 @@ const en: Record<string, string> = {
"health.createKey.end": "instead and set",
/* make my homework ------------------------------------------------- */
"homework.nav": "Make my homework",
"homework.nav": "New homework",
"wizard.mode.deck": "Study deck",
"wizard.mode.homework": "Make my homework",
"wizard.mode.aria": "Study deck or homework",
@ -317,6 +317,7 @@ const en: Record<string, string> = {
"homework.guide.stepOf": "Step {n} of {total}",
"homework.guide.close": "Close the guide",
"homework.guide.tryIt": "Try it yourself",
"homework.report": "Report exercise",
"homework.guide.hint": "Show a hint",
"homework.guide.hintMore": "Another hint",
"homework.guide.hintAnswer": "Show the answer",
@ -344,7 +345,7 @@ const en: Record<string, string> = {
/* quick actions (library empty state + /create switcher) ----------- */
"emptyStateCreateDeck": "Create study deck",
"emptyStateHomework": "Make my homework",
"emptyStateHomework": "New homework",
/* library --------------------------------------------------------- */
"library.title": "Your icebox",
@ -1212,7 +1213,7 @@ const cs: Record<string, string> = {
"health.createKey.end": "a nastavit",
/* make my homework ------------------------------------------------- */
"homework.nav": "Udělej mi úkol",
"homework.nav": "Nový úkol",
"wizard.mode.deck": "Studijní sada",
"wizard.mode.homework": "Udělej mi úkol",
"wizard.mode.aria": "Studijní sada nebo úkol",
@ -1259,6 +1260,7 @@ const cs: Record<string, string> = {
"homework.guide.stepOf": "Krok {n}/{total}",
"homework.guide.close": "Zavřít návod",
"homework.guide.tryIt": "Zkus si to",
"homework.report": "Nahlásit cvičení",
"homework.guide.hint": "Ukaž nápovědu",
"homework.guide.hintMore": "Další nápověda",
"homework.guide.hintAnswer": "Ukaž odpověď",
@ -1286,7 +1288,7 @@ const cs: Record<string, string> = {
/* quick actions (library empty state + /create switcher) ----------- */
"emptyStateCreateDeck": "Vytvořit studijní balíček",
"emptyStateHomework": "Udělej mi úkol",
"emptyStateHomework": "Nový úkol",
/* library --------------------------------------------------------- */
"library.title": "Tvoje mraznička",

View file

@ -132,10 +132,15 @@ export function withoutReported(deckId: string, activities: Activity[]): Activit
}
/** Adds (or refreshes) a report and sends a copy to the server, best effort. */
export function reportQuestion(input: { deck: { id: string; title: string }; activity: Activity }): QuestionReport {
export function reportQuestion(input: {
/** A deck id, or the homework record's id for a homework exercise. */
deckId: string;
deckTitle?: string;
activity: Activity;
}): QuestionReport {
const entry: QuestionReport = {
deckId: input.deck.id,
deckTitle: input.deck.title,
deckId: input.deckId,
deckTitle: input.deckTitle,
activityId: input.activity.id,
type: input.activity.type,
question: activityText(input.activity).slice(0, 600),

View file

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

View file

@ -9,7 +9,7 @@
*
* Bumping CACHE_VERSION is how a new deploy invalidates old assets.
*/
const CACHE_VERSION = "freezmo-v51";
const CACHE_VERSION = "freezmo-v52";
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const ASSET_CACHE = `${CACHE_VERSION}-assets`;

169
tests/contrast.mjs Normal file
View file

@ -0,0 +1,169 @@
/**
* Contrast diagnostic (NOT part of `npm run test:e2e`): axe cannot judge text
* on a blurred glass surface — it reports those nodes as "incomplete" — so
* this samples the **rendered pixels** instead.
*
* For every text element it takes a screenshot of that element's box, finds
* the dominant colour in it (the background: glyphs are the minority of the
* pixels) and compares it with the text colour from the computed style using
* the WCAG relative-luminance formula. Large text (>= 24px, or >= 18.66px
* bold) is held to 3:1, everything else to 4.5:1.
*
* node tests/contrast.mjs # every screen, both themes
* node tests/contrast.mjs study # filter by screen name
*
* It always exits 0 — the numbers are for tuning the palette.
*/
import { BASE, DECK, launchBrowser, makePage, seed } from "./lib.mjs";
const filter = process.argv[2] ?? "";
const browser = await launchBrowser();
const failures = [];
/** WCAG relative luminance of an [r, g, b] triple (0-255). */
const LUMINANCE = `
const luminance = ([r, g, b]) => {
const channel = (value) => {
const v = value / 255;
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
};
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
};
const contrast = (a, b) => {
const [l1, l2] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (l1 + 0.05) / (l2 + 0.05);
};
`;
const screens = [
["library", "/", null],
["study", "/deck/seed_deck", null],
["create", "/create", null],
["homework", "/homework", null],
["chat", "/chat", null],
["stats", "/stats", null],
["exam", "/exam", null],
["settings", "/", "settings"],
];
for (const [label, path, mode] of screens) {
if (filter && !label.includes(filter)) continue;
for (const theme of ["dark", "light"]) {
const { context, page } = await makePage(browser, { width: 390, height: 844, deviceScaleFactor: 1 });
await seed(page, { decks: [DECK], theme });
await page.goto(`${BASE}${path}`, { waitUntil: "networkidle" });
await page.waitForTimeout(1000);
if (mode === "settings") {
await page.locator("nav.tabbar").getByRole("button").click();
await page.getByRole("dialog").waitFor({ timeout: 6000 });
await page.waitForTimeout(700);
}
// Every text-bearing element with a visible box, small enough to sample.
// Content behind an open sheet is dimmed on purpose and not readable, and
// disabled controls are exempt from 1.4.3 — both are skipped.
const targets = await page.evaluate(() => {
const out = [];
const dialog = document.querySelector('[role="dialog"]');
const root = dialog ?? document;
for (const element of root.querySelectorAll("p, span, li, h1, h2, h3, button, a, label, dt, dd")) {
if (element.closest("[disabled]") || element.closest('[aria-disabled="true"]')) continue;
const text = (element.textContent ?? "").trim();
if (text.length < 2) continue;
if (element.children.length > 0 && [...element.children].some((child) => (child.textContent ?? "").trim().length > 0)) continue;
const style = getComputedStyle(element);
if (style.visibility === "hidden" || style.display === "none" || Number(style.opacity) < 0.5) continue;
const box = element.getBoundingClientRect();
if (box.width < 8 || box.height < 8 || box.bottom < 0 || box.top > window.innerHeight) continue;
out.push({
id: out.length,
text: text.slice(0, 40),
color: style.color,
fontSize: Number.parseFloat(style.fontSize),
fontWeight: Number(style.fontWeight),
box: { x: box.x, y: box.y, width: box.width, height: box.height },
});
}
return out;
});
const screenshot = await page.screenshot({ type: "png", scale: "css" });
const dataUrl = `data:image/png;base64,${screenshot.toString("base64")}`;
const report = await page.evaluate(
async ({ targets, dataUrl, luminanceSource }) => {
const image = await new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = dataUrl;
});
const canvas = document.createElement("canvas");
canvas.width = image.width;
canvas.height = image.height;
const ctx = canvas.getContext("2d");
ctx.drawImage(image, 0, 0);
const full = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
const { contrast } = new Function(`${luminanceSource} return { contrast };`)();
const parseColor = (value) => {
const match = /rgba?\(([^)]+)\)/.exec(value);
if (!match) return null;
const parts = match[1].split(",").map((part) => Number.parseFloat(part));
return [parts[0], parts[1], parts[2], parts[3] ?? 1];
};
const results = [];
for (const target of targets) {
const text = parseColor(target.color);
if (!text) continue;
// Sample a slightly larger box and ignore pixels close to the text
// colour: those are the glyphs themselves, not the background.
const x0 = Math.max(0, Math.floor(target.box.x) - 2);
const y0 = Math.max(0, Math.floor(target.box.y) - 2);
const x1 = Math.min(canvas.width, Math.ceil(target.box.x + target.box.width) + 2);
const y1 = Math.min(canvas.height, Math.ceil(target.box.y + target.box.height) + 2);
if (x1 - x0 < 4 || y1 - y0 < 4) continue;
const counts = new Map();
let sampled = 0;
for (let y = y0; y < y1; y += 1) {
for (let x = x0; x < x1; x += 1) {
const index = (y * canvas.width + x) * 4;
const pixel = [full[index], full[index + 1], full[index + 2]];
const distance = Math.max(
Math.abs(pixel[0] - text[0]),
Math.abs(pixel[1] - text[1]),
Math.abs(pixel[2] - text[2]),
);
if (distance <= 24) continue; // glyph pixel (or its antialiasing)
const key = `${pixel[0]},${pixel[1]},${pixel[2]}`;
counts.set(key, (counts.get(key) ?? 0) + 1);
sampled += 1;
}
}
if (sampled < 12) continue; // all glyphs: nothing to measure against
let best = null;
for (const [key, count] of counts) if (!best || count > best.count) best = { key, count };
const background = best.key.split(",").map(Number);
const ratio = contrast(text, background);
const large = target.fontSize >= 24 || (target.fontSize >= 18.66 && Number(target.fontWeight) >= 700);
const minimum = large ? 3 : 4.5;
if (ratio < minimum) {
results.push({ text: target.text, ratio: Number(ratio.toFixed(2)), minimum, color: target.color, background: best.key });
}
}
return results;
},
{ targets, dataUrl, luminanceSource: LUMINANCE },
);
if (report.length > 0) {
console.log(`\n[${theme}] ${label} — ${report.length} below minimum`);
for (const row of report) {
console.log(` ${row.ratio}:1 (needs ${row.minimum}) "${row.text}" — ${row.color} on rgb(${row.background})`);
failures.push({ theme, label, ...row });
}
}
await context.close();
}
}
console.log(`\ncontrast scan done — ${failures.length} below-minimum findings`);
await browser.close();

View file

@ -74,7 +74,7 @@ const dragDown = async (page, locator, dy = 170, steps = 9) => {
check("CTA >= 44px tall", ctaBox.height >= 43.5, String(Math.round(ctaBox.height)));
await page.getByRole("link", { name: "Homework", exact: true }).click();
await page.waitForTimeout(700);
const hwCta = page.getByRole("link", { name: "Make my homework", exact: true });
const hwCta = page.getByRole("link", { name: "New homework", exact: true });
check("homework quick action", await hwCta.isVisible());
await page.getByRole("link", { name: "Study Decks", exact: true }).click();
await page.waitForTimeout(700);
@ -174,7 +174,7 @@ for (const locale of ["en", "cs"]) {
check("scrolled: drag snaps back, sheet stays", (await dialog.count()) === 1);
await page.locator(".sheet-scroll").evaluate((el) => (el.scrollTop = 0));
await page.waitForTimeout(250);
await dragDown(page, dialog.locator("h2"));
await dragDown(page, dialog.locator("#settings-title"));
await page.waitForTimeout(800);
check("handle+header swipe closes the sheet", (await dialog.count()) === 0);

View file

@ -97,7 +97,7 @@ const browser = await launchBrowser();
await page.goto(target, { waitUntil: "networkidle" });
await page.waitForTimeout(600);
check("share: chooser lists the file", await page.getByText("shared.txt").isVisible());
await page.getByRole("button", { name: "Make my homework" }).first().click();
await page.getByRole("button", { name: "New homework" }).first().click();
await page.waitForTimeout(1200);
check("share: homework got the file", await page.getByText("shared.txt").first().isVisible());
await context.close();

View file

@ -16,7 +16,7 @@ const browser = await launchBrowser();
check("library title", await page.getByRole("heading", { name: "Your icebox" }).isVisible());
const sidebar = page.getByRole("complementary", { name: "Sidebar" });
for (const row of ["New deck", "Make my homework", "All decks", "Unorganized", "Biology", "Progress", "Exam mode", "Settings"]) {
for (const row of ["New deck", "New homework", "All decks", "Unorganized", "Biology", "Progress", "Exam mode", "Settings"]) {
check(`sidebar row: ${row}`, (await sidebar.getByText(row, { exact: true }).count()) >= 1);
}
check("desktop hides the tab bar", !(await page.locator("nav.tabbar").isVisible()));
@ -74,7 +74,7 @@ const browser = await launchBrowser();
await page.getByRole("link", { name: "Homework", exact: true }).click();
await page.waitForTimeout(600);
check("homework tab empty state", await page.getByRole("link", { name: "Make my homework", exact: true }).isVisible());
check("homework tab empty state", await page.getByRole("link", { name: "New homework", exact: true }).isVisible());
await context.close();
}

View file

@ -89,7 +89,7 @@ for (const viewport of [{ width: 390, height: 844 }, { width: 360, height: 740 }
await page.getByRole("link", { name: "Homework", exact: true }).click();
await page.waitForTimeout(600);
await page.getByRole("link", { name: "Make my homework", exact: true }).click();
await page.getByRole("link", { name: "New homework", exact: true }).click();
await sampleBar(page, "homework empty state → homework");
check("homework headline is the step name", await page.getByRole("heading", { name: "The assignment" }).isVisible());
check("homework Next disabled with nothing", await page.getByRole("button", { name: "Next" }).isDisabled());