From 03a78943b1bffb5760a5dc8a88a8b5a5eb838a57 Mon Sep 17 00:00:00 2001 From: dapiced Date: Tue, 29 Sep 2026 17:00:31 -0400 Subject: [PATCH 1/6] feat(v41): CHANGELOG extracted from PLAN.md, version aligned on the latest wave One entry per wave (V7 to V40, sub-waves included, V12 pending excluded), newest first, generated by \ pm run changelog\ from the roadmap tables. A unit test compares the committed file with the generator's output byte for byte, so the changelog cannot drift from the plan in either direction; three plan rows (V25-V27) have no rationale cell and are kept with an empty one rather than dropped. package.json (and the lockfile's root entries) now read 1.40.0 - major 1, minor = wave - which the home page will read to name the latest wave. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .prettierignore | 2 + CHANGELOG.md | 217 ++++++++++++++++++++++++++++++++++++++ package-lock.json | 4 +- package.json | 3 +- scripts/changelog.d.mts | 12 +++ scripts/changelog.mjs | 178 +++++++++++++++++++++++++++++++ src/lib/changelog.test.ts | 186 ++++++++++++++++++++++++++++++++ 7 files changed, 599 insertions(+), 3 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 scripts/changelog.d.mts create mode 100644 scripts/changelog.mjs create mode 100644 src/lib/changelog.test.ts diff --git a/.prettierignore b/.prettierignore index dcf0642..b1f234d 100644 --- a/.prettierignore +++ b/.prettierignore @@ -7,3 +7,5 @@ package-lock.json docs/img .wrangler index.html +# V41 — generated from PLAN.md by `npm run changelog`; a test compares it byte for byte +CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4e44f8d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,217 @@ +# Changelog + +Generated from the roadmap tables in `PLAN.md` by `npm run changelog` — not edited by +hand: a unit test fails when this file and the plan disagree, in either direction. One +entry per wave, newest first; the first six waves (V1–V6, the MVP) predate the tables and +are recorded in PLAN.md §B and §J. Each entry carries the opening sentence of its row and +the reason the wave was built. + +## V40 — Data Studio: validity, drift, and an auditable diff + +Quality was measured as completeness and consistency of type; what was missing is **validity** — a value can be present, correctly typed and still impossible. + +_Why:_ Came last because it builds on V38's faithful read and V39's per-column recipe: validity rules on mis-parsed numbers would have flagged the parser, not the data. Closes the Data Studio group. Of the 36 new unit tests, eleven assert that a rule REFUSES to fire — a rule that flags a good file is worse than no rule, because it teaches the reader to ignore the panel. + +## V39 — Data Studio: a recipe that works column by column + +`RecipeOptions` applied `missing` and `clipOutliers` to the **whole file** — one strategy for every column, however different they are. + +_Why:_ A single global strategy is the kind of default that looks tidy and quietly makes the data worse; per-column steps cost little to build because the recipe was already an object, not a pile of checkboxes. The build confirmed it: the engine change is contained in one stage of `applyRecipe`, and the 17 pre-existing recipe tests passed untouched. + +## V38 — Data Studio: reading the file exactly as it was written + +The headline item was a **defect in shipped code, not a missing feature**, and the wave opened by proving it. + +_Why:_ Owner request (22/08/2026): what to improve in /data. The audit found a defect first, and the wave began by reproducing it end to end: a French-locale CSV — the single most likely file this owner's users will open — silently loses every numeric column. The repair is exact rather than approximate: the French file now trains to the same types, the same feature count and the same score, to ten decimal places, as the file that never had the problem. + +## V37 — ML Lab: speed and the comfort of long sessions + +The wave opened, as the plan demanded, with a measurement — and the measurement moved the wave. + +_Why:_ Launched 23/08/2026, after V36. The plan said « measure before and after so the gain is published rather than claimed » — and the measurement is what turned the wave around: the promised parallelism was worth 6%, while the bottleneck it revealed was worth 5×. Two latent defects fell out of the same instrumentation: models corrupted by structured clone, and an inference column reading 0 ms for every parallel family. + +## V36 — ML Lab: the gaps that were deliberately left open + +Each item was consciously deferred in an earlier wave rather than forgotten; delivering them together keeps the descopes visible instead of letting them quietly become permanent. + +_Why:_ Launched 23/08/2026, right after V35. Each item was a named descope, not an oversight — and the ensemble exposed one more silent-disappearance defect, of the same family as the one V35 found. + +## V35 — ML Lab: the number stops flattering itself + +Two method defects in shipped code, fixed, plus the two additions that follow from them. + +_Why:_ Owner request (22/08/2026), launched 23/08/2026. Two of the four items were defects rather than gaps: a lab that sells honest evaluation cannot ship a headline figure it knows to be optimistic, nor a split that leaks on dated data. + +## V34 — The explanations, the how-to guides, and a limits page extracted from this very file + +Six new pages per language — two explanations (the method choices; what LabML does not do) and four task-shaped how-to guides (score a batch, compare two runs, read a learning curve, hand a SQL result to the lab) — bringing the documentation to **twelve pages per language across all four Diátaxis quadrants**. + +_Why:_ Three audiences, deliberately: the curious visitor (five minutes), the practitioner (one task), and the evaluator judging whether the engineering is rigorous. The explanation pages are what the third one reads. + +## V33 — The reference, and a table of refusals extracted from the code rather than from memory + +Five pages per language — the refusals table, ML Lab, Data Studio, Vision & assistant, and file formats — grouped by section rather than one page per panel, so a lookup lands on one page with anchors instead of hunting across twenty. + +_Why:_ A feature nobody can look up is a feature that does not exist for the reader; and a refusal nobody can decode reads as a bug rather than as the design it is. + +## V32 — Documentation that cannot lie — and a measurement that rewrote this row's own rule + +A `/docs` route, linked from the footer, built on the **Diátaxis** split, with the Markdown living in `src/content/docs//*.md` and compiled **at build time**: the reader downloads finished pages, an outline and a search index — never a parser, and never a request to a documentation host. + +_Why:_ label`compiles to a deep link, and`/ml`now reads`?demo=`and`?target=`. A screenshot is a claim about the past that rots silently; a deep link either works or the e2e catches it. The parameter becomes a fetched path, so it is resolved against the shipped demo list and nothing else — an e2e test asserts that `?demo=../../../etc/passwd`fetches nothing. **What this wave deliberately does not do**: write the reference pages before the template is settled (rewriting all of them is the predictable cost), hand-take a screenshot, or claim the figure guard is total.`marked` is a build-time devDependency and never reaches the browser. 601 unit tests, 86 e2e. + +## V31 — Vision that says « I do not know » — and a bench that refuted three of this row's own predictions + +**(A) Measure first**, as V30 taught: the complaint « it still makes mistakes » is not a measurable statement. + +_Why:_ Owner report (22/08/2026): the vision playground is better than the chat but still makes mistakes. Naming the label-space mismatch is what turns a vague complaint into a fixable defect. + +## V30 — Chat that reads better, measured before it is made bigger + +The wave began by building the instrument, because V27's stood on 18 cases that needed a GPU with `shader-f16` — one laptop's worth of evidence, re-runnable by nobody. + +_Why:_ Owner question (22/08/2026): would a bigger model raise the share of correct answers? The wave answers with a measurement rather than an estimate, and the answer has two halves. For **0 MB**, the app went from **33 right / 15 wrong** to **42 right / 7 wrong** out of 55 — nine more correct answers and **fifty-three percent fewer wrong ones**, the single largest piece of which came from the deterministic parser rather than the model. And the second half was measured too, not deferred: **Qwen3-1.7B at 1.43 GB — four times the download — scores worse** (40 right / 12 wrong against 42 / 7). It reads the hard questions better and the easy ones worse. « Bigger » is not a direction of improvement on this task; it is a trade whose sign has to be measured, and the bench now measures it in one command. + +## V29 — Analytical SQL in the browser (DuckDB-Wasm, MIT) + +**Analytical SQL in the browser (DuckDB-Wasm, MIT)**: the Data Studio gains a real OLAP engine — joins, window functions, aggregations — over the file you just loaded, with no server and no upload. + +_Why:_ Owner request (21/08/2026): real analytical SQL on ~100 MB files with zero backend. Delivered after the /privacy page at the owner's request (22/08/2026). + +## V28 — « Ne nous croyez pas sur parole » + +**« Ne nous croyez pas sur parole »** — a `/privacy` route that states the local-only promise once, in full, and then hands the reader the means to check it without trusting a word of it. + +_Why:_ Owner request (22/08/2026): the promise is repeated across the site, but a user has no way to tell a true claim from a comforting one. Verifiability is the product here — anyone can write « your data stays local » in a footer. + +## V27.3 — A `>=` that was quietly an `=` + +**A `>=` that was quietly an `=`**: retesting the comparison question after V27.2 produced « 0 ligne correspond où fare >= 0 » — impossible on a table where all 891 fares clear zero. + +_Why:_ Found by retesting in production (22/08/2026). An arithmetically impossible answer — zero rows for a condition every row satisfies — is worse than a refusal and worse than a wrong reading: it makes the engine itself untrustworthy, which is the one thing LabML sells. + +## V27.2 — Two honesty defects, one measured, one found while reading the measurement + +**Two honesty defects, one measured, one found while reading the measurement**: (1) the comparison question V27.1 left wrong — « est-ce que les femmes payaient plus cher que les hommes ? » read as a correlation between `fare` and `age` — gets a rule that names both halves of the mistake: a question comparing two groups is an aggregate with `groupBy` on the column whose values name them, NEVER a correlation; and never pick a column the question does not mention. + +_Why:_ Measured by the owner on real hardware (22/08/2026): 5 of 6 reference questions right after V27.1. The sixth is a confidently wrong answer to a different question than the one asked, and the « sur 891 lignes » wording was found by reading that same screenshot closely — a right number inside a wrong sentence is exactly what this project refuses to ship. + +## V27.1 — The model earns its place, it does not take it + +**The model earns its place, it does not take it**: the V27 order was wrong, and the measurement said so. + +_Why:_ Measured by the owner in production (22/08/2026), the day V27 shipped. A confidently wrong answer costs more trust than a refusal — and V27 produced two of them, including a 0 where the deterministic engine already had the right 168. + +## V27 — Local chat, upgraded + +**Local chat, upgraded**: a real language model — **Qwen3-0.6B-DQ, 355 MB, Apache-2.0** — running entirely in the browser, offered beside the V6 deterministic interpreter, which stays the DEFAULT and the fallback. + +## V26 — Learning curves + +**Learning curves**: the lab answers the classic budget question — "would more data help this model, or is it time to work on features?" — with one new chart. + +## V25 — Scale + +**Scale**: the lab now takes 100k–1M-row files without dying, on a measure-first design. + +## V24 — Text columns + +**Text columns**: free text stops being skipped and enters the pipeline as a hand-written **TF-IDF** block — accent-folding bilingual tokenizer, merged FR/EN stop words, vocabulary capped at 256 terms ranked by document frequency (ties alphabetical, terms seen in a single training document dropped), smoothed IDF, L2-normalized vectors, fitted on the training split only. + +_Why:_ Real CSVs have text columns (comments, descriptions) — the lab used to drop them on the floor + +## V23 — Vision 2 + +**Vision 2**: SqueezeNet (2012) retired for three self-hosted ONNX models — **EfficientNet-Lite4 int8** classification (1,000 ImageNet classes, 77.6% top-1), **YOLOX-Nano** object detection (80 COCO classes; the stronger-but-AGPL YOLOs were ruled out, Apache-2.0 kept) and **UltraFace RFB-320** face detection — boxes drawn on the image, FR/EN class names, plain-language counts ("1 person · 1 face"). + +_Why:_ Owner request (21/08/2026): portraits have no ImageNet class, so the old model answered off-target — and the detector must recognize a whole range of things, not just faces + +## V22 — The model comes back + +**The model comes back**: export as format v2 — the JSON embeds the fitted pipeline (imputation/encoding/standardization), the target, the classes and the exporting run's test metrics as an honest reference. + +_Why:_ Closes the last loop: train today, come back in a month, score + +## V21 — Compare two runs + +**Compare two runs**: check two runs in the history → side-by-side diff on /ml/compare — features added/removed as ± badges, every model's metric in an A/B/Δ table (signed colors), plain-language read of the best model's movement, and a cross-run verdict when both runs carry V20 CIs (disjoint → the gap exceeds both uncertainties; overlapping → possibly noise). + +_Why:_ "Did my cleaning help?" — the central iterative gesture of ML + +## V20 — Honest uncertainty + +**Honest uncertainty**: seeded bootstrap of the test set (1,000 resamples shared across models — paired comparisons) → percentile 95% CI on every leaderboard model's main metric (whiskers on a shared scale), plain-language paired winner-vs-baseline verdict ("the gap survives resampling — probably real" / "the interval crosses zero — possibly noise"), analysis attached to the run (history/report/share). + +_Why:_ `0.82` on 178 rows is not `0.82`; say what the number does not say + +## V19 — Persistent projects + +**Persistent projects**: the dataset joins the project, opt-in ("keep in this browser") — lz-string-compressed CSV in IndexedDB, explicit 50 MB budget (named refusal with the numbers, never a silent cut), saved list (reopen/forget) under the history, runs linked to the saved dataset ("reopen this run's data"), identical retraining (seed 42). + +_Why:_ A refresh erased everything; "projects" are only real if they survive + +## V18 — Per-segment analysis + +**Per-segment analysis**: after a run, the test set is sliced by every categorical column — including those excluded from the features, where proxy effects hide — and the inspected model's metric (accuracy or RMSE) is recomputed per slice, gap vs global signed and sorted worst-first. + +_Why:_ "Where does my model fail?" — an honest gateway to fairness + +## V17 — Data Studio 3: joins & anomalies + +**Data Studio 3: joins & anomalies**: left join of a second file on a shared key (exact match after trim — a dirty key becomes a named orphan, never silence; match rate, duplicates, unused rows; the joined result becomes THE dataset), and **multivariate anomalies** via a hand-written seeded isolation forest (100 trees, exact c(n)) as a step of the **replayable recipe** (threshold 0.6). + +_Why:_ Real data prep starts by crossing two files; multivariate anomalies see what Tukey misses + +## V16 — Imbalance & thresholds + +**Imbalance & thresholds**: precision-recall curve (AP, chance line drawn), adjustable decision threshold priced by a cost matrix (false alarm vs missed case, one-click optimum), calibration curve (Brier), imbalanced demo `fraud.csv`; the chosen threshold joins the run. + +_Why:_ Real datasets are imbalanced; accuracy lies there + +## V15 — Score a new batch + +**Score a new batch**: after a run, drop a new file → the inspected model scores it in the browser (exportable predictions, all columns preserved); if the target is present, honest test-vs-batch comparison (unknown labels excluded and counted); schema validated, drifted demo `iris-field.csv`, score attached to the run (history/report/share) + +_Why:_ The complete MLOps loop: V11 says "the inputs moved", V15 says "does the model still hold" + +## V14 — Generalized prerendering + +**Generalized prerendering**: static shells for all six sections (the V9 approach extended — inlined CSS, Latin fonts as data:, per-route preloaded façade, header template), Lighthouse /ml 0.86 → 0.99 and /data 1.0 under real throttling (3-run medians); the root stays the SPA fallback (accepted) + +_Why:_ The last Lighthouse gap + +## V13 — Complete runs + +**Complete runs**: tuning, latest Shapley explanation, exploration and forecast attached to the run record — IndexedDB history (with chips), stored-run page, HTML report and v2 share links (subsampled scatter plots in the URL; v1 links remain decodable) + +_Why:_ The V5–V8 artifacts did not survive the run + +## V11 — Data drift + +**Data drift** in the Data Studio: a reference file, a file to compare → schema differences (columns added/removed/retyped), **PSI per column** (quantile bins from the reference, thresholds 0.1/0.25), new/vanished categories, missing-rate gaps, overall verdict — with a deliberately drifted demo (`cafe-sales-june.csv`) + +_Why:_ The MLOps gesture par excellence: checking that a new batch looks like what the model learned on + +## V10 — Data Studio 2 + +**Data Studio 2**: importable recipe replayable on a new file, per-column forced types, derived columns + +_Why:_ Completes the reproducibility loop + +## V9 — Performance & comfort + +**Performance & comfort**: /ml Lighthouse budget ≥ 0.90 (preloads, splitting), PWA update toast, webcam for vision (Permissions-Policy to open) + +_Why:_ Perceived quality and scores + +## V8 — Time series + +**Time series**: date + numeric target detection → trend/season decomposition, hand-written Holt-Winters forecasting, rolling-origin backtest + +_Why:_ Opens up an entire class of problems + +## V7 — Unsupervised exploration + +**Unsupervised exploration** in the ML Lab: hand-written k-means (seeded k-means++ init, k ∈ 2–5 chosen by silhouette), hand-written 2D PCA projection (power iteration), plain-language group profiles, scatter plot with colors **and shapes** (palette validated for color blindness by the design-system validator) + +_Why:_ Fills the real gap: today the lab requires a target; many datasets are explored first without one diff --git a/package-lock.json b/package-lock.json index d423aaf..1cbe7a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "labml", - "version": "0.1.0", + "version": "1.40.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "labml", - "version": "0.1.0", + "version": "1.40.0", "license": "MIT", "dependencies": { "@duckdb/duckdb-wasm": "1.28.0", diff --git a/package.json b/package.json index 0a27a95..44a8c4c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "labml", "private": true, - "version": "1.0.0", + "version": "1.40.0", "license": "MIT", "type": "module", "engines": { @@ -15,6 +15,7 @@ "llm:bench": "node scripts/run-llm-bench.mjs", "llm:bench:node": "LABML_LLM_BENCH=1 vitest run src/features/ai/llm/bench.node.test.ts", "llm:fetch": "node scripts/prepare-llm.mjs .llm-cache --flat", + "changelog": "node scripts/changelog.mjs", "lint": "eslint .", "format": "prettier --write .", "format:check": "prettier --check .", diff --git a/scripts/changelog.d.mts b/scripts/changelog.d.mts new file mode 100644 index 0000000..5e67522 --- /dev/null +++ b/scripts/changelog.d.mts @@ -0,0 +1,12 @@ +export interface Wave { + version: string; + title: string; + summary: string; + why: string; +} + +export function extractWaves(plan: string): Wave[]; +export function renderChangelog(waves: Wave[]): string; +export function packageVersionFor(waves: Wave[]): string; +export function syncPackageVersion(packageJson: string, version: string): string; +export function syncLockVersion(lock: string, version: string): string; diff --git a/scripts/changelog.mjs b/scripts/changelog.mjs new file mode 100644 index 0000000..ea9854e --- /dev/null +++ b/scripts/changelog.mjs @@ -0,0 +1,178 @@ +/** + * V41 — the CHANGELOG, extracted from `PLAN.md` rather than written by hand. + * + * PLAN.md is the engineering log: every wave has a row in one of its roadmap + * tables, with the wave's content and the reason it was built. At 195 KB it is + * not something a visitor reads, so this script reads it instead and writes one + * entry per wave, newest first. The rule is the one V34 set for the limits + * page: a record recalled from memory flatters, a record extracted from the + * source cannot. `src/lib/changelog.test.ts` fails when `CHANGELOG.md` and the + * plan disagree, in either direction. + * + * npm run changelog + * + * Also aligns the version in `package.json` (and the lockfile's root entries) + * on `1..0`, which is what the home page reads to say « V40 ». + */ +import { readFileSync, writeFileSync } from 'node:fs'; +import { pathToFileURL } from 'node:url'; + +/** @typedef {{ version: string; title: string; summary: string; why: string }} Wave */ + +const ROW = /^\|(.*)\|\s*$/; +/** `V7`, `V27.1 — delivered`, `V12 — pending` — after the bold markers are gone. */ +const WAVE = /^V(\d+(?:\.\d+)?)(?:\s*—\s*(\w+))?$/; +/** + * A sentence ends at a period followed by whitespace, optionally closing a + * bold run first (`itself.** Two`) so the bold stays balanced. A period inside + * a decimal (`0.818`), a version (`V27.1`) or a file name (`x.csv`) has no + * whitespace after it and ends nothing. + */ +const SENTENCE_END = /\.(\*\*)?(?=\s)/; + +/** + * @param {string} plan + * @returns {Wave[]} + */ +export function extractWaves(plan) { + /** @type {Wave[]} */ + const waves = []; + for (const line of plan.split(/\r?\n/)) { + const row = ROW.exec(line); + if (!row) continue; + const cells = row[1].split(/(? cell.trim()); + if (cells.length < 2) continue; + const head = WAVE.exec(cells[0].replace(/\*/g, '').trim()); + if (!head || head[2] === 'pending') continue; + const content = cells[1]; + const title = titleOf(content); + // Three rows of the plan (V25–V27) never got a « why » cell; an empty + // rationale is reported as such rather than dropping the wave. + waves.push({ + version: head[1], + title, + summary: summaryOf(content, title), + why: cells[2] ?? '', + }); + } + return waves.sort((a, b) => compareVersions(b.version, a.version)); +} + +/** @param {string} content */ +function titleOf(content) { + const bold = /\*\*(.+?)\*\*/.exec(content); + const raw = bold ? bold[1] : content.split(':')[0]; + return plain(raw); +} + +/** + * The first sentence — unless that sentence is the bold title and nothing + * else, in which case the heading would be repeated and the sentence after it + * is the one that says what the wave did. + * @param {string} content + * @param {string} title + */ +function summaryOf(content, title) { + const first = firstSentence(content); + if (plain(first) !== title) return first; + const rest = content.slice(first.length).trim(); + return rest === '' ? first : firstSentence(rest); +} + +/** @param {string} content */ +function firstSentence(content) { + const end = SENTENCE_END.exec(content); + return end ? content.slice(0, end.index + end[0].length) : content; +} + +/** Markdown bold and trailing punctuation removed, for comparing a title to a sentence. */ +function plain(text) { + return text + .replace(/\*\*/g, '') + .trim() + .replace(/[.:—-]+$/, '') + .trim(); +} + +/** + * `28` sorts after `27.3`, which sorts after `27`. + * @param {string} a + * @param {string} b + */ +function compareVersions(a, b) { + const [aMajor, aMinor = 0] = a.split('.').map(Number); + const [bMajor, bMinor = 0] = b.split('.').map(Number); + return aMajor - bMajor || aMinor - bMinor; +} + +/** + * @param {Wave[]} waves newest first + * @returns {string} + */ +export function renderChangelog(waves) { + const head = [ + '# Changelog', + '', + 'Generated from the roadmap tables in `PLAN.md` by `npm run changelog` — not edited by', + 'hand: a unit test fails when this file and the plan disagree, in either direction. One', + 'entry per wave, newest first; the first six waves (V1–V6, the MVP) predate the tables and', + 'are recorded in PLAN.md §B and §J. Each entry carries the opening sentence of its row and', + 'the reason the wave was built.', + '', + ]; + const body = waves.flatMap((wave) => [ + `## V${wave.version} — ${wave.title}`, + '', + wave.summary, + '', + ...(wave.why === '' ? [] : [`_Why:_ ${wave.why}`, '']), + ]); + return [...head, ...body].join('\n'); +} + +/** + * `1..0` — major 1 because nothing here breaks a + * consumer, minor for the wave, patch always 0. Sub-waves (27.1, 27.2…) are + * honesty fixes to their wave and do not move the number. + * @param {Wave[]} waves + */ +export function packageVersionFor(waves) { + const latest = Math.max(...waves.map((wave) => parseInt(wave.version, 10))); + return `1.${latest}.0`; +} + +/** + * Rewrites the first `"version"` field only, leaving the file otherwise + * byte-identical — `package.json` is hand-formatted and diffs should say + * « version », not « reformatted ». + * @param {string} packageJson + * @param {string} version + */ +export function syncPackageVersion(packageJson, version) { + return packageJson.replace(/("version":\s*")[^"]*(")/, `$1${version}$2`); +} + +/** + * The lockfile names the root package twice (top level and `packages[""]`); + * both carry a version, and only those two — every dependency has its own name. + * @param {string} lock + * @param {string} version + */ +export function syncLockVersion(lock, version) { + return lock.replace(/("name": "labml",\s*"version": ")[^"]*(")/g, `$1${version}$2`); +} + +function main() { + const waves = extractWaves(readFileSync('PLAN.md', 'utf8')); + if (waves.length === 0) throw new Error('PLAN.md holds no wave row'); + writeFileSync('CHANGELOG.md', renderChangelog(waves)); + const version = packageVersionFor(waves); + writeFileSync('package.json', syncPackageVersion(readFileSync('package.json', 'utf8'), version)); + writeFileSync( + 'package-lock.json', + syncLockVersion(readFileSync('package-lock.json', 'utf8'), version), + ); + console.log(`CHANGELOG.md: ${waves.length} waves, latest V${waves[0].version} → ${version}`); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main(); diff --git a/src/lib/changelog.test.ts b/src/lib/changelog.test.ts new file mode 100644 index 0000000..4b49625 --- /dev/null +++ b/src/lib/changelog.test.ts @@ -0,0 +1,186 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { + extractWaves, + packageVersionFor, + renderChangelog, + syncLockVersion, + syncPackageVersion, +} from '../../scripts/changelog.mjs'; + +/** + * V41 — the CHANGELOG is extracted from `PLAN.md`, never written by hand, on + * the rule V34 set for `/docs/limites`: a record recalled from memory flatters. + * The plan's roadmap tables are the one place every wave is recorded, so the + * changelog reads them and a drift in either direction fails here. + */ +const SAMPLE = ` +| Wave | Content | Why | +| ------------------- | ----------------------------------------------------------------------- | ------------------------------ | +| **V7** | **Unsupervised exploration** in the ML Lab: hand-written k-means (seeded). Fills a gap. | Fills the real gap | +| V8 | **Time series**: date + numeric target detection → Holt-Winters forecasting | Opens up an entire class of problems | +| — | **Generative chat** (optional, outside the cap): requires a server proxy | A product decision to make separately | +| **V11 — delivered** | **Data drift** in the Data Studio: a reference file, thresholds 0.1/0.25, overall verdict | The MLOps gesture par excellence | +| V12 — pending | **Consented generative chat**: a Cloudflare Pages Function | Owner's product decision: postponed | +| **V27.1 — delivered** | **The model earns its place, it does not take it**: the V27 order was wrong. With the local model selected it read EVERY question. | Measured, not assumed. | +| **V35 — delivered** | **ML Lab: the number stops flattering itself.** Two method defects in shipped code, fixed. **(1) The winner** was picked on test. | Owner request (22/08/2026). | +| **V25 — delivered** | **Scale**: the lab now takes 100k–1M-row files without dying. Before: a stack overflow. | +| **V39 — delivered** | **Recipe.** \`RecipeOptions\` applied one strategy to the whole file. A median makes sense for an age. | Tidy defaults lie. | +`; + +describe('extractWaves', () => { + const waves = extractWaves(SAMPLE); + + it('keeps every wave row that is not pending, newest first', () => { + expect(waves.map((wave) => wave.version)).toEqual(['39', '35', '27.1', '25', '11', '8', '7']); + }); + + it('accepts a row whose why column is missing, with an empty why', () => { + const scale = waves.find((wave) => wave.version === '25'); + expect(scale?.title).toBe('Scale'); + expect(scale?.summary).toBe('**Scale**: the lab now takes 100k–1M-row files without dying.'); + expect(scale?.why).toBe(''); + }); + + it('skips the header, the separator, the dash row and the pending row', () => { + const versions = waves.map((wave) => wave.version); + expect(versions).not.toContain('12'); + expect(waves.some((wave) => wave.title.includes('Generative chat'))).toBe(false); + }); + + it('takes the first bold run as the title, without its trailing punctuation', () => { + expect(waves.find((wave) => wave.version === '11')?.title).toBe('Data drift'); + expect(waves.find((wave) => wave.version === '35')?.title).toBe( + 'ML Lab: the number stops flattering itself', + ); + }); + + it('keeps the first sentence of the content as the summary, markdown intact', () => { + expect(waves.find((wave) => wave.version === '7')?.summary).toBe( + '**Unsupervised exploration** in the ML Lab: hand-written k-means (seeded).', + ); + // No sentence-ending period: the whole cell is the summary. + expect(waves.find((wave) => wave.version === '8')?.summary).toBe( + '**Time series**: date + numeric target detection → Holt-Winters forecasting', + ); + }); + + it('skips to the next sentence when the bold title is a whole sentence by itself', () => { + // Repeating the heading as the summary would say nothing twice. + expect(waves.find((wave) => wave.version === '35')?.summary).toBe( + 'Two method defects in shipped code, fixed.', + ); + // The sentence after the title may open with inline code. + expect(waves.find((wave) => wave.version === '39')?.summary).toBe( + '`RecipeOptions` applied one strategy to the whole file.', + ); + }); + + it('does not cut a sentence on a decimal or an inline sub-version', () => { + expect(waves.find((wave) => wave.version === '11')?.summary).toContain('0.1/0.25'); + expect(waves.find((wave) => wave.version === '27.1')?.summary).toBe( + '**The model earns its place, it does not take it**: the V27 order was wrong.', + ); + }); + + it('carries the why column verbatim', () => { + expect(waves.find((wave) => wave.version === '8')?.why).toBe( + 'Opens up an entire class of problems', + ); + }); +}); + +describe('renderChangelog', () => { + const text = renderChangelog(extractWaves(SAMPLE)); + + it('writes one heading per wave, newest first, with summary and rationale', () => { + const headings = text.split('\n').filter((line) => line.startsWith('## ')); + expect(headings[0]).toBe('## V39 — Recipe'); + expect(headings.at(-1)).toBe('## V7 — Unsupervised exploration'); + expect(text).toContain('\n_Why:_ Opens up an entire class of problems\n'); + }); + + it('leaves out the rationale line when the plan gave none', () => { + const entry = text.slice(text.indexOf('## V25 — Scale'), text.indexOf('## V11 — Data drift')); + expect(entry).not.toContain('_Why:_'); + expect(entry).toBe( + '## V25 — Scale\n\n**Scale**: the lab now takes 100k–1M-row files without dying.\n\n', + ); + }); + + it('says where it comes from and that it is not edited by hand', () => { + expect(text.startsWith('# Changelog\n')).toBe(true); + expect(text).toMatch(/npm run changelog/); + expect(text).toMatch(/V1–V6/); + }); +}); + +describe('packageVersionFor', () => { + it('maps the latest integer wave to 1..0, ignoring sub-versions', () => { + expect(packageVersionFor(extractWaves(SAMPLE))).toBe('1.39.0'); + expect(packageVersionFor([{ version: '27.3', title: '', summary: '', why: '' }])).toBe( + '1.27.0', + ); + }); +}); + +describe('syncPackageVersion', () => { + it('rewrites only the version field and leaves the rest of the file byte for byte', () => { + const before = + '{\n "name": "labml",\n "private": true,\n "version": "1.0.0",\n "x": 1\n}\n'; + expect(syncPackageVersion(before, '1.35.0')).toBe( + '{\n "name": "labml",\n "private": true,\n "version": "1.35.0",\n "x": 1\n}\n', + ); + }); +}); + +describe('syncLockVersion', () => { + it('rewrites the two root entries of the lockfile and no dependency', () => { + const before = [ + '{', + ' "name": "labml",', + ' "version": "0.1.0",', + ' "packages": {', + ' "": {', + ' "name": "labml",', + ' "version": "0.1.0",', + ' "dependencies": {}', + ' },', + ' "node_modules/dexie": {', + ' "version": "4.4.5"', + ' }', + ' }', + '}', + '', + ].join('\n'); + const after = syncLockVersion(before, '1.35.0'); + expect(after.match(/"version": "1\.35\.0"/g)).toHaveLength(2); + expect(after).toContain('"version": "4.4.5"'); + }); +}); + +describe('the committed CHANGELOG', () => { + const plan = readFileSync('PLAN.md', 'utf8'); + const waves = extractWaves(plan); + + it('exists', () => { + expect(existsSync('CHANGELOG.md'), 'run `npm run changelog`').toBe(true); + }); + + it('is exactly what PLAN.md produces — a drift in either direction fails here', () => { + expect(readFileSync('CHANGELOG.md', 'utf8')).toBe(renderChangelog(waves)); + }); + + it('reaches the last delivered wave and names every wave', () => { + expect(Number(waves[0].version)).toBeGreaterThanOrEqual(40); + for (const wave of waves) { + expect(wave.title, `V${wave.version} has no title`).not.toBe(''); + expect(wave.summary, `V${wave.version} has no summary`).not.toBe(''); + } + }); + + it('is the version package.json declares', () => { + const pkg = JSON.parse(readFileSync('package.json', 'utf8')) as { version: string }; + expect(pkg.version).toBe(packageVersionFor(waves)); + }); +}); From 5a9d847c7c16a092822ef1b1210a59328c37ad52 Mon Sep 17 00:00:00 2001 From: dapiced Date: Tue, 29 Sep 2026 17:06:42 -0400 Subject: [PATCH 2/6] feat(v41): a shell per documentation page, and a home page that describes itself Measured on production (29 Sep 2026): the twelve /docs/ URLs the sitemap advertised were served by the root fallback - the home page's title, Open Graph tags and canonical, and an empty root - so a crawler saw twelve duplicates of the home page and a shared tutorial link previewed the wrong page. Each doc page now gets its own prerendered shell (docs/.html, served at the clean URL Pages and vite preview both resolve) carrying the compiled article, its title, its summary and its canonical. The home page's description opened mid-sentence because only the highlighted half of the title was prepended; and / was the one section painting nothing before JavaScript, so index.html now carries the home hero with HomePage's exact classes. The doc shells stay out of the service worker precache (~1.5 MB the app already renders offline from its DOCS module); docs/index.html remains. shells.spec.ts now reads every URL the sitemap lists and asserts its own canonical, og:url, a unique title and an

in the HTML itself. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- e2e/shells.spec.ts | 52 +++++++++++++++++ vite.config.ts | 139 +++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 179 insertions(+), 12 deletions(-) diff --git a/e2e/shells.spec.ts b/e2e/shells.spec.ts index db3a01e..bd953b7 100644 --- a/e2e/shells.spec.ts +++ b/e2e/shells.spec.ts @@ -81,6 +81,58 @@ test('every shell carries its own title, description and social card', async ({ expect(og.headers()['content-type']).toContain('image/png'); }); +/** + * V41 — every URL the sitemap advertises must describe itself. + * + * Measured on production (29 Sep 2026): the twelve `/docs/` pages the + * sitemap listed were all served by the root fallback — the home page's title, + * description, Open Graph tags and canonical, and an empty `
`. + * To a crawler that is twelve duplicates of the home page; to a reader pasting + * a tutorial link into a chat, a preview of the wrong page. The V35 guard + * above checked the section shells and the sitemap's status codes, and missed + * it because a fallback answers 200 too. This one reads every advertised URL. + */ +test('every page the sitemap advertises describes itself without JavaScript', async ({ + request, +}) => { + const xml = await (await request.get('/sitemap.xml')).text(); + const listed = [...xml.matchAll(/https:\/\/app\.dominicdapice\.com([^<]*)<\/loc>/g)].map( + (match) => match[1], + ); + expect(listed.length).toBeGreaterThanOrEqual(21); + + const titles = new Map(); + for (const path of listed) { + const html = await (await request.get(path)).text(); + const meta = (pattern: RegExp) => pattern.exec(html)?.[1]?.trim() ?? ''; + + expect(meta(/([^<]*)<\/title>/); + expect(titles.has(title), `${path} repeats the title of ${titles.get(title)}`).toBe(false); + titles.set(title, path); + expect( + meta(/]/); + } +}); + +test('the home page description starts where its sentence starts', async ({ request }) => { + const html = await (await request.get('/')).text(); + const description = / { const xml = await (await request.get('/sitemap.xml')).text(); const listed = [...xml.matchAll(/https:\/\/app\.dominicdapice\.com([^<]*)<\/loc>/g)].map( diff --git a/vite.config.ts b/vite.config.ts index 2b17549..e1854c2 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -7,7 +7,7 @@ import react from '@vitejs/plugin-react'; import { defineConfig, type Plugin } from 'vite'; import { VitePWA } from 'vite-plugin-pwa'; import { viteStaticCopy } from 'vite-plugin-static-copy'; -import { docsPlugin } from './vite-docs.ts'; +import { docsPlugin, readDocs } from './vite-docs.ts'; /** * A prerendered route: its static shell (index.html + the page hero injected @@ -200,18 +200,59 @@ function prerenderShells(rootTargets: string[]): Plugin { return name.includes(suffix) ? name : `${name} · ${suffix}`; }; + /** + * The hero a shell paints before JavaScript: header footprint, eyebrow, + * title, lede. The classes are the page component's own, so React's + * mount replaces the shell with identical geometry and shifts nothing. + */ + const hero = (parts: { + eyebrow: string; + title: string; + lede: string; + section: string; + h1: string; + ledeClass: string; + body?: string; + }) => + `
` + + `
` + + `

${esc(parts.eyebrow)}

` + + `

${parts.title}

` + + `

${esc(parts.lede)}

${parts.body ?? ''}
`; + const highlighted = (pre: string, highlight: string) => + `${esc(pre)} ${esc(highlight)}`; + // The root file is also the SPA fallback, so it answers for every route // without a shell of its own — the home page's metadata is the honest // default there. Written after the helpers above exist, and from a // `base` that still carries none, so the shells below inherit preloads // and stylesheet but never the home page's title or Open Graph tags. + // + // V41 — it now carries the home hero too. Measured on production: `/` + // was the one section that painted nothing before JavaScript, and the + // one most visitors land on. Its description also opened mid-sentence + // (« entirely in your browser. Drop a dataset… ») because only the + // highlighted half of the title was prepended to the lede. writeFileSync( htmlPath, withMeta( - base.replace('', () => `${rootTargets.map(preload).join('')} `), + base + .replace('', () => `${rootTargets.map(preload).join('')} `) + .replace( + '
', + () => + `
${hero({ + eyebrow: key('home.eyebrow'), + title: highlighted(key('home.titlePre'), key('home.titleHighlight')), + lede: key('home.lede'), + section: 'py-16 sm:py-24', + h1: 'mt-3 max-w-3xl font-display text-4xl font-bold text-balance sm:text-6xl', + ledeClass: 'mt-6 max-w-2xl text-lg text-muted', + })}
`, + ), '/', pageTitle('home'), - describe(`${key('home.titleHighlight')} ${key('home.lede')}`), + describe(`${key('home.titlePre')} ${key('home.titleHighlight')} ${key('home.lede')}`), ), ); @@ -220,18 +261,23 @@ function prerenderShells(rootTargets: string[]): Plugin { // 87% render delay without it. for (const route of SHELL_ROUTES) { const title = route.highlight - ? `${esc(key(`${route.prefix}.titlePre`))} ${esc(key(`${route.prefix}.titleHighlight`))}` + ? highlighted(key(`${route.prefix}.titlePre`), key(`${route.prefix}.titleHighlight`)) : esc(key(`${route.prefix}.title`)); - const hero = - `
` + - `
` + - `

${esc(key(`${route.prefix}.eyebrow`))}

` + - `

${title}

` + - `

${esc(key(`${route.prefix}.lede`))}

`; const shell = withMeta( base .replace('', () => `${preload(route.facade)} `) - .replace('
', () => `
${hero}
`), + .replace( + '
', + () => + `
${hero({ + eyebrow: key(`${route.prefix}.eyebrow`), + title, + lede: key(`${route.prefix}.lede`), + section: 'py-12 sm:py-16', + h1: 'mt-3 max-w-3xl font-display text-3xl font-bold text-balance sm:text-5xl', + ledeClass: 'mt-5 max-w-2xl text-lg text-muted', + })}
`, + ), `/${route.dir}/`, pageTitle(route.titleKey), describe(key(`${route.prefix}.lede`)), @@ -240,6 +286,61 @@ function prerenderShells(rootTargets: string[]): Plugin { writeFileSync(join(outDir, route.dir, 'index.html'), shell); } + // V41 — one shell per documentation page. Measured on production + // (29 Sep 2026): the twelve `/docs/` URLs the sitemap advertised + // were served by the root fallback — home title, home Open Graph, a + // canonical pointing at `/`, and an empty root. A crawler saw twelve + // duplicates of the home page; a tutorial link pasted into a chat + // previewed the home page. The Markdown is already compiled at build + // time (`vite-docs.ts`), so the shell carries the finished article: the + // page reads without JavaScript, and its own title, summary and + // canonical. Written as `docs/.html` because Pages serves that at + // the clean URL `/docs/` — the form the sitemap and the app's + // links already use — where a directory would add a redirect to `/`. + // English, like every other shell; the app switches on mount. + const docPages = readDocs(); + const docSlugs = [...new Set(docPages.map((page) => page.slug))].sort(); + const docsFacade = SHELL_ROUTES.find((route) => route.dir === 'docs')!.facade; + for (const slug of docSlugs) { + const page = + docPages.find((candidate) => candidate.slug === slug && candidate.lang === 'en') ?? + docPages.find((candidate) => candidate.slug === slug)!; + const toc = page.headings + .map( + (heading) => + `${esc(heading.text)}`, + ) + .join(''); + const body = + `
` + + `
${page.html}
` + + `
`; + const shell = withMeta( + base + .replace('', () => `${preload(docsFacade)} `) + .replace( + '
', + () => + `
${hero({ + eyebrow: key('docs.eyebrow'), + title: esc(page.title), + lede: page.summary, + section: 'py-12 sm:py-16', + h1: 'mt-3 max-w-3xl font-display text-3xl font-bold text-balance sm:text-5xl', + ledeClass: 'mt-5 max-w-2xl text-lg text-muted', + body, + })}
`, + ), + `/docs/${slug}`, + `${page.title} · ${suffix}`, + describe(page.summary), + ); + writeFileSync(join(outDir, 'docs', `${slug}.html`), shell); + } + // A sitemap built from the routes that exist, plus the documentation // slugs read from the Markdown itself: a page added to `src/content/docs` // appears here without anyone remembering to list it, and a page removed @@ -353,7 +454,21 @@ export default defineConfig({ globPatterns: ['**/*.{js,css,html,svg,png,woff2,csv}'], // The vision model and ONNX runtime are cached on first use instead of // being precached — they would bloat the install for non-vision users. - globIgnores: ['models/**', 'ort/**', 'ort-llm/**', 'llm/**', 'duckdb/**'], + // V41 — the twelve documentation shells are left out too: each one + // embeds the inlined stylesheet and fonts (~130 KB), and the app + // already renders every doc page offline from its bundled `DOCS` + // module through the navigation fallback. Precaching them would add + // ~1.5 MB to every install for pages that already work offline. The + // section index (`docs/index.html`) stays precached like every other + // section shell — the extglob spares it. + globIgnores: [ + 'models/**', + 'ort/**', + 'ort-llm/**', + 'llm/**', + 'duckdb/**', + 'docs/!(index).html', + ], navigateFallback: '/index.html', maximumFileSizeToCacheInBytes: 4 * 1024 * 1024, runtimeCaching: [ From fcbfd3acd13375b4fa4764e44818294fe4edb2f7 Mon Sep 17 00:00:00 2001 From: dapiced Date: Tue, 29 Sep 2026 17:14:18 -0400 Subject: [PATCH 3/6] feat(v41): a real 404, a bare shell for dynamic routes, and Pages' routing under test Measured with wrangler pages dev: the one rule in _redirects, /* /index.html 200, was INVALID and ignored - Pages strips /index.html from URLs, so the rewrite would loop - and the 200 every unknown address answered came from the default single-page fallback. A misspelled URL was a soft 404. The build now emits 404.html (served with a 404 by Pages, painting the not-found hero before JavaScript) and shell.html (the site card, an empty root, noindex), and _redirects lists only the routes that have no file of their own - a run, a comparison, a share link - rewritten to the clean URL /shell with a 200, exact sources before splats. The service worker's navigation fallback moves to shell.html so an offline run page no longer flashes the home hero. Two tests hold it together: src/app/redirects.test.ts derives the expected rule set from router.tsx and the shell routes in vite.config.ts, so a route added to one side and not the other fails; e2e/routing.spec.ts runs in a new Playwright project against wrangler pages dev, the one server that applies _redirects, _headers and 404.html the way production does - 404 on unknown addresses and missing assets, 200 on dynamic routes, the CSP actually served. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- e2e/routing.spec.ts | 80 ++++++++++++++++++++++++++++++++++++++ e2e/shells.spec.ts | 8 ++-- playwright.config.ts | 33 +++++++++++++--- public/_redirects | 20 +++++++++- src/app/redirects.test.ts | 72 ++++++++++++++++++++++++++++++++++ vite.config.ts | 82 ++++++++++++++++++++++++++++++++------- 6 files changed, 270 insertions(+), 25 deletions(-) create mode 100644 e2e/routing.spec.ts create mode 100644 src/app/redirects.test.ts diff --git a/e2e/routing.spec.ts b/e2e/routing.spec.ts new file mode 100644 index 0000000..5e4ecce --- /dev/null +++ b/e2e/routing.spec.ts @@ -0,0 +1,80 @@ +import { expect, test } from '@playwright/test'; + +/** + * V41 — what Cloudflare Pages does with a URL, checked against Pages' own + * routing rather than the dev server's. + * + * `vite preview` answers every unknown path with `index.html` and a 200, and + * ignores `_redirects`, `_headers` and `404.html` — so nothing in the rest of + * the suite could tell a real 404 from a soft one. This spec runs in the + * `pages` project only, against `wrangler pages dev dist`, which applies the + * same asset routing production does. + * + * Measured on production before V41: every misspelled address answered 200, + * and the file's one rule (`/* /index.html 200`) was silently INVALID — Pages + * strips `/index.html` from URLs, so the rewrite would loop and the parser + * dropped it. The 200 came from the default single-page fallback instead. + */ +test.use({ locale: 'en-US' }); + +test('an unknown address answers 404 with the not-found page, not indexed', async ({ request }) => { + const response = await request.get('/this-address-does-not-exist'); + expect(response.status()).toBe(404); + const html = await response.text(); + expect(html).toContain('Page not found · LabML'); + expect(html).toContain(''); + // A 404 has no canonical: there is nothing there to be the canonical of. + expect(html).not.toContain('rel="canonical"'); +}); + +test('a misspelled documentation slug answers 404 too', async ({ request }) => { + expect((await request.get('/docs/this-page-does-not-exist')).status()).toBe(404); +}); + +test('a missing asset answers 404, never HTML with a 200', async ({ request }) => { + const response = await request.get('/assets/this-chunk-does-not-exist.js'); + expect(response.status()).toBe(404); +}); + +test('a run, a comparison and a share link answer 200 with the bare shell', async ({ request }) => { + for (const path of ['/ml/run/abc', '/ml/compare/a/b', '/ml/compare-many/a,b,c', '/ml/share']) { + const response = await request.get(path); + expect(response.status(), path).toBe(200); + const html = await response.text(); + // No hero: these pages describe one visitor's local data and the app + // paints them; a hero would show the wrong content for a frame. + expect(html, path).not.toMatch(/]/); + expect(html, path).toContain(''); + // Previews still work — a share link pasted in a chat shows the site card. + expect(html, path).toContain('
'); + } +}); + +test('the home page, a section and a documentation page answer 200 with their hero', async ({ + request, +}) => { + const pages: [string, string][] = [ + ['/', 'A machine learning lab,'], + ['/ml/', 'From a CSV to a leaderboard'], + [ + '/docs/premier-modele', + 'rel="canonical" href="https://app.dominicdapice.com/docs/premier-modele"', + ], + ]; + for (const [path, text] of pages) { + const response = await request.get(path); + expect(response.status(), path).toBe(200); + const html = await response.text(); + expect(html, path).toContain(text); + expect(html, path).toMatch(/]/); + } +}); + +test('the security headers are served by the asset routing, not only declared', async ({ + request, +}) => { + const headers = (await request.get('/')).headers(); + expect(headers['content-security-policy']).toContain("default-src 'self'"); + expect(headers['x-frame-options']).toBe('DENY'); +}); diff --git a/e2e/shells.spec.ts b/e2e/shells.spec.ts index bd953b7..e3f04a8 100644 --- a/e2e/shells.spec.ts +++ b/e2e/shells.spec.ts @@ -176,9 +176,11 @@ test('the sitemap lists every page that exists, and nothing that does not', asyn * index. There is no error and no visible symptom, which is exactly why this * belongs in a test rather than in someone's memory. * - * The check is on the CONTENT, never the status code: `_redirects` sends every - * unknown path to `index.html` with HTTP 200, so a missing file still answers - * 200 — with HTML. That trap cost a false positive during the V35 audit. + * The check is on the CONTENT, never the status code: `vite preview`, which + * serves this suite, answers every unknown path with `index.html` and a 200, + * so a missing file would still answer 200 — with HTML. That trap cost a false + * positive during the V35 audit. (Production answers a real 404 since V41; + * `routing.spec.ts` checks that side against Pages' own routing.) */ test('the Bing site verification file is served from the root', async ({ request }) => { const response = await request.get('/BingSiteAuth.xml'); diff --git a/playwright.config.ts b/playwright.config.ts index 61f5149..3ba75cc 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -26,7 +26,7 @@ export default defineConfig({ // still runs once, and only the specs that can actually catch a viewport or // a theme regression are replayed. projects: [ - { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, + { name: 'chromium', use: { ...devices['Desktop Chrome'] }, testIgnore: /routing\.spec\.ts/ }, { name: 'mobile', testMatch: /(layout|a11y)\.spec\.ts/, @@ -58,10 +58,31 @@ export default defineConfig({ testMatch: /a11y\.spec\.ts/, use: { ...devices['Desktop Chrome'], colorScheme: 'dark' }, }, + { + // V41 — Cloudflare Pages' routing, emulated. `vite preview` serves + // `index.html` with a 200 for every unknown path and ignores + // `_redirects`, `_headers` and `404.html`, so the suite above cannot + // tell a real 404 from a soft one, nor see the headers production + // sends. `wrangler pages dev` applies the same asset routing Pages + // does; only the spec that needs it runs there. + name: 'pages', + testMatch: /routing\.spec\.ts/, + use: { ...devices['Desktop Chrome'], baseURL: 'http://127.0.0.1:8788' }, + }, + ], + webServer: [ + { + command: 'npm run preview', + url: 'http://127.0.0.1:4173', + reuseExistingServer: !process.env.CI, + }, + { + command: 'npx wrangler pages dev dist --port 8788 --ip 127.0.0.1', + url: 'http://127.0.0.1:8788/', + reuseExistingServer: !process.env.CI, + timeout: 120_000, + // No telemetry from the test runner, and no interactive prompt. + env: { WRANGLER_SEND_METRICS: 'false', CI: '1' }, + }, ], - webServer: { - command: 'npm run preview', - url: 'http://127.0.0.1:4173', - reuseExistingServer: !process.env.CI, - }, }); diff --git a/public/_redirects b/public/_redirects index 7797f7c..2630fbe 100644 --- a/public/_redirects +++ b/public/_redirects @@ -1 +1,19 @@ -/* /index.html 200 +# V41 — Cloudflare Pages serves exact files first (the prerendered shells, the +# documentation pages, every asset), then applies these rules, then 404.html. +# Only the routes that have no file of their own are listed: a run, a +# comparison, a share link — pages that describe one visitor's local data and +# that the app paints. They are handed the bare shell at its clean URL, with a +# 200 so the address in the bar stays what the visitor typed. +# +# Before V41 the single rule was `/* /index.html 200`. `wrangler pages dev` +# reports it as INVALID and ignores it — Pages strips `/index.html` from URLs, +# so the rewrite would loop — and the 200 on every unknown address came from +# the default single-page fallback instead: a misspelled URL was a soft 404. +# With a 404.html in the build that fallback is off, so this list must be +# exact; `src/app/redirects.test.ts` keeps it aligned with the router. +# +# Exact sources first, splats after — the order the Pages parser asks for. +/ml/share /shell 200 +/ml/run/* /shell 200 +/ml/compare/* /shell 200 +/ml/compare-many/* /shell 200 diff --git a/src/app/redirects.test.ts b/src/app/redirects.test.ts new file mode 100644 index 0000000..c5bf6c4 --- /dev/null +++ b/src/app/redirects.test.ts @@ -0,0 +1,72 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +/** + * V41 — `public/_redirects` and `src/app/router.tsx` describe the same routes, + * from two sides. The router says which paths the app answers; the redirects + * file says which of them Cloudflare Pages must hand to the app because no + * static file exists for them (a run, a comparison, a share link). A route + * added to one and not the other is a page that renders in the dev server and + * answers 404 in production — the kind of gap nothing else would report. + * + * Until V41 the file held `/* /index.html 200`, which `wrangler pages dev` + * reports as an INVALID rule (Pages strips `/index.html`, so the rewrite loops + * and is ignored): the 200 on unknown URLs came from the default SPA fallback, + * and every misspelled address was a soft 404. A `404.html` now takes that + * role with the right status, so the rewrite list must be exact. + */ +const redirects = readFileSync('public/_redirects', 'utf8'); +const router = readFileSync('src/app/router.tsx', 'utf8'); +const viteConfig = readFileSync('vite.config.ts', 'utf8'); + +const rules = redirects + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line !== '' && !line.startsWith('#')) + .map((line) => { + const [source, destination, status] = line.split(/\s+/); + return { source, destination, status }; + }); + +/** Every `path: '…'` the router declares, as written. */ +const routerPaths = [...router.matchAll(/path: '([^']+)'/g)].map((match) => match[1]); +/** Every section with a prerendered shell (`dir: '…'` in vite.config.ts). */ +const shellDirs = [...viteConfig.matchAll(/\bdir: '([^']+)'/g)].map((match) => match[1]); + +/** `ml/run/:id` → `/ml/run/*`, `ml/share` → `/ml/share`. */ +function toSource(path: string): string { + const param = path.indexOf(':'); + return `/${param === -1 ? path : `${path.slice(0, param)}*`}`; +} + +describe('_redirects', () => { + it('has no catch-all: unknown addresses must reach 404.html with a 404', () => { + expect(rules.map((rule) => rule.source)).not.toContain('/*'); + }); + + it('rewrites every app route that has no static file, and nothing else', () => { + const expected = routerPaths + .filter((path) => path !== '/' && path !== '*') + // Sections with a shell and doc pages are exact files on disk. + .filter((path) => !shellDirs.includes(path) && !path.startsWith('docs')) + .map(toSource) + .sort(); + expect(rules.map((rule) => rule.source).sort()).toEqual(expected); + expect(expected.length).toBeGreaterThanOrEqual(4); + }); + + it('serves the bare shell at its clean URL, with a 200, for each of them', () => { + for (const rule of rules) { + // `/shell.html` would be answered with a 308 to `/shell` — the very + // loop that made the old rule invalid. The clean URL is the asset. + expect(rule.destination, rule.source).toBe('/shell'); + expect(rule.status, rule.source).toBe('200'); + } + }); + + it('lists exact sources before splats, as the Pages parser recommends', () => { + const firstSplat = rules.findIndex((rule) => rule.source.includes('*')); + const lastExact = rules.map((rule) => rule.source.includes('*')).lastIndexOf(false); + expect(lastExact).toBeLessThan(firstSplat); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index e1854c2..a0d52e6 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -166,11 +166,25 @@ function prerenderShells(rootTargets: string[]): Plugin { * Replace the shared head metadata with this page's own, and add what * the shells never had: a canonical URL, Open Graph and a Twitter card. * Without them a LabML link pasted anywhere shows no preview at all. + * + * `index: false` (V41) is for pages a crawler must not keep: the bare + * shell behind a run or a share link, and the 404 page. They get the + * social card — a share link pasted in a chat still previews — but a + * `noindex` instead of a canonical, since there is nothing there to be + * the canonical of. */ - const withMeta = (html: string, path: string, title: string, description: string) => { + const withMeta = ( + html: string, + path: string, + title: string, + description: string, + { index = true } = {}, + ) => { const url = `${SITE}${path}`; const tags = [ - ``, + index + ? `` + : ``, ``, ``, ``, @@ -222,17 +236,15 @@ function prerenderShells(rootTargets: string[]): Plugin { const highlighted = (pre: string, highlight: string) => `${esc(pre)} ${esc(highlight)}`; - // The root file is also the SPA fallback, so it answers for every route - // without a shell of its own — the home page's metadata is the honest - // default there. Written after the helpers above exist, and from a - // `base` that still carries none, so the shells below inherit preloads - // and stylesheet but never the home page's title or Open Graph tags. - // - // V41 — it now carries the home hero too. Measured on production: `/` - // was the one section that painted nothing before JavaScript, and the - // one most visitors land on. Its description also opened mid-sentence - // (« entirely in your browser. Drop a dataset… ») because only the - // highlighted half of the title was prepended to the lede. + // The root file is the home page. Until V41 it was also the SPA + // fallback, so it could carry no hero (a run page would have flashed + // the home title for a frame) and `/` was the one section painting + // nothing before JavaScript — the one most visitors land on. Its + // description also opened mid-sentence (« entirely in your browser. + // Drop a dataset… ») because only the highlighted half of the title was + // prepended to the lede. Written from a `base` that carries no metadata + // yet, so the shells below inherit preloads and stylesheet but never the + // home page's title or Open Graph tags. writeFileSync( htmlPath, withMeta( @@ -256,6 +268,42 @@ function prerenderShells(rootTargets: string[]): Plugin { ), ); + // V41 — the bare shell: what `public/_redirects` hands to the routes + // that have no file of their own (a run, a comparison, a share link). + // It is exactly what the root file used to be — the site's card for + // previews, an empty root for the app to paint — plus a `noindex`, + // because those pages describe one visitor's local data. Also the + // service worker's navigation fallback, for the same reason: offline, a + // run page must not flash the home hero for a frame. + writeFileSync( + join(outDir, 'shell.html'), + withMeta(base, '/', pageTitle('home'), describe(key('home.lede')), { index: false }), + ); + + // V41 — a real 404. Pages serves this file, with a 404 status, for every + // address no file and no rule answers; without it the platform falls + // back to `index.html` with a 200 and every misspelled URL is a soft + // 404. The app then mounts and renders its own not-found page over + // this hero — same words, painted before JavaScript. + writeFileSync( + join(outDir, '404.html'), + withMeta( + base.replace( + '
', + () => + `
` + + `
` + + `

404

` + + `

${esc(key('notFound.title'))}

` + + `

${esc(key('notFound.lede'))}

`, + ), + '/', + pageTitle('notFound'), + describe(key('notFound.lede')), + { index: false }, + ), + ); + // Cloudflare Pages serves exact files before the SPA fallback, so only // direct visits get this head start — measured LCP driver on /ml was // 87% render delay without it. @@ -460,7 +508,8 @@ export default defineConfig({ // module through the navigation fallback. Precaching them would add // ~1.5 MB to every install for pages that already work offline. The // section index (`docs/index.html`) stays precached like every other - // section shell — the extglob spares it. + // section shell — the extglob spares it. The 404 page is not needed + // offline either. globIgnores: [ 'models/**', 'ort/**', @@ -468,8 +517,11 @@ export default defineConfig({ 'llm/**', 'duckdb/**', 'docs/!(index).html', + '404.html', ], - navigateFallback: '/index.html', + // V41 — the bare shell, not the home page: offline, a run or a share + // link must not flash the home hero for a frame before the app mounts. + navigateFallback: '/shell.html', maximumFileSizeToCacheInBytes: 4 * 1024 * 1024, runtimeCaching: [ { From cbb8d7a525443e8768bd854430e10993b19bf2da Mon Sep 17 00:00:00 2001 From: dapiced Date: Tue, 29 Sep 2026 17:19:03 -0400 Subject: [PATCH 4/6] feat(v41): the home page says what is new, and the size guidance moves to the drop zone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The status card said « the three modules are live » and listed them - a sentence written at V22 and untouched through the eighteen waves that followed (SQL, the local chat, forecasts, documentation, the privacy page). It is now « What's new »: a current one-paragraph map of the five areas, the latest delivered wave read from the build version (Vite define from package.json, which the changelog test pins to PLAN.md's last wave) and a link to the CHANGELOG. Both languages; HomePage.test.tsx checks the wave, the link and that the old sentence is gone. The « PS: » about dataset size leaves the /ml lede - a hero is not the place for a postscript - and becomes a short note under the drop zone, where the file arrives. The README carried the same LIMITATION line twice; once is enough. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 2 - src/features/home/HomePage.test.tsx | 57 +++++++++++++++++++++++++ src/features/home/HomePage.tsx | 19 +++++++++ src/features/ml/components/DropZone.tsx | 2 + src/locales/en.json | 9 ++-- src/locales/fr.json | 9 ++-- src/types/app.d.ts | 7 +++ vite.config.ts | 10 +++++ 8 files changed, 107 insertions(+), 8 deletions(-) create mode 100644 src/features/home/HomePage.test.tsx create mode 100644 src/types/app.d.ts diff --git a/README.md b/README.md index bca581a..8714ee4 100644 --- a/README.md +++ b/README.md @@ -90,8 +90,6 @@ contributions](docs/screenshots/insights.png) _Every figure above was produced by the app itself, on the `titanic.csv` sample, seed 42 — reproducible by pressing train._ -**LIMITATION: The ideal dataset size is between 1MB and 30MB; beyond 30 MB, the browser response time may take longer to return the results.** - ### Data Studio — `/data` - **Quality report** with a deterministic 0–100 score: missing cells, duplicates, diff --git a/src/features/home/HomePage.test.tsx b/src/features/home/HomePage.test.tsx new file mode 100644 index 0000000..143ca4a --- /dev/null +++ b/src/features/home/HomePage.test.tsx @@ -0,0 +1,57 @@ +import { render, screen } from '@testing-library/react'; +import { readFileSync } from 'node:fs'; +import { MemoryRouter } from 'react-router'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { HomePage } from '@/features/home/HomePage'; +import i18n from '@/lib/i18n'; + +function renderHome() { + return render( + + + , + ); +} + +/** + * V41 — the home page's status card. It said « the three modules are live » + * and listed them, a sentence written at V22 and never touched through the + * eighteen waves that followed: SQL, the local chat, the forecasts, the + * documentation, the privacy page. The card now names the latest delivered + * wave from the build's version — which `changelog.test.ts` pins to the last + * wave PLAN.md records — and points at the CHANGELOG for the rest. + */ +describe('HomePage — what is new', () => { + const { version } = JSON.parse(readFileSync('package.json', 'utf8')) as { version: string }; + const wave = `V${version.split('.')[1]}`; + + beforeEach(async () => { + await i18n.changeLanguage('en'); + }); + + it('names the latest delivered wave, read from the build version', () => { + renderHome(); + expect(screen.getByText(new RegExp(`\\b${wave}\\b`))).toBeInTheDocument(); + }); + + it('links to the CHANGELOG on the repository', () => { + renderHome(); + expect(screen.getByRole('link', { name: /changelog/i })).toHaveAttribute( + 'href', + 'https://github.com/dapiced/LabML/blob/main/CHANGELOG.md', + ); + }); + + it('no longer counts three modules', () => { + renderHome(); + expect(screen.queryByText(/three modules/i)).toBeNull(); + expect(screen.getByText("What's new")).toBeInTheDocument(); + }); + + it('says the same in French', async () => { + await i18n.changeLanguage('fr'); + renderHome(); + expect(screen.getByText('Quoi de neuf')).toBeInTheDocument(); + expect(screen.getByText(new RegExp(`\\b${wave}\\b`))).toBeInTheDocument(); + }); +}); diff --git a/src/features/home/HomePage.tsx b/src/features/home/HomePage.tsx index 3fe3d59..2be50d3 100644 --- a/src/features/home/HomePage.tsx +++ b/src/features/home/HomePage.tsx @@ -4,6 +4,15 @@ import { Link } from 'react-router'; import { Card } from '@/components/ui/card'; import { Eyebrow } from '@/components/ui/eyebrow'; +/** + * V41 — `1..0`: the minor is the latest delivered wave, kept there by + * `npm run changelog` and pinned to PLAN.md by its test. The card used to say + * « the three modules are live », a sentence written at V22 and untouched + * through the eighteen waves that followed. + */ +const LATEST_WAVE = `V${__APP_VERSION__.split('.')[1]}`; +const CHANGELOG_URL = 'https://github.com/dapiced/LabML/blob/main/CHANGELOG.md'; + export function HomePage() { const { t } = useTranslation(); @@ -54,6 +63,16 @@ export function HomePage() { {t('home.statusTitle')}

{t('home.statusBody')}

+

+ {t('home.latestWave')} {LATEST_WAVE} + {' · '} + + {t('home.changelog')} + +

diff --git a/src/features/ml/components/DropZone.tsx b/src/features/ml/components/DropZone.tsx index 61224a7..8b45ac4 100644 --- a/src/features/ml/components/DropZone.tsx +++ b/src/features/ml/components/DropZone.tsx @@ -40,6 +40,8 @@ export function DropZone() {