App versioning + the create page's missing top padding

Versioning: package.json carries the app version (0.1.2 here; one bump per
task with `npm run version:bump`, patch 0-9 rolling into the minor at 10),
baked into the build via lib/version.ts, reported by /api/healthz and shown
in Settings next to the running commit. AGENTS.md documents the rule; the
native suite checks both the API and the Settings row.

Fix: on desktop the app header is hidden, so every page owns its top padding —
/create was the only one without `lg:pt-10`, which slammed the Study deck /
Make my homework switch against the top of the window while /homework had a
comfortable gap. Both now sit at 40px; the wizard suite asserts the clearance
and that the two pages stay in step (90 checks).

SW -> freezmo-v50.
This commit is contained in:
Martin Dvořák 2026-10-12 20:04:26 +00:00
commit aac6befb0d
12 changed files with 124 additions and 10 deletions

View file

@ -54,3 +54,8 @@ Production only ever runs a pushed, verified commit deployed by
`tests/bubble-perf.mjs`, `tests/model-compare.mjs`. `tests/bubble-perf.mjs`, `tests/model-compare.mjs`.
10. **Keep the service worker honest**: bump `CACHE_VERSION` in `public/sw.js` 10. **Keep the service worker honest**: bump `CACHE_VERSION` in `public/sw.js`
for every user-visible deploy (the deploy itself does not do this). for every user-visible deploy (the deploy itself does not do this).
11. **Version the app once per task.** Bump `package.json` `version` with
`npm run version:bump`: the patch digit goes 0-9 and after 9 it rolls
into the minor (`0.1.9` → `0.2.0`). The version is baked into the build
(`lib/version.ts`), reported by `/api/healthz` and shown in Settings next
to the running commit — so every task's release is identifiable.

View file

@ -899,6 +899,7 @@ grading, regeneration and “add more” (and the assignment language for homewo
| `npm run build` | production build (type-check + lint) | | `npm run build` | production build (type-check + lint) |
| `npm start` | `next start -H 0.0.0.0 -p 3207` | | `npm start` | `next start -H 0.0.0.0 -p 3207` |
| `npm run lint` | ESLint | | `npm run lint` | ESLint |
| `npm run version:bump` | bump the app version one step (`0.1.7` → `0.1.8`, `0.1.9` → `0.2.0`); one bump per task |
| `node scripts/make-icons.mjs` | regenerate the PWA/iOS icons (needs a Playwright Chromium) | | `node scripts/make-icons.mjs` | regenerate the PWA/iOS icons (needs a Playwright Chromium) |
| `node scripts/copy-pdf-worker.mjs` | copy the pdf.js worker (also runs on predev/prebuild) | | `node scripts/copy-pdf-worker.mjs` | copy the pdf.js worker (also runs on predev/prebuild) |

View file

@ -1,6 +1,8 @@
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import { APP_VERSION } from "@/lib/version";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@ -25,6 +27,7 @@ export async function GET() {
return Response.json( return Response.json(
{ {
ok: true, ok: true,
version: APP_VERSION,
commit, commit,
short: release?.short ?? commit.slice(0, 7), short: release?.short ?? commit.slice(0, 7),
builtAt: release?.builtAt ?? null, builtAt: release?.builtAt ?? null,

View file

@ -28,7 +28,7 @@ export default async function CreatePage({
<SidebarOffset /> <SidebarOffset />
<main className="flex min-h-dvh min-w-0 flex-1 flex-col"> <main className="flex min-h-dvh min-w-0 flex-1 flex-col">
<AppHeader /> <AppHeader />
<div className="mx-auto w-full max-w-3xl flex-1 px-4 pb-tabbar lg:px-10 lg:pb-16"> <div className="mx-auto w-full max-w-3xl flex-1 px-4 pb-tabbar lg:px-10 lg:pb-16 lg:pt-10">
<WizardModeSwitch mode="deck" /> <WizardModeSwitch mode="deck" />
<RouteTransition> <RouteTransition>
<UploadScreen /> <UploadScreen />

View file

@ -518,11 +518,13 @@ export function SettingsDialog({
{version ? ( {version ? (
<DetailRow <DetailRow
label={t("settings.version")} label={t("settings.version")}
value={ value={[
version.builtAt version.version ? `v${version.version}` : "",
? `${version.short} · ${new Date(version.builtAt).toLocaleString()}` version.short,
: version.short version.builtAt ? new Date(version.builtAt).toLocaleString() : "",
} ]
.filter(Boolean)
.join(" · ")}
mono mono
/> />
) : null} ) : null}
@ -797,7 +799,11 @@ export function SettingsDialog({
{health ? <HealthPanel health={health} /> : null} {health ? <HealthPanel health={health} /> : null}
{version ? ( {version ? (
<p className="mt-2 text-[11px] text-[var(--text-subtle)]"> <p className="mt-2 text-[11px] text-[var(--text-subtle)]">
{t("settings.version")}: <span className="font-mono">{version.short}</span> {t("settings.version")}:{" "}
<span className="font-mono">
{version.version ? `v${version.version} · ` : ""}
{version.short}
</span>
{version.builtAt ? ` · ${new Date(version.builtAt).toLocaleString()}` : ""} {version.builtAt ? ` · ${new Date(version.builtAt).toLocaleString()}` : ""}
</p> </p>
) : null} ) : null}

View file

@ -3,6 +3,8 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
export type RunningVersion = { export type RunningVersion = {
/** App version from package.json (bumped once per task). */
version?: string;
commit: string; commit: string;
short: string; short: string;
builtAt: string | null; builtAt: string | null;

9
lib/version.ts Normal file
View file

@ -0,0 +1,9 @@
import packageJson from "../package.json";
/**
* The app version from package.json, inlined at build time. One bump per task
* with `npm run version:bump`: patch 0-9, then it rolls into the minor
* (0.1.9 -> 0.2.0). Shown in Settings next to the running commit and reported
* by /api/healthz.
*/
export const APP_VERSION: string = packageJson.version;

View file

@ -1,6 +1,6 @@
{ {
"name": "freezmo", "name": "freezmo",
"version": "0.1.0", "version": "0.1.2",
"private": true, "private": true,
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"scripts": { "scripts": {
@ -10,7 +10,8 @@
"build": "next build", "build": "next build",
"start": "next start -H 0.0.0.0 -p 3207", "start": "next start -H 0.0.0.0 -p 3207",
"lint": "eslint", "lint": "eslint",
"test:e2e": "node tests/run-all.mjs" "test:e2e": "node tests/run-all.mjs",
"version:bump": "node scripts/bump-version.mjs"
}, },
"dependencies": { "dependencies": {
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",

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-v49"; const CACHE_VERSION = "freezmo-v50";
const SHELL_CACHE = `${CACHE_VERSION}-shell`; const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const ASSET_CACHE = `${CACHE_VERSION}-assets`; const ASSET_CACHE = `${CACHE_VERSION}-assets`;

41
scripts/bump-version.mjs Normal file
View file

@ -0,0 +1,41 @@
#!/usr/bin/env node
/**
* Bump the app version one step: 0.1.7 -> 0.1.8, and 0.1.9 -> 0.2.0 — the
* patch digit never reaches two digits (the working rule is one bump per
* task; see AGENTS.md).
*
* npm run version:bump # one step up, prints old -> new
* npm run version:bump -- 0.3.0 # set an explicit version
*
* The version is baked into the build (lib/version.ts) and shown in Settings
* next to the running commit.
*/
import { readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
const file = path.join(import.meta.dirname, "..", "package.json");
const raw = readFileSync(file, "utf8");
const current = JSON.parse(raw).version;
const explicit = process.argv[2];
let next = explicit;
if (!next) {
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(current);
if (!match) {
console.error(`package.json version "${current}" is not x.y.z — pass one explicitly`);
process.exit(1);
}
const major = Number(match[1]);
const minor = Number(match[2]);
const patch = Number(match[3]);
next = patch >= 9 ? `${major}.${minor + 1}.0` : `${major}.${minor}.${patch + 1}`;
}
if (!/^\d+\.\d+\.\d+$/.test(next)) {
console.error(`"${next}" is not a valid x.y.z version`);
process.exit(1);
}
writeFileSync(file, raw.replace(`"version": "${current}"`, `"version": "${next}"`));
console.log(`${current} -> ${next}`);

View file

@ -30,6 +30,25 @@ const browser = await launchBrowser();
await context.close(); await context.close();
} }
/* 1b. the app version is reported and shown in Settings --------------- */
{
const { context, page } = await makePage(browser);
await seed(page, { decks: [] });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(600);
const health = await page.evaluate(async () => (await fetch("/api/healthz", { cache: "no-store" })).json());
check("healthz reports the app version", /^\d+\.\d+\.\d+$/.test(health.version ?? ""), String(health.version));
await page.locator("nav.tabbar").getByRole("button").click();
await page.getByRole("dialog").waitFor({ timeout: 6000 });
await page.waitForTimeout(700);
check(
"settings shows the app version",
(await page.getByText(new RegExp(`v${String(health.version).replace(/\./g, "\\.")}`)).count()) >= 1,
String(health.version),
);
await context.close();
}
/* 2. offline: banner + generation disabled --------------------------- */ /* 2. offline: banner + generation disabled --------------------------- */
{ {
const { context, page } = await makePage(browser); const { context, page } = await makePage(browser);

View file

@ -115,6 +115,33 @@ for (const viewport of [{ width: 390, height: 844 }, { width: 360, height: 740 }
await context.close(); await context.close();
} }
/* 2b. the wizard clears the top edge on desktop ----------------------- */
/* On desktop the app header is hidden (`lg:hidden`), so each page owns its
top padding. /create was missing it and the mode switch sat flush against
the top of the window while /homework had a comfortable gap. */
{
const { context, page } = await makePage(browser, { width: 1280, height: 900 });
await seed(page, { decks: [] });
const switcherTop = async () => {
await page.waitForTimeout(700);
return page.evaluate(() => {
const switcher = document.querySelector('[role="group"][aria-label="Study deck or homework"]');
return switcher ? Math.round(switcher.getBoundingClientRect().top) : null;
});
};
await page.goto(`${BASE}/create`, { waitUntil: "networkidle" });
const createTop = await switcherTop();
check("create: the mode switch clears the top edge on desktop", createTop !== null && createTop >= 24, `${createTop}px`);
await page.goto(`${BASE}/homework`, { waitUntil: "networkidle" });
const homeworkTop = await switcherTop();
check(
"create and homework keep the same clearance",
homeworkTop !== null && homeworkTop >= 24 && createTop !== null && Math.abs(homeworkTop - createTop) <= 4,
`${createTop}px vs ${homeworkTop}px`,
);
await context.close();
}
/* 3. reduced motion: bar still pinned, wizard usable ----------------- */ /* 3. reduced motion: bar still pinned, wizard usable ----------------- */
{ {
const { context, page } = await makePage(browser, { reducedMotion: "reduce" }); const { context, page } = await makePage(browser, { reducedMotion: "reduce" });