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`.
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).
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 start` | `next start -H 0.0.0.0 -p 3207` |
| `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/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 path from "node:path";
import { APP_VERSION } from "@/lib/version";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@ -25,6 +27,7 @@ export async function GET() {
return Response.json(
{
ok: true,
version: APP_VERSION,
commit,
short: release?.short ?? commit.slice(0, 7),
builtAt: release?.builtAt ?? null,

View file

@ -28,7 +28,7 @@ export default async function CreatePage({
<SidebarOffset />
<main className="flex min-h-dvh min-w-0 flex-1 flex-col">
<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" />
<RouteTransition>
<UploadScreen />

View file

@ -518,11 +518,13 @@ export function SettingsDialog({
{version ? (
<DetailRow
label={t("settings.version")}
value={
version.builtAt
? `${version.short} · ${new Date(version.builtAt).toLocaleString()}`
: version.short
}
value={[
version.version ? `v${version.version}` : "",
version.short,
version.builtAt ? new Date(version.builtAt).toLocaleString() : "",
]
.filter(Boolean)
.join(" · ")}
mono
/>
) : null}
@ -797,7 +799,11 @@ export function SettingsDialog({
{health ? <HealthPanel health={health} /> : null}
{version ? (
<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()}` : ""}
</p>
) : null}

View file

@ -3,6 +3,8 @@
import { useEffect, useState } from "react";
export type RunningVersion = {
/** App version from package.json (bumped once per task). */
version?: string;
commit: string;
short: string;
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",
"version": "0.1.0",
"version": "0.1.2",
"private": true,
"license": "GPL-3.0-only",
"scripts": {
@ -10,7 +10,8 @@
"build": "next build",
"start": "next start -H 0.0.0.0 -p 3207",
"lint": "eslint",
"test:e2e": "node tests/run-all.mjs"
"test:e2e": "node tests/run-all.mjs",
"version:bump": "node scripts/bump-version.mjs"
},
"dependencies": {
"canvas-confetti": "^1.9.4",

View file

@ -9,7 +9,7 @@
*
* 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 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();
}
/* 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 --------------------------- */
{
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();
}
/* 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 ----------------- */
{
const { context, page } = await makePage(browser, { reducedMotion: "reduce" });