For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
1Context
Milestones 1, 2 and 3A and the pre-3B review changes are merged into main (main at a5c3801). On 2026-10-15 Fadel M. Megahed split Milestone 3 into three plans:
- 3A, merged: the translation service (
POST /api/translate, recorded fixtures inreplication_materials/tests/fixtures/translate/). - 3B, this plan: the interface. Step 1 (Describe), step 2 (Confirm, with the pre-checks), step 3 (Design), the help panels and the glossary, the global announcer and the privacy page. It also makes project storage ready for reports, because 3B is the first milestone that stores them.
- 3C: exports, import and the Report step, with the full end-to-end flow and the accessibility sweep.
On 2026-10-16 Fadel M. Megahed made four decisions for this plan (P1 to P4 below): a stored report of an older report version is computed again from the spec and the seed; Milestone 3B does not wait for Alan R. Vazquez's confirmation of R23 and of the Dark2 purple, and a one-page note asks him; Claude drafts the glossary, and each entry shows "Draft, not yet reviewed" until Alan R. Vazquez signs it; the references for Section 1.8 are a verified list that Alan R. Vazquez confirms. He also asked that the document pages use the purple accent (P5).
Goal: A person can describe an experiment, confirm the specification card with its agreement flags and pre-checks, and get the FrF2 design with its properties in the browser, with help at each ? button, a glossary, one global announcer and a privacy page; the projects keep their reports safely on the device.
Architecture: The logic is in lib/ and has unit tests: a client-safe response schema and fetch client for the translate route, a pure card model (build from a translation, edit, validate, labels), the pre-checks on the catalogue summary, the report explanations and the run-sheet SHA-256. The React components in components/steps/, components/spec-card/, components/design/ and components/help/ are thin and are tested in Chromium against the production build, with /api/translate answered from the recorded fixtures. The project store resolves writes on transaction completion, never hides a stored record, and marks a project whose report has an older version for a rebuild in step 3.
Tech Stack: Next.js 16.3.8 (App Router), React 19.3.0, Tailwind CSS 4.3.3, zod 4.6.5, WebR 0.6.0 (R 4.6.0) with FrF2 2.3-5 and DoE.base 1.2-5, KaTeX 0.18.11 (build time only), vitest 4.1.11 with fake-indexeddb 6.2.5, Playwright 1.63.0 (Chromium), testthat in native R 4.6.1.
Spec: replication_materials/plans/2026-10-14-ff-design-translator-design.md (version 3), Sections 1.8, 4.2, 4.3, 5.1, 5.5 to 5.7, 6, 7.3, 7.5, 7.7 to 7.9, 8.1, 8.2, 8.5, 9, 11, 13.3, 14 and 17.1. The 3A plan and ledger (2026-10-15-m3a-translation-service-plan.md, 2026-10-16-m3a-execution-ledger.md) give the API contract and the items carried to 3B; the Milestone 2 ledger (2026-10-15-m2-execution-ledger.md) gives the storage items carried to Milestone 3.
2Global Constraints
- Versions. Pin every npm package exactly, with no
^or~(.npmrchassave-exact=trueandlegacy-peer-deps=true). One new package:katex@0.18.11as a devDependency (published 2026-10-01; 0.19.0 came out 21 minutes later and is a new minor line). No new runtime dependency and no new test library. - Dropbox. The repository is in Dropbox, which locks
node_moduleswhile it syncs (EBUSY). Beforenpm install, ask the user to pause Dropbox sync. - Client and server. Client components never import
lib/translate/config.ts,service.ts,handler.ts,llm-voter.ts,jev-voter.ts,translate.ts,agreement.tsorlib/ops/*at run time.import typefrom any module is allowed. Client code may importlib/translate/schema.ts,messages.ts,evidence.ts,numbers.ts,text.ts,response.tsandclient.ts. The privacy notice comes from the server page as a prop, becauseprocess.env.TRANSLATE_*is empty in a client component. - JSON imports. Every module that a Playwright spec imports, directly or through other modules, imports JSON only with
with { type: "json" }. - No LLM call in tests. Browser tests answer
/api/translatefrom the recorded fixtures (3A decision P14); a test may derive a new answer from a fixture (for example with a disagreement), and says so. No live call in this plan. - Fixed texts. Every message the interface shows for a translation outcome, a pre-check or a storage failure is in
content/messages.jsonor is a fixed string in one module with a unit test. The scope messages and the pre-check messages contain none of the advice words "try", "instead", "recommend", "consider", "should" (whole words; "entry" is allowed). - Wording. Plain academic wording (spec 9.7): no marketing, no exclamation marks, labels as in the spec ("From your text", "default", "default (today's date)", "Edited by you", "Check this value", "Not cross-checked", "R code"). Comments and documents are about 80% ASD-STE100.
- Accessibility. WCAG 2.1 AA. One global announcer: the app's own markup has exactly two live regions (one polite with
role="status", one assertive), both incomponents/a11y/Announcer.tsx. The route announcer of Next.js (in the shadow root of<next-route-announcer>) is the framework's and reads only the page title after a navigation. Every input has a visible label. Status is always text. A?button is a real<button>witharia-expandedandaria-controls. Focus moves to the step heading after a step change (existing). - Fail closed. A translation answer that does not match the response schema is shown as "The interpretation service failed. You can fill in the form yourself." A report with
ok: falseshows "Report check failed. The design is not shown." and the failed checks, and no table (spec 7.8). - Storage. A write resolves only when its transaction completes. No stored record is hidden. A project whose report waits for a rebuild is never written until a new report replaces it.
- R. R runs through the PowerShell tool:
& "C:\Program Files\R\R-4.6.1\bin\Rscript.exe" <file>. Native R tests run withnpm run test:r. - Tests. All tests are in
replication_materials/tests/.npm run checkmust pass at the end of each task that changes code. - Outside contact. The npm registry only (for KaTeX). Nothing is pushed. Only the main session publishes the private plan and spec pages and the note for Alan R. Vazquez.
- Commits. One commit per task unless a task says otherwise. Every commit message ends with these two lines (the
git commitcommands in the tasks show only the subject line):Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>Claude-Session: https://claude.ai/code/session_01Wc7WXMFHs2SXBhwsTi2xnf
3Review Focus
These are the five inputs or conditions that a person is most likely to meet and that the spec does not test directly. Each has tests in the task that owns its code.
- A translation that is slow, refused or lost: the 40 s deadline, a dropped network, HTTP 429 (rate limit), 503 (paused or no key), a body that is not the expected shape, and the user's Cancel. The step shows the fixed message, "Fill in the form yourself" works, and nothing is lost from the request text. Tests: Task 2 (client), Task 6 (Chromium: paused, rate limited, broken body, Cancel).
- Stored data from an earlier version or a damaged record: a Milestone 2 record without the new fields, a report of report version 1.1.0, a record that matches no schema, a write that the browser aborts (full disk), another tab that upgrades the database. Nothing is hidden, nothing is overwritten by accident. Tests: Task 1 (unit), Task 9 (Chromium).
- Edits that make the specification invalid: an empty name, two names with the same R name ("Temp" and "Temp!"), equal levels, a quantitative factor with text levels, a seed out of range, center points with a qualitative factor, more factors than the run size holds. The card names each problem as a fact, and "Generate design" stays unavailable. Tests: Task 4 (unit), Task 3 (pre-checks), Task 7 (Chromium).
- Large designs: 4096 runs, 65 factors, long alias lists and a long WLP. The run sheet has pages, the properties stay readable, and the help sentences stay short. Tests: Task 8 (unit with a synthetic large report, Chromium with a 256-run design).
- Keyboard and screen-reader use: factor moves are announced ("temperature moved to position 2 of 7."), help panels open in place with
aria-expanded, "Show explanations" is remembered, and only the global announcer speaks. Tests: Task 5 and Task 7 (Chromium).
4Decisions made during planning
The plan makes these decisions. Each one has a reason and a cost if it is wrong. The authors can change any of them before execution.
| # | Decision | Reason | Cost if wrong |
|---|---|---|---|
| P1 | A stored report with another report_version is not hidden. The store reads the project with report: null and pending_rebuild: { report_version }. Step 3 runs R again from the stored spec and seed and compares the run-sheet SHA-256. If it is equal, the new report replaces the old one; if it differs, the stored record stays unchanged and the step names the versions that differ. A record that matches no schema is listed in the sidebar as "cannot be opened", with Export and Delete. |
Decision of F. M. Megahed (2026-10-16): "Recompute from spec + seed". Spec 8.5 already compares the SHA-256 on import. | One module (store.ts) and one branch of step 3. |
| P2 | Milestone 3B uses R23 and the accent #625d96 as they are. The main session publishes a one-page note that asks Alan R. Vazquez to confirm both, and the references of P4. |
Decision of F. M. Megahed (2026-10-16). A change after his answer is small: R23 is one report version step, the accent is two tokens. | One report version step or two tokens. |
| P3 | Claude drafts the glossary (35 entries). An entry with reviewed_by: null shows "Draft, not yet reviewed" in its panel and on the glossary page. The test "every ? key has an entry" runs now; the test "every entry is reviewed" runs only with FFD_RELEASE=1 and is a release gate (Milestone 4). |
Decision of F. M. Megahed (2026-10-16). Spec 11 asks for reviewed entries; the review is an open item for Alan R. Vazquez. | Spec 11 wording; one skipIf. |
| P4 | The references are Wu and Hamada (2021, 3rd ed., Section 4.6), Box and Meyer (1986), Hamada and Wu (1992), Li, Sudarsanam and Frey (2006) and Ockuly et al. (2017), checked against Crossref on 2026-10-16. The glossary cites no number from Ockuly et al. (2017), because its abstract was not read. | Decision of F. M. Megahed (2026-10-16): Claude's candidates, Alan R. Vazquez confirms. | The list changes after his answer. |
| P5 | The document pages (scripts/spec-page.css, figures/architecture.svg) use the accent #625d96 (text accent #4c4977), and #9e9ad0 and #b0addb in the dark theme, with the ribbon #625d96 (light) and #57538a (dark). The old mockups and the earlier plan pages keep their colours, because they record what was decided then. |
Question of F. M. Megahed (2026-10-16). Measured: every pair has at least 5.02:1 (light) and 6.03:1 (dark). | Six values in one stylesheet. Done in the plan commit. |
| P6 | Logic lives in lib/ with vitest unit tests in Node. Components are thin and are tested in Chromium against the production build. No React Testing Library and no jsdom. |
Milestone 2 tested the shell this way. A second component test stack would test a different DOM from the one the user gets. | Component logic that slips into components is tested only end to end. |
| P7 | The project schema stays version 1. The new fields have defaults: card (null), frf2_messages ([]), pending_rebuild (null) and request.notice (null). A Milestone 2 record still reads. |
Additive fields with defaults need no migration; a version step would need one. | A later field that is not additive needs version 2 and a migration. |
| P8 | Pre-checks add two rules to spec 6.2: a factor limit for each run size (the catalogue has no entry for, for example, 40 factors in 1024 runs, and FrF2 then stops with "subscript out of bounds"), and the refusal of the 7 default sizes whose catalogue entry has the status broken (FrF2 uses 26-17.1 for 26 factors in 512 runs without a message). The rows limit uses , as the report does. One JSON file holds the cases for the TypeScript test and the native R test. |
Measured on 2026-10-16 (Facts below). One case file keeps the two tests the same. | Two messages in content/messages.json. |
| P9 | The card is stored in the project. Each factor has an id, so its labels and flags move with it when the order changes. A disagreement or a "Check this value" flag blocks "Generate design" until the user selects a value, confirms it or edits it. A disagreement on the number of factors is resolved by "Keep these factors" or by an edit of the list. | Spec 5.6 and 5.7. A flag tied to a position would move to the wrong factor after "Move up". | One reducer per action in lib/card/card.ts. |
| P10 | A field where all voters that gave it agree shows "{n} of {n} agree" only when (answered, not checked, because Jev gives only some fields). A field with one answer shows no agreement text; the voter summary says "Not cross-checked" when fewer than two voters are valid. The card ignores voters[].unread (accounting data). |
3A ledger: agree is true when only one voter answered; Jev answers only the numbers. |
One function in lib/card/labels.ts. |
| P11 | A quote ("From your text") is shown only for a value that passed the evidence check. A value in check_fields shows "Check this value" instead. |
3A final review M5. A failed quote would look like proof. | One condition in cardFromTranslation. |
| P12 | run_sheet_sha256 is the SHA-256 (hex) of the UTF-8 text JSON.stringify({ labels, run, std, levels, coded }) from the report. 3C writes the same value into the hidden sheet of the .xlsx file and checks it on import. |
Spec 8.3 and 8.5 need one definition of "the SHA-256 of the design". The labels are in it, because a renamed factor is a different run sheet. | 3C reads the definition from lib/report/sha.ts. |
| P13 | A help panel has three parts: "What it means" (the glossary text, the review state and the source), "In your design" (sentences that lib/report/explain.ts computes) and a link to the glossary entry. "Watch this part" comes with the video (Milestone 4). |
Spec 9.5. The video does not exist yet. | One link per panel in Milestone 4. |
| P14 | A new project gets its name from the first six words of the request, or "{k} factors in {n} runs" for a card filled by hand. A rename control comes with the project menu (3C). | Spec 9.1 shows names in the sidebar; a list of "Untitled design" is not usable. | One function in lib/projects/name.ts. |
| P15 | Step 3 starts the run when the project has a spec and no report, and stores the report after the checks pass. "Print" prints the page that is shown; "Download" comes with the exports (3C). | Spec 4.3, items 7 and 8; spec 8.3 places the files in 3C. | A large run sheet prints one page at a time until 3C. |
| P16 | The glossary source is content/glossary.json (text with \( ... \) math). scripts/build-glossary.mjs converts it with KaTeX (output: "mathml", throwOnError: false, trust: false) to data/glossary.json, which is committed; a unit test fails if the two differ. |
Spec 9.9: MathML at build time, no math fonts. A committed file keeps KaTeX out of the app bundle. | Run one script after each glossary edit. |
| P17 | Moved to 3C: the download progress of R.wasm (spec 17.1), the measurement of the main-thread parse of the largest reports (Milestone 2 ledger), the focus after Clear and Cancel and the size of the Home link (axe sweep). |
3B is already the largest Milestone 3 plan; 3C has the full end-to-end and accessibility sweep. | These wait one plan. |
| P18 | The FrF2 messages of the design call (conditions) are shown and stored in frf2_messages. The warnings of the report call (reportConditions) stay hidden. |
Spec 7.5 names FrF2 messages. The report helper's tests fail on any warning, so a report warning is a defect, not information. | One field. |
| P19 | A record that cannot be opened has Export (its stored JSON) and Delete in the sidebar. A project whose rebuild gives a different run sheet stays in storage unchanged; export of whole projects comes with the project menu (3C). | Spec 8.2: no silent failure. Export of a raw record needs no schema. | 3C adds project export. |
| P20 | The translate client validates every answer with a client zod schema (lib/translate/response.ts) and fails closed. A unit test checks that every recorded fixture matches it, and a type test checks that the schema and TranslateApiResponse describe the same object. Cancel aborts the request. |
3A final review M7 (fixture-shape test). A server change that the client does not know shows a fixed message, not a broken card. | One schema to keep with the route. |
5Facts measured during planning (2026-10-16)
| Fact | Value |
|---|---|
| FrF2, rule 1 | nruns = 12: error "nruns must be a power of 2."; nruns = 8192: error "less than 4 or more than 4096 runs are not covered by function FrF2." |
| FrF2, rule 2 | 8 factors in 8 runs: error "You can accomodate at most 7 factors in a FrF2 design with 8 runs." |
| FrF2, rule 3 | 32 runs for 4 factors: no error; FrF2 makes the 16-run full factorial twice (32 rows, nruns 16, type "full factorial"). |
| FrF2, rule 4 | 16 runs for 9 factors: entry 9-5.1, resolution III, no message. |
| FrF2, rule 5 | Resolution VI for 5 factors: message "full factorial design needed", 32-run full factorial. Resolution IX for 9 factors: entry 9-1.1 (256 runs). Resolution X for 9 factors: 512-run full factorial. |
| FrF2, rule 6 | Resolution XIV for 13 factors: an 8192-run full factorial, above the supported 4096 runs. |
| FrF2, rule 7 | 200 factors in 4096 runs, and 34 or 40 factors in 1024 runs, 66 in 4096, 81 in 256: error "subscript out of bounds". 16 replications of 4096 runs: 65,536 rows, no error. |
| FrF2, rule 8 | Center points with a factor of levels "a" and "b": warnings "NAs introduced by coercion", then error "some inherently qualitative factors were wrongly declared quantitative". |
| FrF2, broken entry | 26 factors in 512 runs: entry 26-17.1 (status broken), no message. Resolution IV for 26 factors: 26-20.1 (64 runs, status ok). |
| Catalogue limits | Most factors per run size: 4 → 3, 8 → 7, 16 → 15, 32 → 31, 64 → 63, 128 → 127, 256 → 80, 512 → 160, 1024 → 33, 2048 → 47, 4096 → 65. Factors 3 to 160 all have entries; 2 factors have none (full factorial only). Status counts of the 554 default sizes: 540 ok, 7 stored_wlp_error, 7 broken. |
| Expected rows | r/ffd_report.R: (ncube + center_points) * replications. data/supported-limits.json: max_runs 4096, max_total_rows 32768. |
| KaTeX | 0.18.11 published 2026-10-01T16:33Z; 0.19.0 published 2026-10-01T16:54Z. |
| References | Crossref records: Wu and Hamada (2021) 10.1002/9781119470007 (3rd ed.; the author's table of contents names "Fundamental Principles for Factorial Effects: Effect Hierarchy, Effect Sparsity, and Effect Heredity", Section 4.6); Box and Meyer (1986) 10.1080/00401706.1986.10488093; Hamada and Wu (1992) 10.1080/00224065.1992.11979383; Li, Sudarsanam and Frey (2006) 10.1002/cplx.20123 (113 published data sets); Ockuly et al. (2017) 10.1016/j.chemolab.2017.03.009 (abstract not retrieved). |
| Fixtures | 7 recorded answers, all 4 of 4 checked, no check_fields, no disagreement in the fields. In coating-design.json, Jev judged the outcome out_of_scope (1 of 4; the known misjudgement of level ranges, 3A ledger), so the coating card shows Jev's flag. The clarification fixture's tool turn equals the question of needs-clarification.json, and its answer is "Seven factors in 16 runs." |
| Planning check | The code of Tasks 1 to 10 was applied to a copy of main (a5c3801) outside Dropbox, as the plan writes it. npm run check passed: types; unit 356 passed, 1 skipped (the release gate of P3); WebR 101 passed; R tests with the 14 new pre-check behaviour tests; browser 40 passed. The tests found these faults in the first draft of the plan, and the plan text has the fixes: a Jev flag in the coating fixture, a missing return type, an ambiguous edit instruction, the Next.js route announcer, texts that the announcer and the hidden help panels repeat, the ? button names that getByLabel matches, a statement with its button in one element, and a confirmed value with its agreement text. Task 11 (documents) was checked against the spec text: each old text exists. |
The probe scripts and outputs (precheck-probe.R, gap-probe.R, dark-accent.mjs) are copied to replication_materials/plans/m3b-probes/ with this plan.
6Deferred minors of the pre-3B review
| # | Minor | In this plan |
|---|---|---|
| 1 | Spec 1.8 says the glossary "has entries" | Task 5 drafts them. |
| 2 | Document pages use Miami Red | Done in the plan commit (P5). |
| 3 | A misspelled feature_notes key compiles |
Task 2 adds a guard test. |
| 4 | A misspelled accent class passes the colour test | Not in this plan; the Chromium tests check the rendered accent of the main buttons. |
| 5 | No guard for an unknown all.2fis.clear string |
Not in this plan; FrF2 is pinned, and a version change runs the full sweep. |
| 6 | "not looked at" is informal | Task 11 changes the three places. |
| 7 | The "Drafted with" row omits version 3's effort | Not in this plan: the effort level of the version 3 and 4 sessions is not recorded where an executor can read it. The authors fill in the row. |
7File structure
| File | Responsibility |
|---|---|
lib/projects/schema.ts, lib/projects/store.ts |
Project fields with defaults, date checks, transaction-complete writes, onversionchange, records that cannot be opened, older reports (P1, P7) |
lib/report/types.ts |
REPORT_VERSION |
lib/translate/response.ts, lib/translate/client.ts |
Client schema of the route answer and the fetch client (P20) |
lib/spec/precheck.ts, lib/report/format.ts |
Pre-checks (spec 6.2, P8); Roman numerals and display helpers |
lib/card/card.ts, lib/card/labels.ts, lib/projects/name.ts |
The card model, its labels, project names (P9 to P11, P14) |
lib/a11y/announcer.ts, components/a11y/Announcer.tsx |
One global announcer |
content/glossary.json, scripts/build-glossary.mjs, data/glossary.json, lib/help/keys.ts, lib/help/glossary.ts |
Glossary source, MathML build and access (P3, P16) |
components/help/HelpContext.tsx, components/help/WithHelp.tsx, app/glossary/page.tsx |
Help panels, "Show explanations", the glossary page |
content/examples.json, components/steps/DescribeStep.tsx |
Step 1 |
components/steps/ConfirmStep.tsx, components/spec-card/*.tsx |
Step 2 |
lib/report/explain.ts, lib/report/sha.ts, components/steps/DesignStep.tsx, components/design/*.tsx |
Step 3 (P12, P13, P15, P18) |
components/shell/*.tsx, app/app/page.tsx, app/layout.tsx |
Workspace wiring, sidebar records, footer links |
app/privacy/page.tsx |
Privacy details (spec 13.3, 17.1) |
replication_materials/tests/fixtures/precheck-cases.json, replication_materials/tests/r/test-precheck-behaviour.R |
Pre-check cases and the FrF2 behaviour each rule prevents |
replication_materials/tests/unit/**, replication_materials/tests/e2e/*.spec.ts, replication_materials/tests/e2e/helpers/translate-fixtures.ts |
Unit tests and Chromium tests |
8Task 1: Project storage ready for reports
Milestone 3B is the first milestone that stores reports. This task closes the storage items that the Milestone 2 review carried to Milestone 3 and adds the rule of decision P1.
Files:
- Modify:
lib/report/types.ts(addREPORT_VERSION) - Modify:
lib/projects/schema.ts(date checks,DEFAULT_PROJECT_NAME,frf2_messages,pending_rebuild) - Modify:
lib/projects/store.ts(whole file) - Modify:
lib/projects/format.ts(designSummaryfor a pending rebuild) - Test:
replication_materials/tests/unit/projects.test.ts,replication_materials/tests/unit/report-types.test.ts
Interfaces:
- Consumes:
ReportSchema(lib/report/types.ts),CoreSpecSchema(lib/spec/validate.ts). - Produces:
REPORT_VERSION: string("1.2.0") inlib/report/types.ts.DEFAULT_PROJECT_NAME = "Untitled design",LOCAL_TIME_PATTERN: RegExp, and theProjectfieldsfrf2_messages: string[]andpending_rebuild: { report_version: string } | nullinlib/projects/schema.ts.- In
lib/projects/store.ts:ProjectStorewith the new methodsunreadable(): Promise<UnreadableRecord[]>andremoveRecord(key: IDBValidKey): Promise<void>;UnreadableRecord { key: IDBValidKey; name: string | null; raw: unknown };readRecord(value: unknown): ReadResult; the constantsPENDING_REBUILD_ERRORandCLOSED_ERROR.
Add to the end of replication_materials/tests/unit/report-types.test.ts, and add REPORT_VERSION to its import from @/lib/report/types:
describe("report version", () => {
it("equals FFD_REPORT_VERSION of the report helper", () => {
const source = readFileSync("r/ffd_report.R", "utf8");
expect(source.match(/FFD_REPORT_VERSION <- "([^"]+)"/)?.[1]).toBe(REPORT_VERSION);
});
});
Replace replication_materials/tests/unit/projects.test.ts with:
import { readFileSync } from "node:fs";
import { IDBFactory } from "fake-indexeddb";
import { describe, expect, it } from "vitest";
import { designSummary, formatBytes, formatDate, projectsSize } from "@/lib/projects/format";
import { DEFAULT_PROJECT_NAME, ProjectSchema, isoWithOffset, newProject } from "@/lib/projects/schema";
import { CLOSED_ERROR, PENDING_REBUILD_ERROR, openProjectStore } from "@/lib/projects/store";
const at = (minutes: number) => new Date(2026, 9, 15, 12, minutes, 0);
const project = (id: string, minutes: number) => newProject(at(minutes), id, "America/New_York");
const ID1 = "00000000-0000-4000-8000-000000000001";
const ID2 = "00000000-0000-4000-8000-000000000002";
const coating = () => JSON.parse(readFileSync("replication_materials/tests/fixtures/reports/coating.json", "utf8"));
/** Writes a record directly, as an earlier version of the app or a damaged database would. */
async function putRaw(factory: IDBFactory, value: unknown): Promise<void> {
const db = await new Promise<IDBDatabase>((resolve, reject) => {
const req = factory.open("ffd-projects", 1);
req.onupgradeneeded = () => req.result.createObjectStore("projects", { keyPath: "id" });
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
await new Promise<void>((resolve, reject) => {
const tx = db.transaction("projects", "readwrite");
tx.objectStore("projects").put(value);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
db.close();
}
/** A database whose write requests succeed and whose write transactions then abort (a full disk). */
function abortingFactory(): IDBFactory {
const db = {
onversionchange: null,
close() {},
transaction(_store: string, mode: IDBTransactionMode) {
const tx: Record<string, unknown> = { error: new DOMException("The quota was exceeded.", "QuotaExceededError") };
const request = (result: unknown) => {
const req: Record<string, unknown> = { result };
setTimeout(() => {
(req.onsuccess as (() => void) | undefined)?.();
setTimeout(() => (mode === "readwrite" ? (tx.onabort as () => void)() : (tx.oncomplete as (() => void) | undefined)?.()));
});
return req;
};
tx.objectStore = () => ({
getAll: () => request([]),
get: () => request(undefined),
put: () => request(undefined),
delete: () => request(undefined),
clear: () => request(undefined),
});
return tx;
},
};
return {
open() {
const req: Record<string, unknown> = { result: db };
setTimeout(() => (req.onsuccess as () => void)());
return req;
},
} as unknown as IDBFactory;
}
describe("project schema (spec 8.1)", () => {
it("makes a valid new project with the creation time and the time zone", () => {
const p = project(ID1, 5);
expect(ProjectSchema.parse(p)).toEqual(p);
expect(p.name).toBe(DEFAULT_PROJECT_NAME);
expect(p.created_at).toMatch(/^2026-10-15T12:05:00[+-]\d{2}:\d{2}$/);
expect(p.time_zone).toBe("America/New_York");
expect(p.spec).toBeNull();
expect(p.last_step).toBe(1);
expect(p.frf2_messages).toEqual([]);
expect(p.pending_rebuild).toBeNull();
});
it("writes the local time with its offset", () => {
expect(isoWithOffset(new Date(2026, 9, 15, 9, 5, 7))).toMatch(/^2026-10-15T09:05:07[+-]\d{2}:\d{2}$/);
});
it("refuses dates that are not times (Milestone 2 review, m-4)", () => {
expect(ProjectSchema.safeParse({ ...project(ID1, 1), created_at: "yesterday" }).success).toBe(false);
expect(ProjectSchema.safeParse({ ...project(ID1, 1), updated_at: "2026-10-15" }).success).toBe(false);
expect(ProjectSchema.safeParse({ ...project(ID1, 1), time_zone: "" }).success).toBe(false);
});
it("reads a Milestone 2 record, which has no Milestone 3B fields (decision P7)", () => {
const { frf2_messages: _m, pending_rebuild: _p, ...m2 } = project(ID1, 1);
expect(ProjectSchema.parse(m2)).toEqual(project(ID1, 1));
});
});
describe("project store (spec 8.2)", () => {
it("stores, lists newest first, reads, removes and clears projects in IndexedDB", async () => {
const store = await openProjectStore(new IDBFactory());
expect(store.persistent).toBe(true);
await store.put(project(ID1, 1));
await store.put(project(ID2, 2));
expect((await store.list()).map((p) => p.id)).toEqual([ID2, ID1]);
expect((await store.get(ID1))?.id).toBe(ID1);
await store.remove(ID1);
expect((await store.list()).map((p) => p.id)).toEqual([ID2]);
await store.clear();
expect(await store.list()).toEqual([]);
});
it("refuses a project that does not match the schema", async () => {
const store = await openProjectStore(new IDBFactory());
await expect(store.put({ ...project(ID1, 1), id: "not-a-uuid" })).rejects.toThrow();
});
it("rejects a write whose transaction aborts after its request succeeded (Review Focus 2)", async () => {
const store = await openProjectStore(abortingFactory());
expect(store.persistent).toBe(true);
await expect(store.put(project(ID1, 1))).rejects.toThrow("The quota was exceeded.");
});
it("closes the database when another tab upgrades it (Review Focus 2)", async () => {
const factory = new IDBFactory();
const store = await openProjectStore(factory);
const outcome = await new Promise<string>((resolve) => {
const req = factory.open("ffd-projects", 2);
req.onsuccess = () => {
req.result.close();
resolve("upgraded");
};
req.onblocked = () => resolve("blocked");
});
expect(outcome).toBe("upgraded");
await expect(store.list()).rejects.toThrow(CLOSED_ERROR);
});
it("lists a record that matches no schema as unreadable, never hides it, and deletes it on request", async () => {
const factory = new IDBFactory();
await putRaw(factory, { id: "old-1", name: "Old notes", text: "no schema" });
await putRaw(factory, project(ID1, 1));
const store = await openProjectStore(factory);
expect((await store.list()).map((p) => p.id)).toEqual([ID1]);
expect(await store.unreadable()).toEqual([{ key: "old-1", name: "Old notes", raw: { id: "old-1", name: "Old notes", text: "no schema" } }]);
await store.removeRecord("old-1");
expect(await store.unreadable()).toEqual([]);
});
it("reads a report of an older report version as a pending rebuild and keeps the stored record (decision P1)", async () => {
const factory = new IDBFactory();
const { all_2fis_clear: _dropped, ...older } = { ...coating(), report_version: "1.1.0" };
await putRaw(factory, { ...project(ID1, 1), report: older, run_sheet_sha256: "a".repeat(64) });
const store = await openProjectStore(factory);
const [listed] = await store.list();
expect(listed.report).toBeNull();
expect(listed.pending_rebuild).toEqual({ report_version: "1.1.0" });
expect((await store.get(ID1))?.pending_rebuild).toEqual({ report_version: "1.1.0" });
expect(designSummary(listed)).toBe("Report to update");
await expect(store.put(listed)).rejects.toThrow(PENDING_REBUILD_ERROR);
await store.put({ ...listed, pending_rebuild: null, report: coating() });
expect((await store.get(ID1))?.report?.report_version).toBe("1.2.0");
});
it("treats a complete report with another version label as a pending rebuild too", async () => {
const factory = new IDBFactory();
await putRaw(factory, { ...project(ID1, 1), report: { ...coating(), report_version: "1.1.9" } });
const [listed] = await (await openProjectStore(factory)).list();
expect(listed.pending_rebuild).toEqual({ report_version: "1.1.9" });
});
it("keeps projects in memory when IndexedDB is missing or blocked (Review Focus 2)", async () => {
const blocked = { open() { throw new Error("blocked"); } } as unknown as IDBFactory;
for (const factory of [undefined, blocked]) {
const store = await openProjectStore(factory);
expect(store.persistent).toBe(false);
await store.put(project(ID1, 1));
expect((await store.list()).map((p) => p.id)).toEqual([ID1]);
expect(await store.unreadable()).toEqual([]);
await expect(store.put({ ...project(ID2, 2), pending_rebuild: { report_version: "1.1.0" } })).rejects.toThrow(PENDING_REBUILD_ERROR);
}
});
it("keeps projects in memory when the database opens but cannot be read", async () => {
const brokenDb = { transaction() { throw new Error("The connection is closed."); } };
const opensBroken = {
open() {
const req: Record<string, unknown> = { result: brokenDb };
setTimeout(() => (req.onsuccess as () => void)());
return req;
},
} as unknown as IDBFactory;
expect((await openProjectStore(opensBroken)).persistent).toBe(false);
});
});
describe("display helpers", () => {
it("formats sizes, dates and the design summary", () => {
expect([512, 41_000, 2_500_000].map(formatBytes)).toEqual(["512 bytes", "41 KB", "2.5 MB"]);
expect(formatDate("2026-10-15T12:00:00-04:00")).toBe("Oct 15, 2026");
expect(designSummary(project(ID1, 1))).toBe("No design yet");
expect(designSummary({ ...project(ID1, 1), report: coating() })).toBe("16 runs");
expect(projectsSize([project(ID1, 1)])).toBeGreaterThan(100);
});
});
Run: npx vitest run --project unit replication_materials/tests/unit/projects.test.ts replication_materials/tests/unit/report-types.test.ts Expected: FAIL. The imports REPORT_VERSION, DEFAULT_PROJECT_NAME, CLOSED_ERROR and PENDING_REBUILD_ERROR do not exist, so both files fail to load.
In lib/report/types.ts, add after the comment block at the top:
/** The report version of r/ffd_report.R (FFD_REPORT_VERSION). A unit test checks that the two agree. */
export const REPORT_VERSION = "1.2.0";
In lib/projects/schema.ts, replace the comment, the constant and the schema (lines 5 to 29) with:
// A project (spec Section 8.1): the unit of work, stored on the device. Version 1. The fields
// added in Milestone 3B have defaults, so a record that Milestone 2 stored still reads
// (decision P7). Milestone 3B fills the request, the card, the spec, the R code, the versions,
// the SHA-256, the report and the FrF2 messages.
export const PROJECT_SCHEMA_VERSION = 1;
export const DEFAULT_PROJECT_NAME = "Untitled design";
/** Local date and time with the offset of the device, to the second (spec 7.3). */
export const LOCAL_TIME_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$/;
export const ProjectSchema = z.object({
schema_version: z.literal(PROJECT_SCHEMA_VERSION),
id: z.uuid(),
name: z.string().min(1),
/** Local creation time with its offset, for example 2026-10-15T09:05:07-04:00 (spec 7.3). */
created_at: z.string().regex(LOCAL_TIME_PATTERN),
time_zone: z.string().min(1),
/** UTC time of the last change, as Date.toISOString() writes it. */
updated_at: z.iso.datetime(),
request: z.object({
text: z.string(),
turns: z.array(z.object({ role: z.enum(["user", "tool"]), text: z.string() })),
}).nullable(),
spec: CoreSpecSchema.nullable(),
r_code: z.string().nullable(),
versions: z.record(z.string(), z.string()).nullable(),
run_sheet_sha256: z.string().regex(/^[0-9a-f]{64}$/).nullable(),
report: ReportSchema.nullable(),
last_step: z.number().int().min(1).max(6),
/** FrF2's warnings and messages of the run that made the report (spec 7.5, decision P18). */
frf2_messages: z.array(z.string()).default([]),
/** Set in memory when the stored report has another report version (decision P1). Never stored. */
pending_rebuild: z.object({ report_version: z.string() }).nullable().default(null),
});
In newProject, use the constant:
name: DEFAULT_PROJECT_NAME,
and add before last_step: 1,:
frf2_messages: [],
pending_rebuild: null,
Replace lib/projects/store.ts with:
import { z } from "zod";
import { REPORT_VERSION } from "@/lib/report/types";
import { ProjectSchema, type Project } from "./schema";
// Projects on this device (spec Section 8.2). IndexedDB holds them. Without IndexedDB (for
// example in private browsing) the store keeps them in memory for this session, and
// persistent is false, so that the sidebar can say so. openProjectStore reads the database
// once before it uses it. A later failure rejects, and the workspace shows a message.
// Rules for stored reports (Milestone 2 review and decision P1):
// - a write resolves when its transaction completes, so a full disk is not a silent failure;
// - the database closes when another tab opens a newer version (onversionchange);
// - a record that matches no schema is listed as unreadable, never hidden;
// - a report with another report version is read as report null and pending_rebuild set.
// Step 3 rebuilds it. Such a project is not written until a new report replaces the old
// one, so the stored report stays until then.
const DB_NAME = "ffd-projects";
const STORE = "projects";
export const PENDING_REBUILD_ERROR = "A project whose report waits for a rebuild is stored only with a new report.";
export const CLOSED_ERROR = "The project database was closed because another tab changed it. Reload the page.";
export interface UnreadableRecord {
key: IDBValidKey;
/** The stored name, if the record has a text name. */
name: string | null;
raw: unknown;
}
export interface ProjectStore {
readonly persistent: boolean;
list(): Promise<Project[]>;
get(id: string): Promise<Project | undefined>;
put(project: Project): Promise<void>;
remove(id: string): Promise<void>;
clear(): Promise<void>;
/** Stored records that match no schema (spec 8.2: no silent failure). */
unreadable(): Promise<UnreadableRecord[]>;
removeRecord(key: IDBValidKey): Promise<void>;
}
const newestFirst = (a: Project, b: Project) => Date.parse(b.updated_at) - Date.parse(a.updated_at);
const OlderReportSchema = ProjectSchema.extend({ report: z.looseObject({ report_version: z.string() }) });
export type ReadResult = { kind: "project"; project: Project } | { kind: "unreadable"; record: UnreadableRecord };
/** Reads one stored record (decision P1). */
export function readRecord(value: unknown): ReadResult {
const current = ProjectSchema.safeParse(value);
if (current.success && (current.data.report === null || current.data.report.report_version === REPORT_VERSION)) {
return { kind: "project", project: current.data };
}
const older = OlderReportSchema.safeParse(value);
if (older.success && older.data.report.report_version !== REPORT_VERSION) {
const { report, ...rest } = older.data;
return { kind: "project", project: { ...rest, report: null, pending_rebuild: { report_version: report.report_version } } };
}
const record = (value ?? {}) as Record<string, unknown>;
return {
kind: "unreadable",
record: { key: record.id as IDBValidKey, name: typeof record.name === "string" ? record.name : null, raw: value },
};
}
function writable(project: Project): Project {
if (project.pending_rebuild !== null) throw new Error(PENDING_REBUILD_ERROR);
return ProjectSchema.parse(project);
}
function request<T>(req: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
/** Resolves when the transaction completes; rejects when it aborts or fails. */
function completion(tx: IDBTransaction): Promise<void> {
return new Promise((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onabort = () => reject(tx.error ?? new Error("The storage transaction was aborted."));
tx.onerror = () => reject(tx.error ?? new Error("The storage transaction failed."));
});
}
function openDb(factory: IDBFactory): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = factory.open(DB_NAME, 1);
req.onupgradeneeded = () => req.result.createObjectStore(STORE, { keyPath: "id" });
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
req.onblocked = () => reject(new Error("The project database is blocked."));
});
}
function indexedDbStore(db: IDBDatabase): ProjectStore {
let closed = false;
// Another tab opens a newer version: close this connection, so that its upgrade is not blocked.
db.onversionchange = () => {
closed = true;
db.close();
};
const transaction = (mode: IDBTransactionMode) => {
if (closed) throw new Error(CLOSED_ERROR);
return db.transaction(STORE, mode);
};
const readAll = async () => (await request(transaction("readonly").objectStore(STORE).getAll())).map(readRecord);
const write = async (action: (store: IDBObjectStore) => void) => {
const tx = transaction("readwrite");
const done = completion(tx);
try {
action(tx.objectStore(STORE));
} catch (error) {
done.catch(() => undefined);
throw error;
}
await done;
};
return {
persistent: true,
async list() {
return (await readAll()).flatMap((r) => (r.kind === "project" ? [r.project] : [])).sort(newestFirst);
},
async get(id) {
const result = readRecord(await request(transaction("readonly").objectStore(STORE).get(id)));
return result.kind === "project" ? result.project : undefined;
},
async put(project) {
const valid = writable(project);
await write((store) => store.put(valid));
},
async remove(id) {
await write((store) => store.delete(id));
},
async clear() {
await write((store) => store.clear());
},
async unreadable() {
return (await readAll()).flatMap((r) => (r.kind === "unreadable" ? [r.record] : []));
},
async removeRecord(key) {
await write((store) => store.delete(key));
},
};
}
function memoryStore(): ProjectStore {
const projects = new Map<string, Project>();
return {
persistent: false,
async list() {
return [...projects.values()].sort(newestFirst);
},
async get(id) {
return projects.get(id);
},
async put(project) {
const valid = writable(project);
projects.set(valid.id, valid);
},
async remove(id) {
projects.delete(id);
},
async clear() {
projects.clear();
},
async unreadable() {
return [];
},
async removeRecord(key) {
projects.delete(String(key));
},
};
}
function safeIndexedDb(): IDBFactory | undefined {
try {
return globalThis.indexedDB ?? undefined;
} catch {
return undefined;
}
}
export async function openProjectStore(factory: IDBFactory | undefined = safeIndexedDb()): Promise<ProjectStore> {
if (!factory) return memoryStore();
let db: IDBDatabase | null = null;
try {
db = await openDb(factory);
const store = indexedDbStore(db);
await store.list();
return store;
} catch {
try {
db?.close();
} catch {
// A broken connection cannot be closed.
}
return memoryStore();
}
}
In lib/projects/format.ts, replace designSummary with:
export function designSummary(project: Project): string {
if (project.pending_rebuild) return "Report to update";
return project.report ? `${project.report.design.nruns} runs` : "No design yet";
}
Run: npx vitest run --project unit replication_materials/tests/unit/projects.test.ts replication_materials/tests/unit/report-types.test.ts Expected: PASS, all tests in the two files.
Run: npm run check -- --fast Expected: "All checks passed." Workspace.tsx does not call the new methods yet, and no other module builds a Project object by hand.
git add lib/report/types.ts lib/projects/schema.ts lib/projects/store.ts lib/projects/format.ts replication_materials/tests/unit/projects.test.ts replication_materials/tests/unit/report-types.test.ts
git commit -m "feat: make project storage ready for reports: completed writes, closed database, unreadable records, older reports (P1)"
9Task 2: The translation client and the answer schema
Files:
- Create:
lib/translate/response.ts,lib/translate/client.ts - Modify:
content/messages.json(two messages),lib/config/budgets.tsandlib/site.ts(JSON import attribute) - Modify:
replication_materials/tests/e2e/shell.spec.ts(import the names fromlib/site) - Test:
replication_materials/tests/unit/translate/response.test.ts,replication_materials/tests/unit/translate/client.test.ts,replication_materials/tests/unit/translate/messages.test.ts
Interfaces:
- Consumes:
OUTCOMES,SCOPE_CATEGORIES,VOTERS,Turn(lib/translate/schema.ts);CoreSpecSchema(lib/spec/validate.ts);MESSAGES(lib/translate/messages.ts); the typeTranslateApiResponse(lib/translate/handler.ts, type only, in the test). - Produces:
TranslateApiResponseSchema(zod) andtype TranslateAnswer = z.infer<typeof TranslateApiResponseSchema>inlib/translate/response.ts; alsotype TranslateResultAnswer = Extract<TranslateAnswer, { kind: "result" }>,type VoterReportAnswer,type LeftOutAnswer,type AgreementAnswer,type FieldAgreementAnswer.requestTranslation(turns: readonly Turn[], options?: { fetch?: typeof fetch; signal?: AbortSignal }): Promise<TranslateReply>andtype TranslateReply = TranslateAnswer | { kind: "network_error"; message: string } | { kind: "cancelled"; message: string }inlib/translate/client.ts.MESSAGES.network_error,MESSAGES.cancelled.
Create replication_materials/tests/unit/translate/response.test.ts:
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import type { TranslateApiResponse } from "@/lib/translate/handler";
import { TranslateApiResponseSchema, type TranslateAnswer } from "@/lib/translate/response";
const DIR = "replication_materials/tests/fixtures/translate";
const index = JSON.parse(readFileSync(`${DIR}/index.json`, "utf8")) as { names: string[] };
const fixture = (name: string) => JSON.parse(readFileSync(`${DIR}/${name}.json`, "utf8")) as { status: number; body: unknown };
// The client schema and the server type describe the same object (decision P20). These two
// functions do not compile if either side has a field that the other side does not accept.
export const toServer = (answer: TranslateAnswer): TranslateApiResponse => answer;
export const toClient = (answer: TranslateApiResponse): TranslateAnswer => answer;
describe("answer schema of POST /api/translate (3A final review M7)", () => {
it.each(index.names)("accepts the recorded answer %s", (name) => {
expect(TranslateApiResponseSchema.safeParse(fixture(name).body).success).toBe(true);
});
it("accepts every refusal of the route", () => {
for (const body of [
{ kind: "invalid_request", message: "x" },
{ kind: "rate_limited", message: "x", retry_after_seconds: 12 },
{ kind: "paused", message: "x" },
{ kind: "unavailable", message: "x" },
{ kind: "error", message: "x" },
]) {
expect(TranslateApiResponseSchema.safeParse(body).success).toBe(true);
}
});
it("refuses an answer with an unknown kind, a missing field or a spec that breaks the core rules", () => {
const good = fixture("coating-design").body as Record<string, unknown>;
expect(TranslateApiResponseSchema.safeParse({ kind: "maybe", message: "x" }).success).toBe(false);
const { agreement: _a, ...noAgreement } = good;
expect(TranslateApiResponseSchema.safeParse(noAgreement).success).toBe(false);
const spec = good.spec as Record<string, unknown>;
expect(TranslateApiResponseSchema.safeParse({ ...good, spec: { ...spec, factors: [] } }).success).toBe(false);
});
});
Create replication_materials/tests/unit/translate/client.test.ts:
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { requestTranslation } from "@/lib/translate/client";
import { MESSAGES } from "@/lib/translate/messages";
const coating = JSON.parse(readFileSync("replication_materials/tests/fixtures/translate/coating-design.json", "utf8"));
const turns = coating.request.turns;
function answering(status: number, body: string): { fetch: typeof fetch; calls: { url: string; init: RequestInit }[] } {
const calls: { url: string; init: RequestInit }[] = [];
const fake = (async (url: string, init: RequestInit) => {
calls.push({ url, init });
return new Response(body, { status, headers: { "content-type": "application/json" } });
}) as unknown as typeof fetch;
return { fetch: fake, calls };
}
describe("translation client (spec 4.3, decision P20)", () => {
it("posts the turns as JSON and returns a valid answer", async () => {
const { fetch, calls } = answering(200, JSON.stringify(coating.body));
const reply = await requestTranslation(turns, { fetch });
expect(reply.kind).toBe("result");
expect(calls[0].url).toBe("/api/translate");
expect(calls[0].init.method).toBe("POST");
expect(JSON.parse(String(calls[0].init.body))).toEqual({ turns });
});
it("returns a refusal of the route as it is (Review Focus 1)", async () => {
const body = { kind: "rate_limited", message: "Too many requests from this address. Wait 30 seconds, or fill in the form yourself.", retry_after_seconds: 30 };
const reply = await requestTranslation(turns, answering(429, JSON.stringify(body)));
expect(reply).toEqual(body);
});
it("fails closed on a body that is not JSON or not the expected shape (Review Focus 1)", async () => {
for (const body of ["<html>proxy error</html>", JSON.stringify({ kind: "result" })]) {
expect(await requestTranslation(turns, answering(200, body))).toEqual({ kind: "error", message: MESSAGES.service_error });
}
});
it("names a lost network and a cancelled request (Review Focus 1)", async () => {
const offline = (async () => {
throw new TypeError("Failed to fetch");
}) as unknown as typeof fetch;
expect(await requestTranslation(turns, { fetch: offline })).toEqual({ kind: "network_error", message: MESSAGES.network_error });
const controller = new AbortController();
controller.abort();
const aborting = (async (_url: string, init: RequestInit) => {
if (init.signal?.aborted) throw new DOMException("The operation was aborted.", "AbortError");
throw new Error("not reached");
}) as unknown as typeof fetch;
expect(await requestTranslation(turns, { fetch: aborting, signal: controller.signal })).toEqual({ kind: "cancelled", message: MESSAGES.cancelled });
});
});
Add to replication_materials/tests/unit/translate/messages.test.ts, inside the existing describe block:
it("has notes only for not-supported categories (pre-3B minor 3)", () => {
for (const key of Object.keys(MESSAGES.feature_notes)) expect(NOT_SUPPORTED_CATEGORIES).toContain(key);
});
it("names a lost network and a cancelled request without advice words", () => {
expect(MESSAGES.network_error).toBe("The interpretation service could not be reached. You can fill in the form yourself.");
expect(MESSAGES.cancelled).toBe("The interpretation was cancelled.");
});
In replication_materials/tests/e2e/shell.spec.ts, replace the comment and the two constants at the top with:
import { APP_NAME, APP_SUBTITLE } from "@/lib/site";
Run: npx vitest run --project unit replication_materials/tests/unit/translate/ Expected: FAIL. @/lib/translate/response and @/lib/translate/client do not exist, and MESSAGES.network_error is undefined.
Run: npx playwright test replication_materials/tests/e2e/shell.spec.ts Expected: FAIL before any test runs: Node refuses to import package.json from lib/site.ts without an import attribute (an import-attribute error). This is the 3A ledger item about JSON imports.
In lib/site.ts, change the first line to:
import pkg from "@/package.json" with { type: "json" };
In lib/config/budgets.ts, change the first line to:
import budgets from "@/config/runtime-budgets.json" with { type: "json" };
In content/messages.json, add after the "voter_error" line:
"network_error": "The interpretation service could not be reached. You can fill in the form yourself.",
"cancelled": "The interpretation was cancelled.",
Create lib/translate/response.ts:
import { z } from "zod";
import { CoreSpecSchema } from "@/lib/spec/validate";
import { OUTCOMES, SCOPE_CATEGORIES, VOTERS } from "./schema";
// The answer of POST /api/translate as the client reads it (spec 5.6 and 5.7, decision P20).
// It mirrors TranslateApiResponse of handler.ts, which is server only. A unit test checks that
// the two types are the same and that every recorded fixture matches this schema. Client code
// may import this module.
const VoterIdSchema = z.enum(VOTERS);
const UsageSchema = z.object({ inputTokens: z.number(), outputTokens: z.number() });
const VoterReportSchema = z.object({
voter: VoterIdSchema,
status: z.enum(["valid", "failed"]),
reason: z.enum(["schema", "deadline", "error"]).nullable(),
message: z.string().nullable(),
model_id: z.string().nullable(),
ms: z.number(),
usage: UsageSchema,
/** Accounting data of a call that the deadline cut off. The card does not show it (P10). */
unread: UsageSchema,
});
const LeftOutSchema = z.object({ voter: VoterIdSchema, reason: z.enum(["spend_cap", "no_key"]) });
const meta = {
mode: z.enum(["single", "ensemble"]),
prompt_version: z.string(),
schema_version: z.string(),
jev_questions_version: z.string(),
voters: z.array(VoterReportSchema),
left_out: z.array(LeftOutSchema),
};
const FieldValueSchema = z.object({ value: z.string(), display: z.string(), count: z.number(), voters: z.array(VoterIdSchema) });
const FieldAgreementSchema = z.object({ agree: z.boolean(), values: z.array(FieldValueSchema), answered: z.number() });
const AgreementSchema = z.object({
checked: z.number(),
total: z.number(),
crossChecked: z.boolean(),
outcome: FieldAgreementSchema,
fields: z.record(z.string(), FieldAgreementSchema),
});
const ResultSchema = z.object({
kind: z.literal("result"),
outcome: z.enum(OUTCOMES),
spec: CoreSpecSchema.nullable(),
clarification_question: z.string().nullable(),
scope_category: z.enum(SCOPE_CATEGORIES).nullable(),
message: z.string().nullable(),
evidence: z.array(z.object({ field: z.string(), quote: z.string() })),
check_fields: z.array(z.string()),
agreement: AgreementSchema,
...meta,
});
const refusal = <K extends string>(kind: K) => z.object({ kind: z.literal(kind), message: z.string() });
export const TranslateApiResponseSchema = z.discriminatedUnion("kind", [
ResultSchema,
z.object({ kind: z.literal("could_not_interpret"), message: z.string(), ...meta }),
refusal("invalid_request"),
z.object({ kind: z.literal("rate_limited"), message: z.string(), retry_after_seconds: z.number() }),
refusal("paused"),
refusal("unavailable"),
refusal("error"),
]);
export type TranslateAnswer = z.infer<typeof TranslateApiResponseSchema>;
export type TranslateResultAnswer = Extract<TranslateAnswer, { kind: "result" }>;
export type VoterReportAnswer = z.infer<typeof VoterReportSchema>;
export type LeftOutAnswer = z.infer<typeof LeftOutSchema>;
export type AgreementAnswer = z.infer<typeof AgreementSchema>;
export type FieldAgreementAnswer = z.infer<typeof FieldAgreementSchema>;
Create lib/translate/client.ts:
import { MESSAGES } from "./messages";
import { TranslateApiResponseSchema, type TranslateAnswer } from "./response";
import type { Turn } from "./schema";
// The browser side of POST /api/translate (spec 4.3, decision P20). Every answer is checked
// with the client schema; an answer of another shape fails closed with the fixed service
// message. A refusal of the route (HTTP 400, 429, 503 or 500) comes back as it is, because its
// message is already a fixed text of content/messages.json.
export type TranslateReply = TranslateAnswer | { kind: "network_error"; message: string } | { kind: "cancelled"; message: string };
export async function requestTranslation(
turns: readonly Turn[],
options: { fetch?: typeof fetch; signal?: AbortSignal } = {},
): Promise<TranslateReply> {
const doFetch = options.fetch ?? fetch;
let response: Response;
try {
response = await doFetch("/api/translate", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ turns }),
signal: options.signal,
});
} catch {
if (options.signal?.aborted) return { kind: "cancelled", message: MESSAGES.cancelled };
return { kind: "network_error", message: MESSAGES.network_error };
}
let body: unknown;
try {
body = await response.json();
} catch {
if (options.signal?.aborted) return { kind: "cancelled", message: MESSAGES.cancelled };
return { kind: "error", message: MESSAGES.service_error };
}
const parsed = TranslateApiResponseSchema.safeParse(body);
return parsed.success ? parsed.data : { kind: "error", message: MESSAGES.service_error };
}
Run: npx vitest run --project unit replication_materials/tests/unit/translate/ Expected: PASS. Then check the guard test of feature_notes: add the key "blockingg": "x" to feature_notes in content/messages.json, run npx vitest run --project unit replication_materials/tests/unit/translate/messages.test.ts (expected: FAIL in "has notes only for not-supported categories"), and remove the key again.
Run: npx tsc --noEmit Expected: no error. An error in toServer or toClient means that the client schema and TranslateApiResponse differ; change response.ts to match the server type, not the reverse.
Run: npx playwright test replication_materials/tests/e2e/shell.spec.ts Expected: PASS (9 tests).
Run: npm run check Expected: "All checks passed."
git add lib/translate/response.ts lib/translate/client.ts content/messages.json lib/site.ts lib/config/budgets.ts replication_materials/tests/unit/translate/response.test.ts replication_materials/tests/unit/translate/client.test.ts replication_materials/tests/unit/translate/messages.test.ts replication_materials/tests/e2e/shell.spec.ts
git commit -m "feat: add the client schema and fetch client of the translate route (P20)"
10Task 3: Feasibility pre-checks
Files:
- Create:
lib/spec/precheck.ts,lib/report/format.ts - Create:
replication_materials/tests/fixtures/precheck-cases.json - Modify:
content/messages.json(theprecheckgroup) - Test:
replication_materials/tests/unit/precheck.test.ts,replication_materials/tests/unit/report-format.test.ts,replication_materials/tests/r/test-precheck-behaviour.R
Interfaces:
- Consumes:
data/catalogue-summary.json,data/supported-limits.json,RUN_SIZESandCoreSpec(lib/spec/validate.ts),MESSAGESandfill(lib/translate/messages.ts). - Produces:
toRoman(n: number): stringinlib/report/format.ts(Task 8 adds more helpers to this file).- In
lib/spec/precheck.ts:PrecheckInput { nfactors; nruns: number | null; min_resolution: number | null; replications; center_points; all_quantitative: boolean },PrecheckResult { ok: boolean; messages: string[]; notes: string[]; nruns: number | null },PrecheckData,DEFAULT_PRECHECK_DATA,precheck(input, data?),precheckSpec(spec: CoreSpec, data?).
The Milestone 1 file test-frf2-behaviour.R already shows the FrF2 behaviour behind rules 1, 2, 3, 5 and 8. The new case file adds rule 4 on the path that codegen uses (only nruns), rule 6, both parts of rule 7, the broken entry (P8), and one input for each rule, so that the TypeScript test and the R test read the same cases.
Create replication_materials/tests/fixtures/precheck-cases.json:
{
"note": "Pre-check cases (spec Section 6.2, decision P8). precheck.test.ts checks the messages of lib/spec/precheck.ts; test-precheck-behaviour.R runs each r.call in native R and checks the FrF2 behaviour that the rule prevents. Measured on 2026-10-16 with FrF2 2.3-5.",
"cases": [
{
"name": "a size in the catalogue",
"input": { "nfactors": 7, "nruns": 16, "min_resolution": null, "replications": 1, "center_points": 0, "all_quantitative": true },
"messages": [], "notes": [], "nruns": 16,
"r": { "call": "FrF2::FrF2(nruns = 16, nfactors = 7, randomize = FALSE)", "rows": 16, "entry": "7-3.1" }
},
{
"name": "rule 1: a run size that is not a power of 2",
"input": { "nfactors": 5, "nruns": 12, "min_resolution": null, "replications": 1, "center_points": 0, "all_quantitative": true },
"messages": ["12 runs is not a supported run size."], "notes": [], "nruns": null,
"r": { "call": "FrF2::FrF2(nruns = 12, nfactors = 5, randomize = FALSE)", "error": "nruns must be a power of 2." }
},
{
"name": "rule 1: a run size above the supported limit",
"input": { "nfactors": 14, "nruns": 8192, "min_resolution": null, "replications": 1, "center_points": 0, "all_quantitative": true },
"messages": ["8192 runs is not a supported run size."], "notes": [], "nruns": null,
"r": { "call": "FrF2::FrF2(nruns = 8192, nfactors = 14, randomize = FALSE)", "error": "more than 4096 runs are not covered by function FrF2." }
},
{
"name": "rule 2: more factors than the run size holds",
"input": { "nfactors": 8, "nruns": 8, "min_resolution": null, "replications": 1, "center_points": 0, "all_quantitative": true },
"messages": ["8 runs can hold at most 7 factors."], "notes": [], "nruns": null,
"r": { "call": "FrF2::FrF2(nruns = 8, nfactors = 8, randomize = FALSE)", "error": "You can accomodate at most 7 factors in a FrF2 design with 8 runs." }
},
{
"name": "rule 3: more runs than the full factorial",
"input": { "nfactors": 4, "nruns": 32, "min_resolution": null, "replications": 1, "center_points": 0, "all_quantitative": true },
"messages": ["The full factorial for 4 factors has 16 runs. 32 runs is more than this."], "notes": [], "nruns": null,
"r": { "call": "FrF2::FrF2(nruns = 32, nfactors = 4, randomize = FALSE)", "rows": 32, "nruns": 16, "type": "full factorial", "message": "creating full factorial with 16 runs" }
},
{
"name": "rule 4: a resolution that the run size cannot reach",
"input": { "nfactors": 9, "nruns": 16, "min_resolution": 4, "replications": 1, "center_points": 0, "all_quantitative": true },
"messages": ["With 9 factors in 16 runs, the highest resolution is III. The smallest design with resolution IV for 9 factors has 32 runs."], "notes": [], "nruns": 16,
"r": { "call": "FrF2::FrF2(nruns = 16, nfactors = 9, randomize = FALSE)", "rows": 16, "entry": "9-5.1", "resolution": 3 }
},
{
"name": "rule 5: a resolution that only the full factorial reaches",
"input": { "nfactors": 5, "nruns": null, "min_resolution": 6, "replications": 1, "center_points": 0, "all_quantitative": true },
"messages": [], "notes": ["No fraction meets resolution VI. FrF2 makes the full factorial (32 runs)."], "nruns": 32,
"r": { "call": "FrF2::FrF2(nfactors = 5, resolution = 6, randomize = FALSE)", "rows": 32, "type": "full factorial", "message": "full factorial design needed" }
},
{
"name": "rule 5: a resolution that a catalogue fraction reaches",
"input": { "nfactors": 26, "nruns": null, "min_resolution": 4, "replications": 1, "center_points": 0, "all_quantitative": true },
"messages": [], "notes": [], "nruns": 64,
"r": { "call": "FrF2::FrF2(nfactors = 26, resolution = 4, randomize = FALSE)", "rows": 64, "entry": "26-20.1" }
},
{
"name": "rule 6: a resolution with no supported design",
"input": { "nfactors": 13, "nruns": null, "min_resolution": 14, "replications": 1, "center_points": 0, "all_quantitative": true },
"messages": ["No supported design has resolution XIV for 13 factors."], "notes": [], "nruns": null,
"r": { "call": "FrF2::FrF2(nfactors = 13, resolution = 14, randomize = FALSE)", "rows": 8192, "type": "full factorial", "message": "full factorial design needed" }
},
{
"name": "rule 7: more factors than any catalogue size",
"input": { "nfactors": 200, "nruns": 4096, "min_resolution": null, "replications": 1, "center_points": 0, "all_quantitative": true },
"messages": ["This version supports at most 160 factors."], "notes": [], "nruns": null,
"r": { "call": "FrF2::FrF2(nruns = 4096, nfactors = 200, randomize = FALSE)", "error": "subscript out of bounds" }
},
{
"name": "rule 7: a size between catalogue entries",
"input": { "nfactors": 40, "nruns": 1024, "min_resolution": null, "replications": 1, "center_points": 0, "all_quantitative": true },
"messages": ["This version supports at most 33 factors in 1024 runs."], "notes": [], "nruns": null,
"r": { "call": "FrF2::FrF2(nruns = 1024, nfactors = 40, randomize = FALSE)", "error": "subscript out of bounds" }
},
{
"name": "rule 7: more rows than the supported limit",
"input": { "nfactors": 20, "nruns": 4096, "min_resolution": null, "replications": 16, "center_points": 0, "all_quantitative": true },
"messages": ["This version supports at most 32,768 rows. This design has 65,536 rows."], "notes": [], "nruns": 4096,
"r": { "call": "FrF2::FrF2(nruns = 4096, nfactors = 20, replications = 16, randomize = FALSE)", "rows": 65536, "entry": "20-8.1" }
},
{
"name": "rule 8: center points with a qualitative factor",
"input": { "nfactors": 3, "nruns": 8, "min_resolution": null, "replications": 1, "center_points": 2, "all_quantitative": false },
"messages": ["Center points need all factors to be quantitative."], "notes": [], "nruns": 8,
"r": { "call": "FrF2::FrF2(nruns = 8, nfactors = 3, factor.names = list(A = c(\"a\", \"b\"), B = c(1, 2), C = c(1, 2)), ncenter = 2, randomize = FALSE)", "error": "some inherently qualitative factors were wrongly declared quantitative" }
},
{
"name": "a broken catalogue entry (decision P8)",
"input": { "nfactors": 26, "nruns": 512, "min_resolution": null, "replications": 1, "center_points": 0, "all_quantitative": true },
"messages": ["The FrF2 catalogue entry 26-17.1 for 26 factors in 512 runs fails the checks of this tool."], "notes": [], "nruns": 512,
"r": { "call": "FrF2::FrF2(nruns = 512, nfactors = 26, randomize = FALSE)", "rows": 512, "entry": "26-17.1" }
}
]
}
Create replication_materials/tests/unit/report-format.test.ts:
import { describe, expect, it } from "vitest";
import { toRoman } from "@/lib/report/format";
describe("display helpers of the report", () => {
it("writes resolutions as Roman numerals", () => {
expect([3, 4, 5, 9, 14, 40].map(toRoman)).toEqual(["III", "IV", "V", "IX", "XIV", "XL"]);
expect(() => toRoman(0)).toThrow();
});
});
Create replication_materials/tests/unit/precheck.test.ts:
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { DEFAULT_PRECHECK_DATA, precheck, precheckSpec, type PrecheckInput } from "@/lib/spec/precheck";
import { ADVICE_WORDS, MESSAGES } from "@/lib/translate/messages";
interface Case { name: string; input: PrecheckInput; messages: string[]; notes: string[]; nruns: number | null }
const { cases } = JSON.parse(readFileSync("replication_materials/tests/fixtures/precheck-cases.json", "utf8")) as { cases: Case[] };
describe("pre-checks (spec 6.2, decision P8)", () => {
it.each(cases.map((c) => [c.name, c] as const))("%s", (_name, c) => {
expect(precheck(c.input)).toEqual({ ok: c.messages.length === 0, messages: c.messages, notes: c.notes, nruns: c.nruns });
});
it("uses the limits that it is given", () => {
const input: PrecheckInput = { nfactors: 7, nruns: 16, min_resolution: null, replications: 2, center_points: 0, all_quantitative: true };
expect(precheck(input, { ...DEFAULT_PRECHECK_DATA, max_total_rows: 20 }).messages).toEqual(["This version supports at most 20 rows. This design has 32 rows."]);
});
it("counts the center points of each replicate in the rows", () => {
const input: PrecheckInput = { nfactors: 7, nruns: 16, min_resolution: null, replications: 2, center_points: 3, all_quantitative: true };
expect(precheck(input, { ...DEFAULT_PRECHECK_DATA, max_total_rows: 37 }).messages).toEqual(["This version supports at most 37 rows. This design has 38 rows."]);
});
it("checks a core spec", () => {
const factor = (name: string) => ({ name, low: -1, high: 1, unit: null, quantitative: true });
const spec = {
design_family: "regular" as const,
factors: ["A", "B", "C", "D", "E", "F", "G", "H"].map(factor),
nruns: 8, min_resolution: null, replications: 1, repeat_only: false, center_points: 0, randomize: true, seed: 20261016,
};
expect(precheckSpec(spec).messages).toEqual(["8 runs can hold at most 7 factors."]);
});
it("states facts only: no advice words in the messages", () => {
for (const text of Object.values(MESSAGES.precheck)) {
for (const word of ADVICE_WORDS) expect(new RegExp(`\\b${word}\\b`, "i").test(text)).toBe(false);
}
});
});
Create replication_materials/tests/r/test-precheck-behaviour.R:
# The FrF2 behaviour that each pre-check rule prevents (spec Sections 6.2 and 11, decision P8).
# The cases are shared with precheck.test.ts, which checks the messages of lib/spec/precheck.ts.
cases <- jsonlite::fromJSON(file.path(ffd_root, "replication_materials", "tests", "fixtures", "precheck-cases.json"),
simplifyVector = FALSE)$cases
for (case in cases) {
local({
behaviour <- case$r
test_that(paste("FrF2 behaviour behind the pre-check:", case$name), {
run <- function() eval(parse(text = behaviour$call))
if (!is.null(behaviour$error)) {
expect_error(suppressWarnings(suppressMessages(run())), behaviour$error, fixed = TRUE)
return(invisible())
}
messages <- character()
d <- withCallingHandlers(run(), message = function(m) {
messages <<- c(messages, conditionMessage(m))
invokeRestart("muffleMessage")
})
info <- DoE.base::design.info(d)
if (!is.null(behaviour$rows)) expect_identical(nrow(d), as.integer(behaviour$rows))
if (!is.null(behaviour$nruns)) expect_equal(info$nruns, behaviour$nruns)
if (!is.null(behaviour$type)) expect_identical(info$type, behaviour$type)
if (!is.null(behaviour$entry)) expect_identical(names(info$catlg.entry), behaviour$entry)
if (!is.null(behaviour$resolution)) expect_equal(info$catlg.entry[[1]]$res, behaviour$resolution)
if (!is.null(behaviour$message)) expect_true(any(grepl(behaviour$message, messages, fixed = TRUE)))
})
})
}
Run: npx vitest run --project unit replication_materials/tests/unit/precheck.test.ts replication_materials/tests/unit/report-format.test.ts Expected: FAIL. @/lib/spec/precheck and @/lib/report/format do not exist.
Run: npm run test:r Expected: PASS for the new file too: it tests FrF2, not the app, and it shows that each case describes the measured behaviour (14 new tests). If a case fails, the case file is wrong; correct the case from the R output, not the rule.
In content/messages.json, add after the "outcome_disagreement" line:
"precheck": {
"run_size": "{nruns} runs is not a supported run size.",
"too_many_factors": "{nruns} runs can hold at most {max} factors.",
"more_than_full": "The full factorial for {m} factors has {full} runs. {nruns} runs is more than this.",
"resolution_unreachable": "With {m} factors in {nruns} runs, the highest resolution is {highest}.",
"smallest_for_resolution": "The smallest design with resolution {r} for {m} factors has {smallest} runs.",
"no_supported_resolution": "No supported design has resolution {r} for {m} factors.",
"full_factorial_note": "No fraction meets resolution {r}. FrF2 makes the full factorial ({full} runs).",
"limit_factors": "This version supports at most {limit} factors.",
"limit_factors_runs": "This version supports at most {limit} factors in {nruns} runs.",
"limit_rows": "This version supports at most {limit} rows. This design has {rows} rows.",
"center_points": "Center points need all factors to be quantitative.",
"broken_entry": "The FrF2 catalogue entry {entry} for {m} factors in {nruns} runs fails the checks of this tool."
},
Create lib/report/format.ts:
// Display helpers for the report and the pre-checks (spec Sections 6.2 and 9.4). Text that the
// app computes is plain text or HTML elements, never parsed LaTeX (spec 9.9).
const NUMERALS: ReadonlyArray<readonly [number, string]> = [
[1000, "M"], [900, "CM"], [500, "D"], [400, "CD"], [100, "C"], [90, "XC"],
[50, "L"], [40, "XL"], [10, "X"], [9, "IX"], [5, "V"], [4, "IV"], [1, "I"],
];
/** A resolution as a Roman numeral, for example 4 -> "IV". */
export function toRoman(n: number): string {
if (!Number.isInteger(n) || n < 1) throw new Error(`There is no Roman numeral for ${n}.`);
let rest = n;
let out = "";
for (const [value, symbol] of NUMERALS) {
while (rest >= value) {
out += symbol;
rest -= value;
}
}
return out;
}
Create lib/spec/precheck.ts:
import catalogue from "@/data/catalogue-summary.json" with { type: "json" };
import limits from "@/data/supported-limits.json" with { type: "json" };
import { toRoman } from "@/lib/report/format";
import { MESSAGES, fill } from "@/lib/translate/messages";
import { RUN_SIZES, type CoreSpec } from "./validate";
// Feasibility pre-checks (spec Section 6). They run before R, from the catalogue summary, and
// stop the inputs for which FrF2 stops with an unclear error or silently makes another design:
// more runs than the full factorial (FrF2 replicates it), a run size that cannot reach the
// minimum resolution (codegen gives only nruns, so FrF2 makes a lower resolution), a size between
// catalogue entries ("subscript out of bounds"), a full factorial above the supported runs, and
// a broken catalogue entry (decision P8). Each rule has a case in
// replication_materials/tests/fixtures/precheck-cases.json, which a native R test runs.
// The messages state facts (spec 6.2).
export interface RunSizeEntry {
nruns: number;
entry: string;
resolution: number;
status: string;
}
export interface CatalogueFactors {
nfactors: number;
run_sizes: RunSizeEntry[];
smallest_runs_by_resolution: { resolution: number; nruns: number; entry: string }[];
}
export interface PrecheckData {
factors: CatalogueFactors[];
max_runs: number;
max_total_rows: number;
}
export const DEFAULT_PRECHECK_DATA: PrecheckData = {
factors: catalogue.factors,
max_runs: limits.max_runs,
max_total_rows: limits.max_total_rows,
};
export interface PrecheckInput {
nfactors: number;
nruns: number | null;
min_resolution: number | null;
replications: number;
center_points: number;
all_quantitative: boolean;
}
export interface PrecheckResult {
/** True when no message stops "Generate design". */
ok: boolean;
messages: string[];
/** Facts that do not stop the design (the full factorial note of spec 6.2). */
notes: string[];
/** The runs of one replicate that FrF2 will make, when the checks know them. */
nruns: number | null;
}
const T = MESSAGES.precheck;
const thousands = (n: number) => n.toLocaleString("en-US");
export function precheck(input: PrecheckInput, data: PrecheckData = DEFAULT_PRECHECK_DATA): PrecheckResult {
const m = input.nfactors;
const r = input.min_resolution;
const full = 2 ** m;
const messages: string[] = [];
const notes: string[] = [];
const own = data.factors.find((f) => f.nfactors === m);
const maxFactors = Math.max(...data.factors.map((f) => f.nfactors));
const entryFor = (nruns: number) => own?.run_sizes.find((s) => s.nruns === nruns);
const maxFactorsFor = (nruns: number) =>
Math.max(...data.factors.filter((f) => f.run_sizes.some((s) => s.nruns === nruns)).map((f) => f.nfactors));
/** The smallest catalogue fraction with at least resolution `res`. */
const smallestFraction = (res: number) =>
(own?.smallest_runs_by_resolution ?? []).filter((s) => s.resolution >= res).sort((a, b) => a.nruns - b.nruns)[0];
const smallestDesign = (res: number): number | null => smallestFraction(res)?.nruns ?? (full <= data.max_runs ? full : null);
if (m > maxFactors) messages.push(fill(T.limit_factors, { limit: maxFactors }));
if (input.center_points > 0 && !input.all_quantitative) messages.push(T.center_points);
let size: number | null = null;
if (input.nruns !== null) {
const n = input.nruns;
if (!(RUN_SIZES as readonly number[]).includes(n) || n > data.max_runs) {
messages.push(fill(T.run_size, { nruns: n }));
} else if (m > n - 1) {
messages.push(fill(T.too_many_factors, { nruns: n, max: n - 1 }));
} else if (n > full) {
messages.push(fill(T.more_than_full, { m, full, nruns: n }));
} else if (n === full) {
size = n;
} else {
const entry = entryFor(n);
if (!entry) {
if (m <= maxFactors) messages.push(fill(T.limit_factors_runs, { limit: maxFactorsFor(n), nruns: n }));
} else {
size = n;
if (r !== null && entry.resolution < r) {
const smallest = smallestDesign(r);
const second = smallest === null
? fill(T.no_supported_resolution, { r: toRoman(r), m })
: fill(T.smallest_for_resolution, { r: toRoman(r), m, smallest });
messages.push(`${fill(T.resolution_unreachable, { m, nruns: n, highest: toRoman(entry.resolution) })} ${second}`);
}
if (entry.status === "broken") messages.push(fill(T.broken_entry, { entry: entry.entry, m, nruns: n }));
}
}
} else if (r !== null) {
const fraction = smallestFraction(r);
if (fraction) {
size = fraction.nruns;
const entry = entryFor(fraction.nruns);
if (entry?.status === "broken") messages.push(fill(T.broken_entry, { entry: entry.entry, m, nruns: fraction.nruns }));
} else if (full <= data.max_runs) {
size = full;
notes.push(fill(T.full_factorial_note, { r: toRoman(r), full }));
} else {
messages.push(fill(T.no_supported_resolution, { r: toRoman(r), m }));
}
}
if (size !== null) {
const rows = (size + input.center_points) * input.replications;
if (rows > data.max_total_rows) messages.push(fill(T.limit_rows, { limit: thousands(data.max_total_rows), rows: thousands(rows) }));
}
return { ok: messages.length === 0, messages, notes, nruns: size };
}
/** The pre-checks of a valid core spec. */
export function precheckSpec(spec: CoreSpec, data: PrecheckData = DEFAULT_PRECHECK_DATA): PrecheckResult {
return precheck(
{
nfactors: spec.factors.length,
nruns: spec.nruns,
min_resolution: spec.min_resolution,
replications: spec.replications,
center_points: spec.center_points,
all_quantitative: spec.factors.every((f) => f.quantitative),
},
data,
);
}
Run: npx vitest run --project unit replication_materials/tests/unit/precheck.test.ts replication_materials/tests/unit/report-format.test.ts Expected: PASS (19 tests: 14 cases and 4 more in precheck.test.ts, 1 in report-format.test.ts).
Run: npm run check -- --fast Expected: "All checks passed." The R tests include the 14 new behaviour tests.
git add lib/spec/precheck.ts lib/report/format.ts content/messages.json replication_materials/tests/fixtures/precheck-cases.json replication_materials/tests/unit/precheck.test.ts replication_materials/tests/unit/report-format.test.ts replication_materials/tests/r/test-precheck-behaviour.R
git commit -m "feat: add the feasibility pre-checks with a factor limit per run size and the broken-entry rule (P8)"
11Task 4: The card model, its labels and project names
The card is the state of step 2. This task makes it a pure model with unit tests; Task 7 draws it.
Files:
- Create:
lib/card/card.ts,lib/card/labels.ts,lib/projects/name.ts - Modify:
lib/projects/schema.ts(thecardfield) - Test:
replication_materials/tests/unit/card.test.ts
Interfaces:
- Consumes:
TranslateResultAnswer(Task 2, type only);precheck,PrecheckInput,PrecheckResult(Task 3);CoreSpecSchema,CoreSpec,Level(lib/spec/validate.ts);SPEC_DEFAULTS,DEFAULT_LEVELS,hasDefaultNames(lib/spec/defaults.ts);defaultSeed,MAX_SEED(lib/spec/seed.ts);VOTERS,VoterId(lib/translate/schema.ts);DEFAULT_PROJECT_NAME(Task 1). - Produces, in
lib/card/card.ts:- Types
Card,CardFactor,TextField,BoolField,Origin,Flag,Choice,FieldPath("nruns" | ... | \factor:{key}`),ScalarKey,BoolKey,CardIssue { path: FieldPath | "factors"; message: string },CardStatus { spec; issues; precheck; openFlags; canGenerate }; the schemaCardSchema; the constantVOTER_STATES`. - Functions
emptyCard(now, newId),cardFromTranslation(answer, now, newId),fieldAt(card, path),editText(card, path, value),editBool(card, path, value),chooseValue(card, path, choice),confirmValue(card, path),keepFactors(card),addFactor(card, id),removeFactor(card, id),moveFactor(card, id, step): { card; announcement },cardToSpec(card),precheckInput(card),openFlags(card),cardStatus(card),levelValue(text).
- Types
- Produces, in
lib/card/labels.ts:VOTER_NAMES,originLabel(origin),agreeLabel(agree),flagLabel(flag, unit?),votersSummary(voters),voterLine(line),jevOutcomeText(voters). - Produces, in
lib/projects/name.ts:nameFromRequest(text),nameFromDesign(nfactors, nruns). - Produces: the
Projectfieldcard: Card | null(default null).
Create replication_materials/tests/unit/card.test.ts:
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import {
addFactor, cardFromTranslation, cardStatus, cardToSpec, chooseValue, confirmValue, editBool, editText, emptyCard,
keepFactors, moveFactor, openFlags, removeFactor, type Card, type FieldPath,
} from "@/lib/card/card";
import { agreeLabel, flagLabel, jevOutcomeText, originLabel, voterLine, votersSummary } from "@/lib/card/labels";
import { nameFromDesign, nameFromRequest } from "@/lib/projects/name";
import { ProjectSchema, newProject } from "@/lib/projects/schema";
import type { TranslateResultAnswer } from "@/lib/translate/response";
const NOW = new Date(2026, 9, 16, 10, 0, 0);
const fixture = (name: string) =>
JSON.parse(readFileSync(`replication_materials/tests/fixtures/translate/${name}.json`, "utf8")).body as TranslateResultAnswer;
const ids = () => {
let n = 0;
return () => `f${++n}`;
};
const coatingCard = () => cardFromTranslation(fixture("coating-design"), NOW, ids());
describe("card from a translation (spec 5.5 to 5.7)", () => {
it("fills the coating card with quotes, defaults, agreement and the voters", () => {
const card = coatingCard();
expect(card.factors.map((f) => f.name.value)).toEqual(["temperature", "pressure", "speed", "time", "feed", "depth", "coolant"]);
expect(card.nruns.value).toBe("16");
expect(originLabel(card.nruns.origin)).toBe("From your text: “16 runs”");
expect(agreeLabel(card.nruns.agree)).toBe("4 of 4 agree");
expect(agreeLabel(card.factors[0].name.agree)).toBe("3 of 3 agree");
expect(originLabel(card.factors[0].low.origin)).toBe("From your text: “150”");
expect(card.factors[0].unit.value).toBe("C");
expect(originLabel(card.factors[0].unit.origin)).toBeNull();
expect(card.seed.value).toBe("20261016");
expect(originLabel(card.seed.origin)).toBe("default (today's date)");
expect(agreeLabel(card.seed.agree)).toBeNull();
expect(originLabel(card.replications.origin)).toBe("default");
expect(originLabel(card.min_resolution.origin)).toBe("not given");
expect(votersSummary(card.voters!)).toBe("4 of 4 checked");
expect(card.voters!.lines.map(voterLine)).toEqual(["OpenAI: answered", "Anthropic: answered", "Google: answered", "Jev (TypeSafe): answered"]);
// The recorded answer has Jev's known misjudgement of level ranges (3A ledger): a flag only.
expect(jevOutcomeText(card.voters!)).toBe("Jev read the request as a request outside the scope (1 of 4).");
});
it("gives the spec of the translation with the default seed, ready to generate", () => {
const status = cardStatus(coatingCard());
expect(status.openFlags).toBe(0);
expect(status.precheck?.ok).toBe(true);
expect(status.canGenerate).toBe(true);
expect(status.spec).toEqual({ ...fixture("coating-design").spec, seed: 20261016 });
});
it("labels default names and a request that gives only a resolution", () => {
const card = cardFromTranslation(fixture("resolution-only"), NOW, ids());
expect(card.factors.map((f) => f.name.value)).toEqual(["A", "B", "C", "D", "E", "F", "G", "H", "J"]);
expect(originLabel(card.factors[0].name.origin)).toBe("default");
expect(card.nruns.value).toBe("");
expect(originLabel(card.nruns.origin)).toBe("not given");
expect(card.min_resolution.value).toBe("4");
expect(cardStatus(card).precheck?.nruns).toBe(32);
});
it("shows no quote for a value that failed the evidence check, and blocks until the user confirms it (P11)", () => {
const answer = { ...fixture("coating-design"), check_fields: ["factors[0].low"] };
const card = cardFromTranslation(answer, NOW, ids());
const low = card.factors[0].low;
expect(originLabel(low.origin)).toBe("From the interpretation");
expect(flagLabel(low.flag!)).toBe("Check this value");
expect(openFlags(card)).toBe(1);
expect(cardStatus(card).canGenerate).toBe(false);
const confirmed = confirmValue(card, `factor:${card.factors[0].id}:low`);
expect(originLabel(confirmed.factors[0].low.origin)).toBe("Confirmed by you");
expect(cardStatus(confirmed).canGenerate).toBe(true);
});
it("lists each value of a disagreement with its count and takes the value the user selects (spec 5.6)", () => {
const base = fixture("coating-design");
const answer = structuredClone(base);
answer.agreement.fields.nruns = {
agree: false,
answered: 4,
values: [
{ value: "n:16", display: "16", count: 3, voters: ["openai", "anthropic", "jev"] },
{ value: "n:32", display: "32", count: 1, voters: ["google"] },
],
};
const card = cardFromTranslation(answer, NOW, ids());
expect(flagLabel(card.nruns.flag!, "runs")).toBe("The voters gave different values: 16 runs: 3; 32 runs: 1");
expect(cardStatus(card).canGenerate).toBe(false);
const chosen = chooseValue(card, "nruns", card.nruns.flag!.choices[1]);
expect(chosen.nruns.value).toBe("32");
expect(originLabel(chosen.nruns.origin)).toBe("Edited by you");
expect(chosen.nruns.flag?.resolved).toBe(true);
const kept = chooseValue(card, "nruns", card.nruns.flag!.choices[0]);
expect(originLabel(kept.nruns.origin)).toBe("Confirmed by you");
expect(cardStatus(kept).canGenerate).toBe(true);
});
it("resolves a disagreement on the number of factors with Keep these factors or an edit of the list (P9)", () => {
const answer = structuredClone(fixture("coating-design"));
answer.agreement.fields.nfactors = {
agree: false,
answered: 4,
values: [
{ value: "n:7", display: "7", count: 3, voters: ["openai", "anthropic", "google"] },
{ value: "n:6", display: "6", count: 1, voters: ["jev"] },
],
};
const card = cardFromTranslation(answer, NOW, ids());
expect(flagLabel(card.nfactors.flag!, "factors")).toBe("The voters gave different values: 7 factors: 3; 6 factors: 1");
expect(openFlags(card)).toBe(1);
expect(openFlags(keepFactors(card))).toBe(0);
expect(openFlags(removeFactor(card, card.factors[6].id))).toBe(0);
});
it("names the voter states and Jev's different outcome (spec 5.6)", () => {
const answer = structuredClone(fixture("coating-design"));
answer.voters = answer.voters
.filter((v) => v.voter !== "jev")
.map((v) => (v.voter === "google" ? { ...v, status: "failed" as const, reason: "deadline" as const } : v.voter === "anthropic" ? { ...v, status: "failed" as const, reason: "schema" as const } : v));
answer.left_out = [{ voter: "jev", reason: "spend_cap" }];
answer.agreement = { ...answer.agreement, checked: 1, crossChecked: false };
const card = cardFromTranslation(answer, NOW, ids());
expect(card.voters!.lines.map(voterLine)).toEqual([
"OpenAI: answered",
"Anthropic: failed",
"Google: missed the deadline",
"Jev (TypeSafe): left out before the call (spend cap reached)",
]);
expect(votersSummary(card.voters!)).toBe("Not cross-checked");
expect(cardFromTranslation(fixture("resolution-only"), NOW, ids()).voters!.jev_outcome).toBeNull();
});
it("shows no agreement text for a field with one answer (P10)", () => {
const answer = structuredClone(fixture("coating-design"));
answer.agreement.fields.nruns = { agree: true, answered: 1, values: [{ value: "n:16", display: "16", count: 1, voters: ["openai"] }] };
expect(cardFromTranslation(answer, NOW, ids()).nruns.agree).toBeNull();
expect(agreeLabel(1)).toBeNull();
});
});
describe("card edits (spec 9.6, decision P9)", () => {
it("moves a factor with its labels and flags, and gives the announcement", () => {
const answer = { ...fixture("coating-design"), check_fields: ["factors[0].low"] };
const card = cardFromTranslation(answer, NOW, ids());
const moved = moveFactor(card, card.factors[0].id, 1);
expect(moved.announcement).toBe("temperature moved to position 2 of 7.");
expect(moved.card.factors[1].name.value).toBe("temperature");
expect(moved.card.factors[1].low.flag?.kind).toBe("check");
expect(moveFactor(card, card.factors[0].id, -1)).toEqual({ card, announcement: null });
});
it("marks an edit and derives the quantitative flag from the levels until the user sets it", () => {
const card = coatingCard();
const id = card.factors[0].id;
const edited = editText(card, `factor:${id}:low`, "cold");
expect(originLabel(edited.factors[0].low.origin)).toBe("Edited by you");
expect(edited.factors[0].quantitative.value).toBe(false);
const set = editBool(card, `factor:${id}:quantitative`, true);
expect(editText(set, `factor:${id}:low`, "cold").factors[0].quantitative.value).toBe(true);
});
it("adds and removes factors", () => {
const card = addFactor(coatingCard(), "new");
expect(card.factors).toHaveLength(8);
expect(card.factors[7].name.value).toBe("");
expect(removeFactor(card, "new").factors).toHaveLength(7);
});
});
describe("card validation (Review Focus 3)", () => {
const filled = (): Card => {
let card = emptyCard(NOW, ids());
card = editText(card, `factor:${card.factors[0].id}:name`, "Temp");
card = editText(card, `factor:${card.factors[1].id}:name`, "Speed");
return editText(card, "nruns", "4");
};
const messages = (card: Card) => {
const result = cardToSpec(card);
return result.ok ? [] : result.issues.map((i) => i.message);
};
it("starts a card filled by hand with two blank factors, the defaults and today's seed", () => {
const card = emptyCard(NOW, ids());
expect(card.factors).toHaveLength(2);
expect(card.factors[0].low.value).toBe("-1");
expect(originLabel(card.factors[0].low.origin)).toBe("default");
expect(card.seed.value).toBe("20261016");
expect(card.voters).toBeNull();
expect(messages(card)).toEqual(["Factor 1 needs a name.", "Factor 2 needs a name."]);
});
it("accepts a complete card", () => {
const status = cardStatus(filled());
expect(status.canGenerate).toBe(true);
expect(status.spec?.factors.map((f) => f.low)).toEqual([-1, -1]);
});
it("names each problem as a fact", () => {
const card = filled();
const [a, b] = card.factors.map((f) => f.id);
expect(messages(editText(card, `factor:${b}:name`, "Temp!"))).toEqual(['"Temp!" and "Temp" have the same R name (Temp).']);
expect(messages(editText(card, `factor:${a}:high`, "-1"))).toEqual(['The two levels of "Temp" are the same.']);
const textLevels = editText(editText(editBool(card, `factor:${a}:quantitative`, true), `factor:${a}:low`, "cold"), `factor:${a}:high`, "hot");
expect(messages(textLevels)).toEqual(['"Temp" is quantitative, so its levels must be numbers.']);
expect(messages(editText(card, "seed", "0"))).toEqual(["A randomized design needs a seed from 1 to 2,147,483,647."]);
expect(messages(editText(card, "seed", "2147483648"))).toEqual(["A randomized design needs a seed from 1 to 2,147,483,647."]);
expect(cardToSpec(editBool(editText(card, "seed", ""), "randomize", false)).ok).toBe(true);
expect(messages(editText(card, "nruns", ""))).toEqual(["Give the number of runs or the minimum resolution."]);
expect(messages(editText(card, "nruns", "12"))).toEqual(["The number of runs must be a power of 2 from 4 to 4096."]);
expect(messages(editText(card, "replications", "0"))).toEqual(["Replications must be a whole number of 1 or more."]);
});
it("adds the pre-check facts and blocks Generate design", () => {
const qualitative = editText(editText(filled(), `factor:${filled().factors[1].id}:low`, "slow"), "center_points", "2");
const status = cardStatus(qualitative);
expect(status.issues.map((i) => i.message)).toContain("Center points need all factors to be quantitative.");
expect(status.canGenerate).toBe(false);
let crowded = filled();
crowded = addFactor(addFactor(crowded, "c"), "d");
crowded = editText(editText(crowded, "factor:c:name", "Time"), "factor:d:name", "Feed");
const status4 = cardStatus(crowded);
expect(status4.precheck?.messages).toEqual(["4 runs can hold at most 3 factors."]);
expect(status4.spec).not.toBeNull();
expect(status4.canGenerate).toBe(false);
});
});
describe("storage and names", () => {
it("stores the card in the project (decision P7)", () => {
const project = { ...newProject(NOW, "00000000-0000-4000-8000-000000000001", "America/New_York"), card: coatingCard() };
expect(ProjectSchema.parse(project).card).toEqual(project.card);
expect(ProjectSchema.parse({ ...project, card: undefined }).card).toBeNull();
});
it("names a project from the request or the design (decision P14)", () => {
expect(nameFromRequest("We want to screen 7 factors in 16 runs: temperature 150 to 200 C")).toBe("We want to screen 7 factors …");
expect(nameFromRequest("Nine factors")).toBe("Nine factors");
expect(nameFromRequest(" ")).toBe("Untitled design");
expect(nameFromDesign(7, 16)).toBe("7 factors in 16 runs");
});
});
// Type check: a factor path names a factor id and a key.
export const examplePath: FieldPath = "factor:f1:name";
Run: npx vitest run --project unit replication_materials/tests/unit/card.test.ts Expected: FAIL. @/lib/card/card, @/lib/card/labels and @/lib/projects/name do not exist.
Create lib/card/card.ts:
import { z } from "zod";
import { DEFAULT_LEVELS, SPEC_DEFAULTS, hasDefaultNames } from "@/lib/spec/defaults";
import { precheck, type PrecheckInput, type PrecheckResult } from "@/lib/spec/precheck";
import { MAX_SEED, defaultSeed } from "@/lib/spec/seed";
import { CoreSpecSchema, type CoreSpec, type Level } from "@/lib/spec/validate";
import type { TranslateResultAnswer } from "@/lib/translate/response";
import { VOTERS } from "@/lib/translate/schema";
// The specification card of step 2 (spec Sections 5.5 to 5.7, 7.3, 9.3 and 9.6; decisions P9
// to P11). A pure model: the components call these functions and store the card in the
// project. Each factor has an id, so its labels and flags move with it when the order changes.
// Values are text while the user edits them; cardToSpec() turns them into a strict CoreSpec.
const OriginSchema = z.discriminatedUnion("kind", [
/** "From your text": the quote passed the evidence check (P11). */
z.object({ kind: z.literal("quote"), quote: z.string() }),
z.object({ kind: z.literal("default") }),
/** "default (today's date)": the seed (spec 7.3). */
z.object({ kind: z.literal("today") }),
/** "not given": runs or resolution that the request does not give. */
z.object({ kind: z.literal("none") }),
z.object({ kind: z.literal("edited") }),
z.object({ kind: z.literal("confirmed") }),
/** A value of the interpretation without a quote that passed the check. */
z.object({ kind: z.literal("model") }),
/** Units and the quantitative flag follow from the levels (spec 5.5): no label. */
z.object({ kind: z.literal("derived") }),
/** A field of a card filled by hand that the user has not typed yet: no label. */
z.object({ kind: z.literal("blank") }),
]);
export type Origin = z.infer<typeof OriginSchema>;
const ChoiceSchema = z.object({ display: z.string(), count: z.number(), voters: z.array(z.enum(VOTERS)) });
export type Choice = z.infer<typeof ChoiceSchema>;
const FlagSchema = z.object({
/** "disagree": the voters gave different values (spec 5.6). "check": the quote failed the evidence check (spec 5.7). */
kind: z.enum(["disagree", "check"]),
choices: z.array(ChoiceSchema),
resolved: z.boolean(),
});
export type Flag = z.infer<typeof FlagSchema>;
/** agree: the number of voters that gave the same value, when at least two did (P10). */
const fieldShape = { origin: OriginSchema, flag: FlagSchema.nullable(), agree: z.number().nullable() };
const TextFieldSchema = z.object({ value: z.string(), ...fieldShape });
const BoolFieldSchema = z.object({ value: z.boolean(), ...fieldShape });
export type TextField = z.infer<typeof TextFieldSchema>;
export type BoolField = z.infer<typeof BoolFieldSchema>;
const CardFactorSchema = z.object({
id: z.string(),
name: TextFieldSchema,
low: TextFieldSchema,
high: TextFieldSchema,
unit: TextFieldSchema,
quantitative: BoolFieldSchema,
});
export type CardFactor = z.infer<typeof CardFactorSchema>;
export const VOTER_STATES = ["answered", "missed_deadline", "failed", "spend_cap", "no_key"] as const;
const VotersSchema = z.object({
checked: z.number(),
total: z.number(),
crossChecked: z.boolean(),
lines: z.array(z.object({ voter: z.enum(VOTERS), state: z.enum(VOTER_STATES) })),
/** Jev's outcome when it differs from the outcome of the LLM voters (spec 5.6: a flag only). */
jev_outcome: z.object({ outcome: z.string(), count: z.number() }).nullable(),
});
export type CardVoters = z.infer<typeof VotersSchema>;
export const CardSchema = z.object({
factors: z.array(CardFactorSchema),
/** The agreement on the number of factors. The count itself is factors.length. */
nfactors: z.object({ flag: FlagSchema.nullable(), agree: z.number().nullable() }),
nruns: TextFieldSchema,
min_resolution: TextFieldSchema,
replications: TextFieldSchema,
repeat_only: BoolFieldSchema,
center_points: TextFieldSchema,
randomize: BoolFieldSchema,
seed: TextFieldSchema,
/** null for a card filled by hand. */
voters: VotersSchema.nullable(),
});
export type Card = z.infer<typeof CardSchema>;
export const SCALAR_KEYS = ["nruns", "min_resolution", "replications", "center_points", "seed"] as const;
export const BOOL_KEYS = ["repeat_only", "randomize"] as const;
export type ScalarKey = (typeof SCALAR_KEYS)[number];
export type BoolKey = (typeof BOOL_KEYS)[number];
export type FactorTextKey = "name" | "low" | "high" | "unit";
export type FactorKey = FactorTextKey | "quantitative";
/** A field of the card: "nruns", "randomize", or "factor:<id>:name". */
export type FieldPath = ScalarKey | BoolKey | `factor:${string}:${FactorKey}`;
export interface CardIssue {
path: FieldPath | "factors";
message: string;
}
const FACTOR_PATH = /^factor:(.+):(name|low|high|unit|quantitative)$/;
const NUMBER_PATTERN = /^[-+]?(\d+\.?\d*|\.\d+)([eE][-+]?\d+)?$/;
const text = (value: string, origin: Origin): TextField => ({ value, origin, flag: null, agree: null });
const bool = (value: boolean, origin: Origin): BoolField => ({ value, origin, flag: null, agree: null });
/** A level as the spec holds it: a number if the text is a number, else the trimmed text. */
export function levelValue(input: string): Level {
const t = input.trim();
return NUMBER_PATTERN.test(t) ? Number(t) : t;
}
function wholeNumber(input: string): number | null {
const t = input.trim();
return /^\d+$/.test(t) ? Number(t) : null;
}
function newFactor(id: string): CardFactor {
return {
id,
name: text("", { kind: "blank" }),
low: text(String(DEFAULT_LEVELS.low), { kind: "default" }),
high: text(String(DEFAULT_LEVELS.high), { kind: "default" }),
unit: text("", { kind: "derived" }),
quantitative: bool(true, { kind: "derived" }),
};
}
/** The card of "Fill in the form yourself" (spec 9.3): two blank factors, the defaults and today's seed. */
export function emptyCard(now: Date, newId: () => string): Card {
return {
factors: [newFactor(newId()), newFactor(newId())],
nfactors: { flag: null, agree: null },
nruns: text("", { kind: "none" }),
min_resolution: text("", { kind: "none" }),
replications: text(String(SPEC_DEFAULTS.replications), { kind: "default" }),
repeat_only: bool(SPEC_DEFAULTS.repeat_only, { kind: "default" }),
center_points: text(String(SPEC_DEFAULTS.center_points), { kind: "default" }),
randomize: bool(SPEC_DEFAULTS.randomize, { kind: "default" }),
seed: text(String(defaultSeed(now)), { kind: "today" }),
voters: null,
};
}
function votersOf(answer: TranslateResultAnswer): CardVoters {
const lines = VOTERS.flatMap((voter): CardVoters["lines"] => {
const report = answer.voters.find((v) => v.voter === voter);
if (report) {
const state = report.status === "valid" ? "answered" : report.reason === "deadline" ? "missed_deadline" : "failed";
return [{ voter, state }];
}
const left = answer.left_out.find((l) => l.voter === voter);
return left ? [{ voter, state: left.reason }] : [];
});
const jev = answer.agreement.outcome.values.find((v) => v.voters.includes("jev"));
return {
checked: answer.agreement.checked,
total: answer.agreement.total,
crossChecked: answer.agreement.crossChecked,
lines,
jev_outcome: jev && jev.display !== answer.outcome ? { outcome: jev.display, count: jev.count } : null,
};
}
/** The card of a design request (spec 4.3, item 5). */
export function cardFromTranslation(answer: TranslateResultAnswer, now: Date, newId: () => string): Card {
const spec = answer.spec;
if (answer.outcome !== "design_request" || spec === null) throw new Error("A card needs a design request with a spec.");
const checks = new Set(answer.check_fields);
const quotes = new Map<string, string>();
for (const item of answer.evidence) if (!quotes.has(item.field)) quotes.set(item.field, item.quote);
const evidenced = (path: string): Origin => {
const quote = quotes.get(path);
return !checks.has(path) && quote !== undefined ? { kind: "quote", quote } : { kind: "model" };
};
const QUIET = new Set(["default", "today", "none"]);
const withAgreement = <F extends TextField | BoolField>(field: F, key: string): F => {
const fa = answer.agreement.fields[key];
let flag: Flag | null = null;
let agree: number | null = null;
if (fa && fa.values.length > 1) {
flag = { kind: "disagree", choices: fa.values.map((v) => ({ display: v.display, count: v.count, voters: v.voters })), resolved: false };
} else if (fa && fa.answered >= 2 && !QUIET.has(field.origin.kind)) {
agree = fa.answered;
}
if (!flag && checks.has(key)) flag = { kind: "check", choices: [], resolved: false };
return { ...field, flag, agree };
};
const defaultNames = hasDefaultNames(spec.factors.map((f) => f.name));
const factors = spec.factors.map((f, i): CardFactor => {
const p = `factors[${i}]`;
const defaultLevels = f.low === DEFAULT_LEVELS.low && f.high === DEFAULT_LEVELS.high;
return {
id: newId(),
name: withAgreement(text(f.name, defaultNames ? { kind: "default" } : evidenced(`${p}.name`)), `${p}.name`),
low: withAgreement(text(String(f.low), defaultLevels ? { kind: "default" } : evidenced(`${p}.low`)), `${p}.low`),
high: withAgreement(text(String(f.high), defaultLevels ? { kind: "default" } : evidenced(`${p}.high`)), `${p}.high`),
unit: withAgreement(text(f.unit ?? "", { kind: "derived" }), `${p}.unit`),
quantitative: withAgreement(bool(f.quantitative, { kind: "derived" }), `${p}.quantitative`),
};
});
const count = answer.agreement.fields.nfactors;
let countFlag: Flag | null = null;
let countAgree: number | null = null;
if (count && count.values.length > 1) {
countFlag = { kind: "disagree", choices: count.values.map((v) => ({ display: v.display, count: v.count, voters: v.voters })), resolved: false };
} else if (count && count.answered >= 2) {
countAgree = count.answered;
}
if (!countFlag && checks.has("factors")) countFlag = { kind: "check", choices: [], resolved: false };
const scalar = (key: ScalarKey, value: number | null, isDefault: boolean, defaultOrigin: Origin, fallback: string) =>
withAgreement(value === null || isDefault ? text(value === null ? fallback : String(value), defaultOrigin) : text(String(value), evidenced(key)), key);
return {
factors,
nfactors: { flag: countFlag, agree: countAgree },
nruns: scalar("nruns", spec.nruns, false, { kind: "none" }, ""),
min_resolution: scalar("min_resolution", spec.min_resolution, false, { kind: "none" }, ""),
replications: scalar("replications", spec.replications, spec.replications === SPEC_DEFAULTS.replications, { kind: "default" }, ""),
repeat_only: withAgreement(spec.repeat_only === SPEC_DEFAULTS.repeat_only ? bool(spec.repeat_only, { kind: "default" }) : bool(spec.repeat_only, evidenced("repeat_only")), "repeat_only"),
center_points: scalar("center_points", spec.center_points, spec.center_points === SPEC_DEFAULTS.center_points, { kind: "default" }, ""),
randomize: withAgreement(spec.randomize === SPEC_DEFAULTS.randomize ? bool(spec.randomize, { kind: "default" }) : bool(spec.randomize, evidenced("randomize")), "randomize"),
seed: withAgreement(spec.seed === null ? text(String(defaultSeed(now)), { kind: "today" }) : text(String(spec.seed), evidenced("seed")), "seed"),
voters: votersOf(answer),
};
}
export function fieldAt(card: Card, path: FieldPath): TextField | BoolField | undefined {
const m = FACTOR_PATH.exec(path);
if (m) return card.factors.find((f) => f.id === m[1])?.[m[2] as FactorKey];
return card[path as ScalarKey | BoolKey];
}
function withField(card: Card, path: FieldPath, field: TextField | BoolField): Card {
const m = FACTOR_PATH.exec(path);
if (m) return { ...card, factors: card.factors.map((f) => (f.id === m[1] ? ({ ...f, [m[2]]: field } as CardFactor) : f)) };
return { ...card, [path]: field } as Card;
}
/** Units and the quantitative flag follow from the levels (spec 5.5) until the user sets the flag. */
function deriveQuantitative(f: CardFactor): CardFactor {
if (f.quantitative.origin.kind !== "derived") return f;
const numeric = typeof levelValue(f.low.value) === "number" && typeof levelValue(f.high.value) === "number";
return { ...f, quantitative: { ...f.quantitative, value: numeric } };
}
const resolved = (flag: Flag | null): Flag | null => (flag ? { ...flag, resolved: true } : null);
/** An edit by the user: "Edited by you", and the flag of the field counts as resolved. */
export function editText(card: Card, path: FieldPath, value: string): Card {
const field = fieldAt(card, path);
if (!field || typeof field.value !== "string") throw new Error(`${path} is not a text field of the card.`);
const next = withField(card, path, { ...field, value, origin: { kind: "edited" }, flag: resolved(field.flag) });
const m = FACTOR_PATH.exec(path);
if (m && (m[2] === "low" || m[2] === "high")) {
return { ...next, factors: next.factors.map((f) => (f.id === m[1] ? deriveQuantitative(f) : f)) };
}
return next;
}
export function editBool(card: Card, path: FieldPath, value: boolean): Card {
const field = fieldAt(card, path);
if (!field || typeof field.value !== "boolean") throw new Error(`${path} is not a check box of the card.`);
return withField(card, path, { ...field, value, origin: { kind: "edited" }, flag: resolved(field.flag) });
}
/** The user selects one value of a disagreement (spec 5.6). */
export function chooseValue(card: Card, path: FieldPath, choice: Choice): Card {
const field = fieldAt(card, path);
if (!field?.flag || field.flag.kind !== "disagree") throw new Error(`${path} has no disagreement.`);
const value = typeof field.value === "boolean" ? choice.display === "true" : choice.display === "not given" ? "" : choice.display;
const origin: Origin = value === field.value ? { kind: "confirmed" } : { kind: "edited" };
return withField(card, path, { ...field, value, origin, flag: { ...field.flag, resolved: true } } as TextField | BoolField);
}
/** The user confirms a value marked "Check this value" (spec 5.7). */
export function confirmValue(card: Card, path: FieldPath): Card {
const field = fieldAt(card, path);
if (!field?.flag) throw new Error(`${path} has no flag.`);
return withField(card, path, { ...field, origin: { kind: "confirmed" }, flag: { ...field.flag, resolved: true } });
}
/** "Keep these factors": resolves a flag on the number of factors (P9). */
export function keepFactors(card: Card): Card {
return { ...card, nfactors: { ...card.nfactors, flag: resolved(card.nfactors.flag) } };
}
export function addFactor(card: Card, id: string): Card {
return { ...card, factors: [...card.factors, newFactor(id)], nfactors: { ...card.nfactors, flag: resolved(card.nfactors.flag) } };
}
export function removeFactor(card: Card, id: string): Card {
return { ...card, factors: card.factors.filter((f) => f.id !== id), nfactors: { ...card.nfactors, flag: resolved(card.nfactors.flag) } };
}
/** "Move up" (-1) or "Move down" (+1), with the text to announce (spec 9.6). */
export function moveFactor(card: Card, id: string, step: -1 | 1): { card: Card; announcement: string | null } {
const i = card.factors.findIndex((f) => f.id === id);
const j = i + step;
if (i < 0 || j < 0 || j >= card.factors.length) return { card, announcement: null };
const factors = [...card.factors];
[factors[i], factors[j]] = [factors[j], factors[i]];
const name = factors[j].name.value.trim() || `Factor ${i + 1}`;
return { card: { ...card, factors }, announcement: `${name} moved to position ${j + 1} of ${factors.length}.` };
}
function issuePath(card: Card, path: readonly PropertyKey[]): FieldPath | "factors" {
if (path[0] === "factors" && typeof path[1] === "number") {
const factor = card.factors[path[1]];
const key = String(path[2]);
return factor && ["name", "low", "high", "unit", "quantitative"].includes(key) ? `factor:${factor.id}:${key as FactorKey}` : "factors";
}
const key = String(path[0]);
return (SCALAR_KEYS as readonly string[]).includes(key) || (BOOL_KEYS as readonly string[]).includes(key) ? (key as ScalarKey | BoolKey) : "factors";
}
/** The strict spec of the card, or the problems as facts (spec 9.7). */
export function cardToSpec(card: Card): { ok: true; spec: CoreSpec } | { ok: false; issues: CardIssue[] } {
const issues: CardIssue[] = [];
const need = (ok: boolean, path: FieldPath | "factors", message: string) => {
if (!ok) issues.push({ path, message });
};
need(card.factors.length >= 2, "factors", "A design needs at least 2 factors.");
card.factors.forEach((f, i) => {
const label = f.name.value.trim() || `Factor ${i + 1}`;
need(f.name.value.trim() !== "", `factor:${f.id}:name`, `Factor ${i + 1} needs a name.`);
need(f.low.value.trim() !== "" && f.high.value.trim() !== "", `factor:${f.id}:low`, `"${label}" needs a low level and a high level.`);
});
const blank = (field: TextField) => field.value.trim() === "";
const nruns = blank(card.nruns) ? null : wholeNumber(card.nruns.value);
need(blank(card.nruns) || nruns !== null, "nruns", "The number of runs must be a whole number.");
const minResolution = blank(card.min_resolution) ? null : wholeNumber(card.min_resolution.value);
need(blank(card.min_resolution) || (minResolution !== null && minResolution >= 3), "min_resolution", "The minimum resolution must be a whole number of 3 or more.");
const replications = wholeNumber(card.replications.value);
need(replications !== null && replications >= 1, "replications", "Replications must be a whole number of 1 or more.");
const centerPoints = wholeNumber(card.center_points.value);
need(centerPoints !== null, "center_points", "Center points must be a whole number of 0 or more.");
const seed = wholeNumber(card.seed.value);
if (card.randomize.value) need(seed !== null && seed >= 1 && seed <= MAX_SEED, "seed", "A randomized design needs a seed from 1 to 2,147,483,647.");
if (issues.length > 0) return { ok: false, issues };
const parsed = CoreSpecSchema.safeParse({
design_family: "regular",
factors: card.factors.map((f) => ({
name: f.name.value.trim(),
low: levelValue(f.low.value),
high: levelValue(f.high.value),
unit: f.unit.value.trim() === "" ? null : f.unit.value.trim(),
quantitative: f.quantitative.value,
})),
nruns,
min_resolution: minResolution,
replications,
repeat_only: card.repeat_only.value,
center_points: centerPoints,
randomize: card.randomize.value,
seed: card.randomize.value ? seed : null,
});
if (parsed.success) return { ok: true, spec: parsed.data };
return { ok: false, issues: parsed.error.issues.map((issue) => ({ path: issuePath(card, issue.path), message: issue.message })) };
}
/** The pre-check input of the card, or null while a count field is not a whole number. */
export function precheckInput(card: Card): PrecheckInput | null {
const optional = (field: TextField) => (field.value.trim() === "" ? null : wholeNumber(field.value));
const nruns = optional(card.nruns);
const minResolution = optional(card.min_resolution);
const replications = wholeNumber(card.replications.value);
const centerPoints = wholeNumber(card.center_points.value);
if (card.nruns.value.trim() !== "" && nruns === null) return null;
if (card.min_resolution.value.trim() !== "" && minResolution === null) return null;
if (replications === null || replications < 1 || centerPoints === null) return null;
if (nruns === null && minResolution === null) return null;
return {
nfactors: card.factors.length,
nruns,
min_resolution: minResolution,
replications,
center_points: centerPoints,
all_quantitative: card.factors.every((f) => f.quantitative.value),
};
}
/** The flags that still need a decision of the user. */
export function openFlags(card: Card): number {
const fields: (TextField | BoolField)[] = [
card.nruns, card.min_resolution, card.replications, card.repeat_only, card.center_points, card.randomize, card.seed,
...card.factors.flatMap((f) => [f.name, f.low, f.high, f.unit, f.quantitative]),
];
const open = fields.filter((f) => f.flag && !f.flag.resolved).length;
return open + (card.nfactors.flag && !card.nfactors.flag.resolved ? 1 : 0);
}
export interface CardStatus {
spec: CoreSpec | null;
issues: CardIssue[];
precheck: PrecheckResult | null;
openFlags: number;
/** "Generate design" is available (spec 5.6 and 6). */
canGenerate: boolean;
}
export function cardStatus(card: Card): CardStatus {
const result = cardToSpec(card);
const input = precheckInput(card);
const checked = input && input.nfactors >= 2 ? precheck(input) : null;
const flags = openFlags(card);
const spec = result.ok ? result.spec : null;
return {
spec,
issues: result.ok ? [] : result.issues,
precheck: checked,
openFlags: flags,
canGenerate: spec !== null && flags === 0 && (checked?.ok ?? false),
};
}
Create lib/card/labels.ts:
import type { VoterId } from "@/lib/translate/schema";
import type { CardVoters, Flag, Origin } from "./card";
// The fixed label texts of the card (spec 5.5 to 5.7 and 9.7; decisions P10 and P11).
export const VOTER_NAMES: Record<VoterId, string> = { openai: "OpenAI", anthropic: "Anthropic", google: "Google", jev: "Jev (TypeSafe)" };
const STATE_TEXT = {
answered: "answered",
missed_deadline: "missed the deadline",
failed: "failed",
spend_cap: "left out before the call (spend cap reached)",
no_key: "left out before the call (no key)",
} as const;
const OUTCOME_TEXT: Record<string, string> = {
design_request: "a design request",
needs_clarification: "a request that needs clarification",
out_of_scope: "a request outside the scope",
not_supported_in_version: "a request that this version does not support",
};
export function originLabel(origin: Origin): string | null {
switch (origin.kind) {
case "quote":
return `From your text: “${origin.quote}”`;
case "default":
return "default";
case "today":
return "default (today's date)";
case "none":
return "not given";
case "edited":
return "Edited by you";
case "confirmed":
return "Confirmed by you";
case "model":
return "From the interpretation";
case "derived":
case "blank":
return null;
}
}
/** "4 of 4 agree", only when at least two voters gave the field (P10). */
export function agreeLabel(agree: number | null): string | null {
return agree !== null && agree >= 2 ? `${agree} of ${agree} agree` : null;
}
/** "Check this value", or each value with its count, for example "16 runs: 3; 32 runs: 1". */
export function flagLabel(flag: Flag, unit: string | null = null): string {
if (flag.kind === "check") return "Check this value";
const values = flag.choices.map((c) => `${c.display}${unit && c.display !== "not given" ? ` ${unit}` : ""}: ${c.count}`);
return `The voters gave different values: ${values.join("; ")}`;
}
export function votersSummary(voters: CardVoters): string {
return voters.crossChecked ? `${voters.checked} of ${voters.total} checked` : "Not cross-checked";
}
export function voterLine(line: CardVoters["lines"][number]): string {
return `${VOTER_NAMES[line.voter]}: ${STATE_TEXT[line.state]}`;
}
export function jevOutcomeText(voters: CardVoters): string | null {
const jev = voters.jev_outcome;
if (!jev) return null;
return `Jev read the request as ${OUTCOME_TEXT[jev.outcome] ?? jev.outcome} (${jev.count} of ${voters.checked}).`;
}
Create lib/projects/name.ts:
import { DEFAULT_PROJECT_NAME } from "./schema";
// Project names for the sidebar (spec 9.1, decision P14). A rename control comes with the
// project menu (Milestone 3C).
/** The first six words of the request, with "…" when the request is longer. */
export function nameFromRequest(text: string): string {
const words = text.trim().split(/\s+/).filter(Boolean);
if (words.length === 0) return DEFAULT_PROJECT_NAME;
const short = words.slice(0, 6).join(" ");
const name = words.length > 6 ? `${short} …` : short;
return name.length > 60 ? `${name.slice(0, 59)}…` : name;
}
/** The name of a card filled by hand, after its design is made. */
export function nameFromDesign(nfactors: number, nruns: number): string {
return `${nfactors} factors in ${nruns} runs`;
}
In lib/projects/schema.ts, add the import:
import { CardSchema } from "@/lib/card/card";
and add to ProjectSchema, after last_step:
/** The specification card of step 2 (decision P9). */
card: CardSchema.nullable().default(null),
In newProject, add before frf2_messages: [],:
card: null,
Run: npx vitest run --project unit replication_materials/tests/unit/card.test.ts replication_materials/tests/unit/projects.test.ts Expected: PASS. In projects.test.ts, the test "reads a Milestone 2 record" also takes out card; change its first line to const { frf2_messages: _m, pending_rebuild: _p, card: _c, ...m2 } = project(ID1, 1);.
Run: npm run check -- --fast Expected: "All checks passed."
git add lib/card/card.ts lib/card/labels.ts lib/projects/name.ts lib/projects/schema.ts replication_materials/tests/unit/card.test.ts replication_materials/tests/unit/projects.test.ts
git commit -m "feat: add the card model of step 2 with flags that move with their factor, labels and project names (P9 to P11, P14)"
12Task 5: One global announcer, help panels and the glossary
Files:
- Modify:
package.json,package-lock.json(katex@0.18.11as a devDependency) - Create:
lib/a11y/announcer.ts,components/a11y/Announcer.tsx - Create:
content/glossary.json,scripts/build-glossary.mjs,data/glossary.json(built),lib/help/keys.ts,lib/help/glossary.ts - Create:
components/help/HelpContext.tsx,components/help/WithHelp.tsx,app/glossary/page.tsx - Modify:
app/layout.tsx,components/shell/EngineStatus.tsx,components/shell/ProjectSidebar.tsx,components/shell/Workspace.tsx,components/shell/StepRail.tsx,components/shell/StepPanel.tsx,components/shell/SiteFooter.tsx - Test:
replication_materials/tests/unit/announcer.test.ts,replication_materials/tests/unit/glossary.test.ts,replication_materials/tests/e2e/help.spec.ts
Interfaces:
- Consumes:
readShowExplanations,writeShowExplanations(lib/projects/pointer.ts). - Produces:
announce(text: string, politeness?: "polite" | "assertive"): void,getAnnouncement(),subscribeAnnouncements(listener)inlib/a11y/announcer.ts;<Announcer />in the root layout.HELP_KEYSandtype HelpKeyinlib/help/keys.ts;GLOSSARY,REFERENCES,glossaryEntry(key),reviewLabel(entry),type GlossaryEntryinlib/help/glossary.ts.<HelpProvider>,useHelp(): { showAll; setShowAll }(components/help/HelpContext.tsx);<WithHelp helpKey computed?>{(button) => ...}</WithHelp>(components/help/WithHelp.tsx). Tasks 6 to 9 put every?button throughWithHelp.renderGlossary(source)andrenderText(text)inscripts/build-glossary.mjs.
The announcer's polite region keeps role="status": engine.spec.ts reads the announced engine text with getByRole("status"), and it passes without a change.
Ask the user to pause Dropbox sync (Global Constraints). Then run: npm install --save-dev --save-exact katex@0.18.11 Expected: package.json lists "katex": "0.18.11" under devDependencies, and npm ls katex prints katex@0.18.11.
Create content/glossary.json (35 entries drafted by Claude on 2026-10-16, decision P3; the references were checked against Crossref on 2026-10-16, decision P4):
{
"version": 1,
"note": "Help text of the ? buttons and the glossary page (spec Sections 9.5 and 14.2). Drafted by Claude on 2026-10-16 (decision P3); each entry is a draft until Alan R. Vazquez reviews it and fills reviewed_by and reviewed_on (YYYY-MM-DD). Math only inside ( ... ). After an edit, run node scripts/build-glossary.mjs.",
"references": [
{
"key": "Box and Meyer (1986)",
"citation": "Box, G. E. P., and Meyer, R. D. (1986). An analysis for unreplicated fractional factorials. Technometrics, 28(1), 11-18.",
"url": "https://doi.org/10.1080/00401706.1986.10488093"
},
{
"key": "Groemping (2014)",
"citation": "Groemping, U. (2014). R package FrF2 for creating and analyzing fractional factorial 2-level designs. Journal of Statistical Software, 56(1), 1-56.",
"url": "https://doi.org/10.18637/jss.v056.i01"
},
{
"key": "Hamada and Wu (1992)",
"citation": "Hamada, M., and Wu, C. F. J. (1992). Analysis of designed experiments with complex aliasing. Journal of Quality Technology, 24(3), 130-137.",
"url": "https://doi.org/10.1080/00224065.1992.11979383"
},
{
"key": "Li, Sudarsanam and Frey (2006)",
"citation": "Li, X., Sudarsanam, N., and Frey, D. D. (2006). Regularities in data from factorial experiments. Complexity, 11(5), 32-45.",
"url": "https://doi.org/10.1002/cplx.20123"
},
{
"key": "Ockuly et al. (2017)",
"citation": "Ockuly, R. A., Weese, M. L., Smucker, B. J., Edwards, D. J., and Chang, L. (2017). Response surface experiments: A meta-analysis. Chemometrics and Intelligent Laboratory Systems, 164, 64-75.",
"url": "https://doi.org/10.1016/j.chemolab.2017.03.009"
},
{
"key": "Wu and Hamada (2021)",
"citation": "Wu, C. F. J., and Hamada, M. S. (2021). Experiments: Planning, analysis, and optimization (3rd ed.). Wiley.",
"url": "https://doi.org/10.1002/9781119470007"
},
{
"key": "Xu and Wu (2001)",
"citation": "Xu, H., and Wu, C. F. J. (2001). Generalized minimum aberration for asymmetrical fractional factorial designs. The Annals of Statistics, 29(4), 1066-1077.",
"url": "https://doi.org/10.1214/aos/1013699993"
}
],
"entries": [
{
"key": "step.describe",
"term": "Step 1: Describe",
"text": "The user writes the request for the experiment in plain words, in any language. \"Interpret request\" sends the text to the translation service. \"Fill in the form yourself\" opens an empty specification card. The request text and the clarification answers are the only data that leave the device.",
"reference": null,
"reviewed_by": null,
"reviewed_on": null
},
{
"key": "step.confirm",
"term": "Step 2: Confirm",
"text": "The specification card shows the factors, the runs, the minimum resolution, the replications, the center points, the randomization and the seed. Each value shows a quote from the request, a \"default\" label, an \"Edited by you\" label or an agreement flag. The user resolves each flagged value, and then \"Generate design\" becomes available. \"Move up\" and \"Move down\" change the factor order, which sets the design column of each factor.",
"reference": null,
"reviewed_by": null,
"reviewed_on": null
},
{
"key": "step.design",
"term": "Step 3: Design",
"text": "FrF2 makes the design in the browser from the confirmed specification. The tool checks the result before it shows the design. If a check fails, the tool shows the name of the failed check and does not show the design. The step shows the run sheet, the design properties and the R code.",
"reference": null,
"reviewed_by": null,
"reviewed_on": null
},
{
"key": "scope.out_of_scope",
"term": "Outside the scope",
"text": "The tool makes two-level regular fractional factorial designs and full factorials with the FrF2 package. Requests for factors with more than two levels, mixed levels, mixture designs or response surface modeling are outside the scope. Requests for optimal designs, designs for computer experiments or adaptive designs, and text that is not a design request, are also outside the scope. A fixed message names the category and gives no alternatives.",
"reference": null,
"reviewed_by": null,
"reviewed_on": null
},
{
"key": "scope.not_supported",
"term": "Not supported in this version",
"text": "This version does not support blocking, split-plot designs, hard-to-change factors, required estimable interactions or user-given generators. It does not support the choice of a catalogue design, the choice of a fraction or criteria other than minimum aberration, for example MaxC2. It does not support Plackett-Burman designs, foldover or the analysis of results. A fixed message names the feature and gives no alternatives.",
"reference": null,
"reviewed_by": null,
"reviewed_on": null
},
{
"key": "factors",
"term": "Factors",
"text": "A factor is a variable that the experiment sets at two levels. Each factor has a name, a low level, a high level, an optional unit and a quantitative flag. The number of factors is \\(k\\) in the notation \\(2^{k-p}\\). If a request gives only the number of factors, the tool uses the FrF2 names A, B, C and so on.",
"reference": null,
"reviewed_by": null,
"reviewed_on": null
},
{
"key": "factor_order",
"term": "Factor order",
"text": "The order of the factors in step 2 sets which factor gets which design column. The order does not change the WLP, the resolution or the number of clear 2FIs. It changes which named effects are aliased and which named factors have all 2FIs clear. \"Move up\" and \"Move down\" change the order.",
"reference": null,
"reviewed_by": null,
"reviewed_on": null
},
{
"key": "levels",
"term": "Levels",
"text": "Each factor has two levels, a low level and a high level. The real levels are the values that the experiment uses, for example 150 and 200, or \"slow\" and \"fast\". The coded levels are \\(-1\\) for low and \\(+1\\) for high, and \\(0\\) for a center point. If a request gives no levels, the default levels are \\(-1\\) and \\(+1\\).",
"reference": null,
"reviewed_by": null,
"reviewed_on": null
},
{
"key": "unit",
"term": "Unit",
"text": "The unit is optional text that the tool shows with the factor name. The unit does not change the design. Units need no quote from the request, because they follow from the levels.",
"reference": null,
"reviewed_by": null,
"reviewed_on": null
},
{
"key": "quantitative",
"term": "Quantitative factor",
"text": "A quantitative factor has levels that are numbers on a scale, for example a temperature. A factor with the levels \"off\" and \"on\" is not quantitative. Center points need all factors to be quantitative, because a center point is at the middle of each range. The quantitative flag needs no quote from the request, because it follows from the levels.",
"reference": null,
"reviewed_by": null,
"reviewed_on": null
},
{
"key": "nruns",
"term": "Number of runs",
"text": "The number of runs is the number of distinct runs in one replicate of the design. It is a power of 2 from 4 to 4096, and it equals \\(2^{k-p}\\). If a request gives only a minimum resolution, FrF2 takes the smallest catalogue design that has this resolution. If no fraction has this resolution, FrF2 makes the full factorial.",
"reference": "Groemping (2014)",
"reviewed_by": null,
"reviewed_on": null
},
{
"key": "min_resolution",
"term": "Minimum resolution",
"text": "The minimum resolution is the lowest resolution that the design must have. With a number of runs and a minimum resolution, the tool checks that the catalogue reaches this resolution before FrF2 makes the design. If the check fails, a message states the highest resolution in this number of runs. Fixed phrase rules map words to a resolution, for example \"estimate all two-factor interactions\" to resolution V.",
"reference": null,
"reviewed_by": null,
"reviewed_on": null
},
{
"key": "replications",
"term": "Replications",
"text": "The number of replications is the number of times that the experiment runs the whole design. The default is one replication. With repeat only off, which is the default, each replicate is a true replication. With repeat only on, the repetitions are repeated measurements of the same runs.",
"reference": "Groemping (2014)",
"reviewed_by": null,
"reviewed_on": null
},
{
"key": "repeat_only",
"term": "Repeat only",
"text": "Repeat only sets the FrF2 argument repeat.only. When it is on, the repetitions are repeated measurements of the same run, not new runs. The repetitions of a run stay together in the run order, and the randomization does not separate them. When it is off, which is the default, each replicate is a true replication.",
"reference": "Groemping (2014)",
"reviewed_by": null,
"reviewed_on": null
},
{
"key": "center_points",
"term": "Center points",
"text": "Center points are extra runs at the middle of the range of each factor, so all factors must be quantitative. They add a check for curvature. The WLP and the resolution do not use them. The number of rows is the number of runs plus the number of center points, times the number of replications.",
"reference": "Groemping (2014)",
"reviewed_by": null,
"reviewed_on": null
},
{
"key": "randomize",
"term": "Randomization",
"text": "By default, the run order is random. FrF2 sets the random order with the seed. With randomization off, the design is in standard order, and the R code has no seed. The run sheet shows the run order and the standard order of each run.",
"reference": "Groemping (2014)",
"reviewed_by": null,
"reviewed_on": null
},
{
"key": "seed",
"term": "Seed",
"text": "The seed is an integer from 1 to 2,147,483,647 that sets the random run order in FrF2. The default seed is the local date of the device as yyyymmdd, for example 20261014. The same seed, design and software versions give the same run order. The language model never selects a seed.",
"reference": null,
"reviewed_by": null,
"reviewed_on": null
},
{
"key": "evidence",
"term": "From your text (evidence)",
"text": "Each value that is not a default has a quote from the request in the \"From your text\" column. The tool checks that the quote occurs in the request and contains the value. If the check fails, the card marks the value \"Check this value\", and the user confirms or edits it. Other labels are \"default\", \"default (today's date)\" for the seed, and \"Edited by you\".",
"reference": null,
"reviewed_by": null,
"reviewed_on": null
},
{
"key": "agreement",
"term": "Agreement flags",
"text": "In ensemble mode, up to four voters read the request: three language models (OpenAI, Anthropic, Google) and TypeSafe Jev, which answers only closed questions. The card shows \"4 of 4 agree\" when the voters agree, or each value with its count (\"16 runs: 3; 32 runs: 1\"). The tool leaves out a failed or late voter (\"3 of 4 checked\"); with fewer than two valid voters, the card says \"Not cross-checked\". Agreement is a signal, not a vote: the user selects a value when the voters disagree.",
"reference": null,
"reviewed_by": null,
"reviewed_on": null
},
{
"key": "design_notation",
"term": "Design notation",
"text": "The notation \\(2^{k-p}\\) describes a design with \\(k\\) factors, \\(p\\) generators and \\(2^{k-p}\\) runs. The \\(k-p\\) base factors form a full factorial, and each of the \\(p\\) added factors comes from a generator. A full factorial has \\(p = 0\\) and \\(2^k\\) runs. For example, a \\(2^{7-3}\\) design has 7 factors, 3 generators and 16 runs.",
"reference": "Wu and Hamada (2021), Chapter 5",
"reviewed_by": null,
"reviewed_on": null
},
{
"key": "guarantee",
"term": "Guarantee statement",
"text": "A minimum aberration (MA) design minimizes \\(A_3\\) first, then \\(A_4\\), and so on, among the designs of the same size. FrF2 selects each fractional design from its catalogue. Until Alan R. Vazquez reviews the guarantee table, the statement names only the catalogue entry, for example \"Design from the FrF2 catalogue, entry 7-3.1.\" The statement makes no MA claim.",
"reference": "Wu and Hamada (2021), Chapter 5; Groemping (2014)",
"reviewed_by": null,
"reviewed_on": null
},
{
"key": "resolution",
"term": "Resolution",
"text": "The resolution is the length of the shortest word in the defining relation: \\(R = \\min\\{j \\ge 1 : A_j > 0\\}\\). In resolution III, no main effect is aliased with another main effect, but some main effects are aliased with two-factor interactions (2FIs). In resolution IV, no main effect is aliased with another main effect or a 2FI, but some 2FIs are aliased with each other. In resolution V, no main effect or 2FI is aliased with another main effect or 2FI.",
"reference": "Wu and Hamada (2021), Chapter 5",
"reviewed_by": null,
"reviewed_on": null
},
{
"key": "wlp",
"term": "Word length pattern (WLP)",
"text": "The word length pattern (WLP) is \\((A_1, \\ldots, A_k)\\), and \\(A_j\\) counts the words of length \\(j\\) in the defining relation. The tool computes the WLP from the design matrix, not only from the catalogue. The WLP counts words of every length, because the definition of minimum aberration uses the full WLP. If all \\(A_j = 0\\), the design is a full factorial.",
"reference": "Wu and Hamada (2021), Chapter 5; Xu and Wu (2001)",
"reviewed_by": null,
"reviewed_on": null
},
{
"key": "generators",
"term": "Generators",
"text": "A generator sets the column of an added factor equal to the product of some base factor columns, for example \\(E = ABC\\). The generators give the defining relation, for example \\(I = ABCE\\). The design has one generator for each added factor, so a \\(2^{k-p}\\) design has \\(p\\) generators. The tool reads the generators from the FrF2 catalogue entry and shows them with the real factor names.",
"reference": "Wu and Hamada (2021), Chapter 5; Groemping (2014)",
"reviewed_by": null,
"reviewed_on": null
},
{
"key": "clear_2fis",
"term": "Clear two-factor interactions",
"text": "A two-factor interaction (2FI) is clear if it is not aliased with any main effect or any other 2FI. The definition comes from FrF2 and does not look at interactions of three or more factors. A full factorial has all \\(\\binom{k}{2}\\) 2FIs clear. The report lists the clear 2FIs with the real factor names and gives their number.",
"reference": "Groemping (2014); Wu and Hamada (2021), Chapter 5",
"reviewed_by": null,
"reviewed_on": null
},
{
"key": "all_2fis_clear",
"term": "Factors with all 2FIs clear",
"text": "A factor has all 2FIs clear if every 2FI that contains the factor is clear. In the FrF2 design for 9 factors in 32 runs, only the factor in position 9 has this property. The factor order in step 2 sets which named factors have this property. In a design of resolution V or higher, and in a full factorial, every factor has this property.",
"reference": "Groemping (2014)",
"reviewed_by": null,
"reviewed_on": null
},
{
"key": "aliased_main",
"term": "Aliased main effects",
"text": "Two effects are aliased when the design cannot separate them: their estimates come from the same column. The list shows the alias chain of each main effect that is aliased with a 2FI, with the real factor names in factor order. Only resolution III designs have main effects aliased with 2FIs. The list does not show interactions of three or more factors, because of the effect hierarchy principle.",
"reference": "Wu and Hamada (2021), Chapter 5; Groemping (2014)",
"reviewed_by": null,
"reviewed_on": null
},
{
"key": "aliased_2fis",
"term": "Aliased 2FIs",
"text": "The list shows the alias chains of the 2FIs that are aliased with other 2FIs, with the real factor names in factor order. The chains leave out interactions of three or more factors, because of the effect hierarchy principle. The factor order in step 2 sets which named 2FIs are aliased with each other. A full factorial has no aliasing, so it has no alias chains.",
"reference": "Wu and Hamada (2021), Chapter 5; Groemping (2014)",
"reviewed_by": null,
"reviewed_on": null
},
{
"key": "run_order",
"term": "Run order",
"text": "The run order is the order in which the experiment carries out the runs. By default, FrF2 sets a random run order with the seed. The standard order is the order of the full table before randomization. With randomization off, the run order equals the standard order.",
"reference": "Groemping (2014)",
"reviewed_by": null,
"reviewed_on": null
},
{
"key": "frf2_messages",
"term": "FrF2 messages",
"text": "FrF2 messages are the warnings and messages that FrF2 gave while it made the design. The tool shows them as text.",
"reference": null,
"reviewed_by": null,
"reviewed_on": null
},
{
"key": "run_sheet",
"term": "Run sheet",
"text": "The run sheet has one row for each run, in run order. Each row shows the standard order, the real levels and the coded levels. The standard order is the order of the full table before randomization. Large tables have pages.",
"reference": null,
"reviewed_by": null,
"reviewed_on": null
},
{
"key": "r_code",
"term": "R code",
"text": "The R code is the exact code that ran in the browser to make the design. It uses the pkg::fun() style, for example FrF2::FrF2(), and it has no library() call. With the same R and package versions, the code gives the same design and run order.",
"reference": null,
"reviewed_by": null,
"reviewed_on": null
},
{
"key": "effect_hierarchy",
"term": "Effect hierarchy",
"text": "Low-order effects are more likely to be active than high-order effects. Main effects and two-factor interactions (2FIs) are usually more important than interactions of three or more factors. Thus, the evaluation of a two-level fractional factorial design usually does not look at interactions of three or more factors. The tool follows this principle, as FrF2 does.",
"reference": "Wu and Hamada (2021), Section 4.6; Li, Sudarsanam and Frey (2006)",
"reviewed_by": null,
"reviewed_on": null
},
{
"key": "effect_sparsity",
"term": "Effect sparsity",
"text": "Only a few effects (main effects or two-factor interactions) are active. An active effect has a larger absolute size than the noise.",
"reference": "Wu and Hamada (2021), Section 4.6; Box and Meyer (1986)",
"reviewed_by": null,
"reviewed_on": null
},
{
"key": "effect_heredity",
"term": "Effect heredity",
"text": "This principle applies only to interactions. Strong heredity: an interaction is more likely to be active if the main effects of all its factors are active. Weak heredity: an interaction is more likely to be active if the main effect of at least one of its factors is active.",
"reference": "Wu and Hamada (2021), Section 4.6; Hamada and Wu (1992)",
"reviewed_by": null,
"reviewed_on": null
}
]
}
Create replication_materials/tests/unit/announcer.test.ts:
import { describe, expect, it } from "vitest";
import { announce, getAnnouncement, subscribeAnnouncements } from "@/lib/a11y/announcer";
describe("global announcer (spec 9.10)", () => {
it("keeps the last text, tells the listeners and counts each call", () => {
let calls = 0;
const stop = subscribeAnnouncements(() => calls++);
const before = getAnnouncement().id;
announce("temperature moved to position 2 of 7.");
announce("temperature moved to position 2 of 7.");
expect(getAnnouncement()).toEqual({ text: "temperature moved to position 2 of 7.", politeness: "polite", id: before + 2 });
expect(calls).toBe(2);
stop();
announce("Report check failed. The design is not shown.", "assertive");
expect(calls).toBe(2);
expect(getAnnouncement().politeness).toBe("assertive");
});
});
Create replication_materials/tests/unit/glossary.test.ts:
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { GLOSSARY, glossaryEntry, reviewLabel } from "@/lib/help/glossary";
import { HELP_KEYS } from "@/lib/help/keys";
import { renderGlossary } from "../../../scripts/build-glossary.mjs";
const source = JSON.parse(readFileSync("content/glossary.json", "utf8"));
const built = JSON.parse(readFileSync("data/glossary.json", "utf8"));
describe("glossary (spec 9.5 and 9.9, decisions P3, P4 and P16)", () => {
it("has an entry for every ? key of the interface", () => {
for (const key of HELP_KEYS) expect(glossaryEntry(key), key).toBeDefined();
});
it("has unique keys, and a term and a text in each entry", () => {
const keys = source.entries.map((e: { key: string }) => e.key);
expect(new Set(keys).size).toBe(keys.length);
for (const e of source.entries) expect(e.term.length * e.text.length, e.key).toBeGreaterThan(0);
});
it("writes math only inside \\( ... \\), never with $", () => {
for (const e of source.entries) expect(e.text, e.key).not.toContain("$");
});
it("cites only the listed references", () => {
const keys: string[] = source.references.map((r: { key: string }) => r.key);
for (const e of source.entries) {
for (const part of e.reference ? e.reference.split("; ") : []) {
expect(keys.some((k) => part.startsWith(k)), `${e.key}: ${part}`).toBe(true);
}
}
});
it("is built: data/glossary.json equals the rendered source", () => {
expect(built).toEqual(renderGlossary(source));
});
it("renders the math as MathML with no KaTeX error", () => {
const html = GLOSSARY.map((e) => e.html).join("");
expect(html).toContain("<math");
expect(html).not.toContain("katex-error");
});
it("shows a draft until the reviewer and the date are both given", () => {
for (const e of source.entries) expect(e.reviewed_by === null, e.key).toBe(e.reviewed_on === null);
for (const e of source.entries.filter((x: { reviewed_on: string | null }) => x.reviewed_on !== null)) expect(e.reviewed_on).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(reviewLabel({ ...GLOSSARY[0], reviewed_by: null, reviewed_on: null })).toBe("Draft, not yet reviewed");
expect(reviewLabel({ ...GLOSSARY[0], reviewed_by: "Alan R. Vazquez", reviewed_on: "2026-11-02" })).toBe("Reviewed by Alan R. Vazquez on 2026-11-02");
});
it.skipIf(process.env.FFD_RELEASE !== "1")("has a reviewer for every entry (release gate, decision P3)", () => {
for (const e of source.entries) expect(e.reviewed_by, e.key).not.toBeNull();
});
});
Create replication_materials/tests/e2e/help.spec.ts:
import { expect, test } from "@playwright/test";
test("a ? button opens its panel in place, under the item (spec 9.5)", async ({ page }) => {
await page.goto("/app?step=3");
const button = page.getByRole("button", { name: "Help: Step 3: Design" });
await expect(button).toHaveAttribute("aria-expanded", "false");
// Step 3 has hidden help panels too, so the checks look at the visible ones.
await expect(page.getByText("What it means").filter({ visible: true })).toHaveCount(0);
await button.click();
await expect(button).toHaveAttribute("aria-expanded", "true");
await expect(page.getByText("What it means").filter({ visible: true })).toHaveCount(1);
await expect(page.getByText(/^Draft, not yet reviewed/).filter({ visible: true })).toHaveCount(1);
await expect(page.getByRole("link", { name: "Glossary: Step 3: Design" })).toHaveAttribute("href", "/glossary#step.design");
await expect(page.getByRole("heading", { level: 2, name: "3 Design" })).toBeVisible();
});
test("Show explanations opens every panel, and the device remembers it (spec 9.5)", async ({ page }) => {
await page.goto("/app?step=1");
const help = page.getByRole("button", { name: "Help: Step 1: Describe" });
await page.getByRole("switch", { name: "Show explanations" }).check();
await expect(help).toHaveAttribute("aria-expanded", "true");
await page.reload();
await expect(page.getByRole("switch", { name: "Show explanations" })).toBeChecked();
await expect(help).toHaveAttribute("aria-expanded", "true");
await help.click();
await expect(help).toHaveAttribute("aria-expanded", "false");
await page.getByRole("switch", { name: "Show explanations" }).uncheck();
});
test("the glossary page lists the entries with MathML and no KaTeX error (spec 9.9)", async ({ page }) => {
await page.goto("/app");
await page.getByRole("contentinfo").getByRole("link", { name: "Glossary" }).click();
await expect(page).toHaveURL(/\/glossary$/);
await expect(page.getByRole("heading", { level: 1, name: "Glossary" })).toBeVisible();
await expect(page.getByText("Clear two-factor interactions", { exact: true })).toBeVisible();
expect(await page.locator("math").count()).toBeGreaterThan(20);
await expect(page.locator(".katex-error")).toHaveCount(0);
await expect(page.getByText(/^Wu, C\. F\. J\., and Hamada, M\. S\. \(2021\)/)).toBeVisible();
});
test("the app has one global announcer with two live regions (spec 9.10)", async ({ page }) => {
await page.goto("/app");
await expect(page.getByTestId("engine-status")).toBeVisible();
// document.querySelectorAll does not enter shadow roots. Next.js keeps its own route announcer
// (it reads the page title after a client-side navigation) in the shadow root of
// <next-route-announcer>, outside the markup of the app.
const own = await page.evaluate(() => document.querySelectorAll("[aria-live], [role=status], [role=alert], [role=log]").length);
expect(own).toBe(2);
});
Run: npx vitest run --project unit replication_materials/tests/unit/announcer.test.ts replication_materials/tests/unit/glossary.test.ts Expected: FAIL. @/lib/a11y/announcer, @/lib/help/glossary, @/lib/help/keys and scripts/build-glossary.mjs do not exist.
Create lib/a11y/announcer.ts:
// One global announcer (spec 9.10, ChatISA pattern). Components call announce(); the component
// Announcer in the root layout holds the only two live regions of the app.
export type Politeness = "polite" | "assertive";
export interface Announcement {
text: string;
politeness: Politeness;
/** Grows with each call, so the same text twice is announced twice. */
id: number;
}
let current: Announcement = { text: "", politeness: "polite", id: 0 };
const listeners = new Set<() => void>();
export function announce(text: string, politeness: Politeness = "polite"): void {
current = { text, politeness, id: current.id + 1 };
for (const listener of listeners) listener();
}
export function getAnnouncement(): Announcement {
return current;
}
export function subscribeAnnouncements(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
Create components/a11y/Announcer.tsx:
"use client";
import { useSyncExternalStore } from "react";
import { getAnnouncement, subscribeAnnouncements, type Announcement } from "@/lib/a11y/announcer";
const NONE: Announcement = { text: "", politeness: "polite", id: 0 };
// The one global announcer of the app (spec 9.10): a polite region (role status) and an
// assertive region. The id is the React key, so the same text twice is announced twice.
export function Announcer() {
const a = useSyncExternalStore(subscribeAnnouncements, getAnnouncement, () => NONE);
return (
<div className="sr-only">
<div role="status" aria-live="polite" aria-atomic="true">
{a.politeness === "polite" && a.text ? <span key={a.id}>{a.text}</span> : null}
</div>
<div aria-live="assertive" aria-atomic="true">
{a.politeness === "assertive" && a.text ? <span key={a.id}>{a.text}</span> : null}
</div>
</div>
);
}
In app/layout.tsx, add the import import { Announcer } from "@/components/a11y/Announcer"; and change the body to:
<body className="min-h-screen bg-white">
{children}
<Announcer />
</body>
In components/shell/EngineStatus.tsx, change the imports and the comment, and replace the live region with an announcement:
"use client";
import { useEffect, useRef } from "react";
import { announce } from "@/lib/a11y/announcer";
import { getEngine } from "@/lib/webr/engine";
import { engineStatusText } from "@/lib/webr/status-text";
import { useEngineStatus, useNow } from "@/lib/webr/use-engine";
// The R engine status in the footer (spec 7.9). The visible text has the percent and the bytes,
// or the elapsed time of a run; the global announcer reads only changes of state (spec 9.10).
// During a run, a progress bar with no value shows activity, because FrF2 does not report its
// progress (P15 of Milestone 2). Cancel appears at the soft limit. Retry appears after a
// failure or a stall.
export function EngineStatus() {
const status = useEngineStatus();
const now = useNow(status.state === "running");
const text = engineStatusText(status, now);
const showRetry = status.state === "failed" || (status.state === "loading" && status.stalled);
const first = useRef(true);
useEffect(() => {
// The text of the first render is the page's own state, not a change.
if (first.current) {
first.current = false;
return;
}
announce(text.announced);
}, [text.announced]);
and delete the line <p role="status" className="sr-only">{text.announced}</p>. The rest of the component stays.
In components/shell/ProjectSidebar.tsx, change <p role="status" className="mt-2 rounded border border-accent p-2 text-sm"> to <p className="mt-2 rounded border border-accent p-2 text-sm">. The workspace announces the error (next step).
Create scripts/build-glossary.mjs:
// Builds data/glossary.json from content/glossary.json (spec Section 9.9, decision P16).
// Math inside \( ... \) becomes MathML (KaTeX, output "mathml", trust false); the other text
// becomes escaped HTML. The app imports the built file, so KaTeX is not in the app bundle.
// A unit test fails if the built file differs from the rendered source.
// node scripts/build-glossary.mjs
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import katex from "katex";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const SOURCE = path.join(ROOT, "content", "glossary.json");
const OUT = path.join(ROOT, "data", "glossary.json");
const escapeHtml = (text) =>
text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
/** One glossary text as HTML: escaped text and MathML. */
export function renderText(text) {
return text
.split(/(\\\(.*?\\\))/s)
.map((part) => {
const math = part.match(/^\\\((.*)\\\)$/s);
return math ? katex.renderToString(math[1].trim(), { output: "mathml", throwOnError: false, trust: false }) : escapeHtml(part);
})
.join("");
}
export function renderGlossary(source) {
return {
note: "Built by scripts/build-glossary.mjs from content/glossary.json. Do not edit this file.",
katex: katex.version,
references: source.references,
entries: source.entries.map((entry) => ({
key: entry.key,
term: entry.term,
html: renderText(entry.text),
reference: entry.reference,
reviewed_by: entry.reviewed_by,
reviewed_on: entry.reviewed_on,
})),
};
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const source = JSON.parse(fs.readFileSync(SOURCE, "utf8"));
fs.writeFileSync(OUT, `${JSON.stringify(renderGlossary(source), null, 2)}\n`);
console.log(`Wrote ${path.relative(ROOT, OUT)} with ${source.entries.length} entries.`);
}
Run: node scripts/build-glossary.mjs Expected: Wrote data\glossary.json with 35 entries. (or with / on other systems).
Create lib/help/keys.ts:
// The keys of the "?" buttons of the interface (spec 9.5: each spec card field, each design
// property, each step and the two fixed scope messages). A unit test checks that each key has a
// glossary entry. The glossary also has entries without a button (the principles of spec 1.8).
export const HELP_KEYS = [
"step.describe", "step.confirm", "step.design",
"scope.out_of_scope", "scope.not_supported",
"factors", "factor_order", "levels", "unit", "quantitative", "nruns", "min_resolution", "replications",
"repeat_only", "center_points", "randomize", "seed", "evidence", "agreement",
"design_notation", "guarantee", "resolution", "wlp", "generators", "clear_2fis", "all_2fis_clear",
"aliased_main", "aliased_2fis", "run_order", "frf2_messages", "run_sheet", "r_code",
] as const;
export type HelpKey = (typeof HELP_KEYS)[number];
Create lib/help/glossary.ts:
import glossary from "@/data/glossary.json" with { type: "json" };
// The built glossary (decision P16): the reviewed text as HTML with MathML (spec 9.9).
// An entry without a reviewer is a draft (decision P3).
export interface GlossaryEntry {
key: string;
term: string;
html: string;
reference: string | null;
reviewed_by: string | null;
reviewed_on: string | null;
}
export interface GlossaryReference {
key: string;
citation: string;
url: string;
}
export const GLOSSARY: readonly GlossaryEntry[] = glossary.entries;
export const REFERENCES: readonly GlossaryReference[] = glossary.references;
export function glossaryEntry(key: string): GlossaryEntry | undefined {
return GLOSSARY.find((entry) => entry.key === key);
}
export function reviewLabel(entry: GlossaryEntry): string {
return entry.reviewed_by && entry.reviewed_on ? `Reviewed by ${entry.reviewed_by} on ${entry.reviewed_on}` : "Draft, not yet reviewed";
}
Create components/help/HelpContext.tsx:
"use client";
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
import { readShowExplanations, writeShowExplanations } from "@/lib/projects/pointer";
interface HelpState {
showAll: boolean;
setShowAll: (value: boolean) => void;
}
const HelpContext = createContext<HelpState>({ showAll: false, setShowAll: () => {} });
// "Show explanations" (spec 9.5): one switch opens every help panel. The device remembers it.
export function HelpProvider({ children }: { children: ReactNode }) {
const [showAll, setShow] = useState(false);
useEffect(() => {
setShow(readShowExplanations());
}, []);
const setShowAll = (value: boolean) => {
setShow(value);
writeShowExplanations(value);
};
return <HelpContext.Provider value={{ showAll, setShowAll }}>{children}</HelpContext.Provider>;
}
export function useHelp(): HelpState {
return useContext(HelpContext);
}
Create components/help/WithHelp.tsx:
"use client";
import { useEffect, useId, useState, type ReactNode } from "react";
import { glossaryEntry, reviewLabel } from "@/lib/help/glossary";
import type { HelpKey } from "@/lib/help/keys";
import { useHelp } from "./HelpContext";
interface Props {
helpKey: HelpKey;
/** Sentences computed from the R output of this design (spec 9.5, layer 2). */
computed?: readonly string[];
/** Draws the item with the "?" button in it. The panel follows the item. */
children: (button: ReactNode) => ReactNode;
}
// A "?" button and its panel (spec 9.5, decision P13). The panel opens in place, under the item,
// and pushes the content down; it covers nothing. "Show explanations" opens every panel; a click
// on one button opens or closes that panel only. "Watch this part" comes with the video.
export function WithHelp({ helpKey, computed = [], children }: Props) {
const { showAll } = useHelp();
const [own, setOwn] = useState<boolean | null>(null);
useEffect(() => {
setOwn(null);
}, [showAll]);
const id = useId();
const entry = glossaryEntry(helpKey);
if (!entry) throw new Error(`The glossary has no entry for the help key ${helpKey}.`);
const open = own ?? showAll;
const button = (
<button
type="button"
aria-expanded={open}
aria-controls={id}
aria-label={`Help: ${entry.term}`}
onClick={() => setOwn(!open)}
className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-tan-dark text-sm leading-none text-tan-dark hover:bg-tan-light print:hidden"
>
?
</button>
);
return (
<>
{children(button)}
<div id={id} hidden={!open} className="my-2 rounded border border-tan-medium bg-warm-white p-3 text-sm print:hidden">
<p className="text-xs font-semibold uppercase tracking-wide text-tan-dark">What it means</p>
<p className="mt-1" dangerouslySetInnerHTML={{ __html: entry.html }} />
<p className="mt-1 text-xs text-tan-dark">
{reviewLabel(entry)}
{entry.reference ? `. Source: ${entry.reference}.` : "."}
</p>
{computed.length > 0 ? (
<>
<p className="mt-3 text-xs font-semibold uppercase tracking-wide text-tan-dark">In your design</p>
{computed.map((sentence) => (
<p key={sentence} className="mt-1">{sentence}</p>
))}
</>
) : null}
<p className="mt-2">
<a href={`/glossary#${entry.key}`} className="underline">Glossary: {entry.term}</a>
</p>
</div>
</>
);
}
Replace components/shell/StepRail.tsx with:
"use client";
import { useHelp } from "@/components/help/HelpContext";
import { STEPS } from "@/lib/steps";
export function StepRail({ current, onSelect }: { current: number; onSelect: (n: number) => void }) {
const { showAll, setShowAll } = useHelp();
return (
<nav aria-label="Steps" className="mb-6 flex flex-wrap items-center gap-4 print:hidden">
<ol className="flex flex-wrap gap-2">
{STEPS.map((s) => (
<li key={s.n}>
{s.phase2 ? (
<span aria-disabled="true" className="inline-block rounded border border-tan-medium px-3 py-2 text-tan-dark">
{s.n} {s.title} <span className="text-xs">(Phase 2)</span>
</span>
) : (
<button
type="button"
onClick={() => onSelect(s.n)}
aria-current={s.n === current ? "step" : undefined}
className={`rounded border px-3 py-2 ${s.n === current ? "border-accent bg-accent text-white" : "border-tan-medium bg-white hover:bg-tan-light"}`}
>
{s.n} {s.title}
</button>
)}
</li>
))}
</ol>
<label className="flex items-center gap-2 text-sm md:ml-auto">
<input type="checkbox" role="switch" checked={showAll} onChange={(e) => setShowAll(e.target.checked)} className="h-4 w-4 accent-accent" />
Show explanations
</label>
</nav>
);
}
Replace components/shell/StepPanel.tsx with:
"use client";
import { useEffect, useRef } from "react";
import { WithHelp } from "@/components/help/WithHelp";
import type { HelpKey } from "@/lib/help/keys";
import { STEPS } from "@/lib/steps";
const STEP_HELP: Partial<Record<number, HelpKey>> = { 1: "step.describe", 2: "step.confirm", 3: "step.design" };
// The content of a step. Focus moves to the step heading after each step change (spec 9.10),
// not on the first load. A "?" button stands next to the heading (spec 9.5), not in it, so the
// heading keeps its name ("3 Design").
export function StepPanel({ step, hasProject }: { step: number; hasProject: boolean }) {
const headingRef = useRef<HTMLHeadingElement>(null);
const shown = useRef(step);
useEffect(() => {
if (shown.current === step) return;
shown.current = step;
headingRef.current?.focus();
}, [step]);
const current = STEPS.find((s) => s.n === step) ?? STEPS[0];
const heading = (
<h2 id="step-heading" ref={headingRef} tabIndex={-1} className="font-display text-2xl">
{current.n} {current.title}
</h2>
);
const helpKey = STEP_HELP[current.n];
return (
<section aria-labelledby="step-heading">
{helpKey ? (
<WithHelp helpKey={helpKey}>{(button) => <div className="flex items-center gap-2">{heading}{button}</div>}</WithHelp>
) : (
heading
)}
{hasProject ? null : <p className="mt-3">Select a project, or select "New design".</p>}
<p className="mt-3 text-tan-dark">This step is not available in this version.</p>
</section>
);
}
Replace components/shell/Workspace.tsx with (the same workspace, with the help provider and the announced storage failure):
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { HelpProvider } from "@/components/help/HelpContext";
import { announce } from "@/lib/a11y/announcer";
import { projectsSize } from "@/lib/projects/format";
import { writeLastPage } from "@/lib/projects/pointer";
import { newProject, type Project } from "@/lib/projects/schema";
import { openProjectStore, type ProjectStore } from "@/lib/projects/store";
import { stepFromQuery } from "@/lib/steps";
import { ProjectSidebar } from "./ProjectSidebar";
import { SiteFooter } from "./SiteFooter";
import { SiteHeader } from "./SiteHeader";
import { SkipLink } from "./SkipLink";
import { StepPanel } from "./StepPanel";
import { StepRail } from "./StepRail";
// A store call that fails after the store opened (for example a full disk) shows this text
// in the sidebar and tells the global announcer (spec 8.2: no silent failure; spec 9.10).
const STORE_ERROR = "The project could not be stored on this device.";
// The workspace (spec 9.1): header, sidebar, step rail, the current step and the footer.
// The address holds the state: /app?project=<id>&step=<n>.
export function Workspace() {
const params = useSearchParams();
const router = useRouter();
const step = stepFromQuery(params.get("step"));
const projectId = params.get("project");
const [store, setStore] = useState<ProjectStore | null>(null);
const [projects, setProjects] = useState<Project[]>([]);
const [storeError, setStoreError] = useState<string | null>(null);
const failStore = useCallback(() => {
setStoreError(STORE_ERROR);
announce(STORE_ERROR, "assertive");
}, []);
useEffect(() => {
let live = true;
openProjectStore()
.then(async (opened) => {
const list = await opened.list();
if (live) {
setStore(opened);
setProjects(list);
}
})
.catch(() => {
if (live) failStore();
});
return () => {
live = false;
};
}, [failStore]);
// The pointer for the next visit to / (spec 9.2).
useEffect(() => {
writeLastPage(`/app${window.location.search}`);
}, [params]);
const go = useCallback(
(project: string | null, nextStep: number) => {
const query = new URLSearchParams();
if (project) query.set("project", project);
query.set("step", String(nextStep));
router.push(`/app?${query.toString()}`);
},
[router],
);
async function newDesign() {
if (!store) return;
const project = newProject(new Date(), crypto.randomUUID(), Intl.DateTimeFormat().resolvedOptions().timeZone);
try {
await store.put(project);
setProjects(await store.list());
setStoreError(null);
} catch {
failStore();
return;
}
go(project.id, 1);
}
async function clearAll() {
if (!store) return;
try {
await store.clear();
setProjects([]);
setStoreError(null);
} catch {
failStore();
return;
}
go(null, 1);
}
const current = projects.find((p) => p.id === projectId) ?? null;
return (
<HelpProvider>
<div className="flex min-h-screen flex-col">
<SkipLink />
<SiteHeader project={current?.name ?? null} />
<div className="mx-auto flex w-full max-w-7xl flex-1 flex-col gap-8 px-4 py-6 md:flex-row">
<ProjectSidebar
projects={projects}
currentId={current?.id ?? null}
persistent={store ? store.persistent : null}
error={storeError}
sizeBytes={projectsSize(projects)}
onNew={newDesign}
onOpen={(id) => go(id, 1)}
onClear={clearAll}
/>
<main id="main" tabIndex={-1} className="min-w-0 flex-1">
<h1 className="sr-only">Workspace</h1>
<StepRail current={step} onSelect={(n) => go(current?.id ?? null, n)} />
<StepPanel step={step} hasProject={current !== null} />
</main>
</div>
<SiteFooter />
</div>
</HelpProvider>
);
}
In components/shell/SiteFooter.tsx, change the comment to // Footer (spec 9.1). Architecture and Paper come when those pages exist (Milestone 4). and add after the Home link:
{" · "}
<a href="/glossary" className="underline">Glossary</a>
Create app/glossary/page.tsx:
import type { Metadata } from "next";
import { SiteFooter } from "@/components/shell/SiteFooter";
import { SiteHeader } from "@/components/shell/SiteHeader";
import { SkipLink } from "@/components/shell/SkipLink";
import { GLOSSARY, REFERENCES, reviewLabel } from "@/lib/help/glossary";
export const metadata: Metadata = { title: "Glossary" };
// The glossary page (spec 9.1, 9.5 and 14.2): the same entries as the help panels, with the
// review state of each entry (decision P3) and the full references (decision P4).
export default function GlossaryPage() {
return (
<div className="flex min-h-screen flex-col">
<SkipLink />
<SiteHeader project={null} />
<main id="main" tabIndex={-1} className="mx-auto w-full max-w-3xl flex-1 px-4 py-10">
<h1 className="font-display text-4xl">Glossary</h1>
<p className="mt-3">The help text of the "?" buttons. Each entry shows whether Alan R. Vazquez has reviewed it.</p>
<dl className="mt-8 space-y-6">
{GLOSSARY.map((entry) => (
<div key={entry.key} id={entry.key} className="scroll-mt-6">
<dt className="font-display text-xl">{entry.term}</dt>
<dd className="mt-1">
<p dangerouslySetInnerHTML={{ __html: entry.html }} />
<p className="mt-1 text-sm text-tan-dark">
{reviewLabel(entry)}
{entry.reference ? `. Source: ${entry.reference}.` : "."}
</p>
</dd>
</div>
))}
</dl>
<section aria-labelledby="references" className="mt-12">
<h2 id="references" className="font-display text-2xl">References</h2>
<ul className="mt-3 space-y-2">
{REFERENCES.map((r) => (
<li key={r.key}>
{r.citation} <a href={r.url} className="underline">{r.url}</a>
</li>
))}
</ul>
</section>
</main>
<SiteFooter />
</div>
);
}
Run: npx vitest run --project unit replication_materials/tests/unit/announcer.test.ts replication_materials/tests/unit/glossary.test.ts Expected: PASS (1 + 7 tests; the release gate is skipped).
Run: npx playwright test replication_materials/tests/e2e/help.spec.ts replication_materials/tests/e2e/shell.spec.ts replication_materials/tests/e2e/engine.spec.ts Expected: PASS. engine.spec.ts reads the announced engine text through the global announcer's role="status" region.
Run: npm run check Expected: "All checks passed."
git add package.json package-lock.json lib/a11y components/a11y content/glossary.json scripts/build-glossary.mjs data/glossary.json lib/help components/help app/glossary app/layout.tsx components/shell replication_materials/tests/unit/announcer.test.ts replication_materials/tests/unit/glossary.test.ts replication_materials/tests/e2e/help.spec.ts
git commit -m "feat: add one global announcer, help panels with Show explanations, and the drafted glossary with MathML (P3, P13, P16)"
13Task 6: Step 1, Describe
Files:
- Create:
content/examples.json,components/steps/DescribeStep.tsx - Create:
replication_materials/tests/e2e/helpers/translate-fixtures.ts - Modify:
lib/projects/schema.ts(request.notice),app/app/page.tsx,components/shell/Workspace.tsx,components/shell/StepPanel.tsx - Test:
replication_materials/tests/unit/examples.test.ts,replication_materials/tests/unit/projects.test.ts,replication_materials/tests/e2e/describe.spec.ts
Interfaces:
- Consumes:
requestTranslation,TranslateReply(Task 2);cardFromTranslation,emptyCard(Task 4);nameFromRequest(Task 4);DEFAULT_PROJECT_NAME,newProject(Task 1);announce(Task 5);WithHelp(Task 5);currentPrivacyNotice(lib/ops/privacy.ts);useNow(lib/webr/use-engine.ts),formatElapsed(lib/webr/status-text.ts). - Produces:
- The
Projectfieldrequest.notice: { text: string; scope: "out_of_scope" | "not_supported" | null } | null(default null). Workspace({ privacyNotice });StepPanelprops{ step; project: Project | null; ready: boolean; privacyNotice: string; onSave: (project: Project) => Promise<boolean>; go: (projectId: string | null, step: number) => void }.onSavesetsupdated_at, stores the project, refreshes the sidebar and returns false after a storage failure. Tasks 7 to 9 use the same props.routeTranslate(page, extra?),loadFixture(name),answering(turns, status, body)in the e2e helper.
- The
Each notice of step 1 is shown in the step and also read by the global announcer, so the browser tests look for it inside main (the announcer is outside it).
A step creates its project only when it has something to store: the request with its answer, or a card. A cancelled request stores nothing.
Create replication_materials/tests/unit/examples.test.ts:
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import examples from "@/content/examples.json" with { type: "json" };
const DIR = "replication_materials/tests/fixtures/translate";
const { names } = JSON.parse(readFileSync(`${DIR}/index.json`, "utf8")) as { names: string[] };
const firstTurns = names.map((n) => JSON.parse(readFileSync(`${DIR}/${n}.json`, "utf8")).request.turns[0].text as string);
describe("example requests of step 1 (spec 9.3)", () => {
it("are recorded fixture requests, so the browser tests cover each example (spec 11)", () => {
expect(examples.examples.length).toBeGreaterThanOrEqual(3);
for (const example of examples.examples) expect(firstTurns).toContain(example.text);
});
});
Add to the describe("project schema (spec 8.1)", ...) block of replication_materials/tests/unit/projects.test.ts:
it("reads a request without a notice, and keeps a notice of step 1 (decision P7)", () => {
const request = { text: "Fit a response surface model for 3 factors.", turns: [{ role: "user" as const, text: "Fit a response surface model for 3 factors." }] };
expect(ProjectSchema.parse({ ...project(ID1, 1), request }).request?.notice).toBeNull();
const notice = { text: "This version does not support blocking.", scope: "not_supported" as const };
expect(ProjectSchema.parse({ ...project(ID1, 1), request: { ...request, notice } }).request?.notice).toEqual(notice);
});
Create replication_materials/tests/e2e/helpers/translate-fixtures.ts:
import fs from "node:fs";
import path from "node:path";
import type { Page } from "@playwright/test";
// The recorded answers of POST /api/translate (3A decision P14). The browser tests answer the
// route from them, so no test calls an LLM (spec 11).
export interface Turn {
role: "user" | "tool";
text: string;
}
export interface Fixture {
name: string;
request: { turns: Turn[] };
status: number;
body: Record<string, unknown>;
}
const DIR = path.join(process.cwd(), "replication_materials", "tests", "fixtures", "translate");
export function loadFixture(name: string): Fixture {
return JSON.parse(fs.readFileSync(path.join(DIR, `${name}.json`), "utf8")) as Fixture;
}
/** An answer for the given turns that a test derives from a fixture or writes for a refusal. */
export function answering(turns: Turn[], status: number, body: Record<string, unknown>): Fixture {
return { name: "derived", request: { turns }, status, body };
}
/**
* Answers POST /api/translate with the recorded answer whose turns equal the request. The
* answers in `extra` come first. Returns the list of the turns that the page sent.
*/
export async function routeTranslate(page: Page, extra: Fixture[] = []): Promise<Turn[][]> {
const index = JSON.parse(fs.readFileSync(path.join(DIR, "index.json"), "utf8")) as { names: string[] };
const fixtures = [...extra, ...index.names.map(loadFixture)];
const seen: Turn[][] = [];
await page.route("**/api/translate", async (route) => {
const turns = (route.request().postDataJSON() as { turns: Turn[] }).turns;
seen.push(turns);
const match = fixtures.find((f) => JSON.stringify(f.request.turns) === JSON.stringify(turns));
await route.fulfill(
match
? { status: match.status, contentType: "application/json", body: JSON.stringify(match.body) }
: { status: 500, contentType: "application/json", body: JSON.stringify({ kind: "error", message: "The test has no recorded answer for these turns." }) },
);
});
return seen;
}
Create replication_materials/tests/e2e/describe.spec.ts:
import { expect, test } from "@playwright/test";
import { ENSEMBLE_NOTICE } from "@/lib/ops/privacy";
import { MESSAGES } from "@/lib/translate/messages";
import { answering, loadFixture, routeTranslate, type Turn } from "./helpers/translate-fixtures";
test("an example request becomes the card of step 2 and names the project (spec 4.3)", async ({ page }) => {
const coating = loadFixture("coating-design");
const seen = await routeTranslate(page);
await page.goto("/app");
await expect(page.getByText(ENSEMBLE_NOTICE)).toBeVisible();
await page.getByRole("button", { name: "Coating study, 7 factors in 16 runs" }).click();
await expect(page.getByLabel("Describe the experiment")).toHaveValue(coating.request.turns[0].text);
await page.getByRole("button", { name: "Interpret request" }).click();
await expect(page).toHaveURL(/\/app\?project=[0-9a-f-]{36}&step=2$/);
expect(seen).toEqual([coating.request.turns]);
await expect(page.getByRole("button", { name: /^We want to screen 7 factors …/ })).toBeVisible();
});
test("a clarification question gets an answer field, and the answer continues the thread (spec 5.1)", async ({ page }) => {
const answer = loadFixture("clarification-answer");
await routeTranslate(page);
await page.goto("/app");
await page.getByLabel("Describe the experiment").fill(answer.request.turns[0].text);
await page.getByRole("button", { name: "Interpret request" }).click();
await expect(page.getByRole("main").getByText(answer.request.turns[1].text)).toBeVisible();
await expect(page).toHaveURL(/step=1$/);
await page.getByLabel("Answer").fill(answer.request.turns[2].text);
await page.getByRole("button", { name: "Send answer" }).click();
await expect(page).toHaveURL(/step=2$/);
});
test("an out-of-scope request shows the fixed message with its ? button, also after a reload (spec 5.1)", async ({ page }) => {
const outside = loadFixture("out-of-scope");
await routeTranslate(page);
await page.goto("/app");
await page.getByLabel("Describe the experiment").fill(outside.request.turns[0].text);
await page.getByRole("button", { name: "Interpret request" }).click();
await expect(page.getByRole("main").getByText(String(outside.body.message))).toBeVisible();
await page.getByRole("button", { name: "Help: Outside the scope" }).click();
await expect(page.getByRole("main").getByText("What it means").filter({ visible: true })).toHaveCount(1);
await page.reload();
await expect(page.getByRole("main").getByText(String(outside.body.message))).toBeVisible();
await expect(page.getByLabel("Describe the experiment")).toHaveValue(outside.request.turns[0].text);
});
test("a not-supported request shows the fixed message with its ? button (spec 5.1)", async ({ page }) => {
const blocks = loadFixture("not-supported");
await routeTranslate(page);
await page.goto("/app");
await page.getByLabel("Describe the experiment").fill(blocks.request.turns[0].text);
await page.getByRole("button", { name: "Interpret request" }).click();
await expect(page.getByRole("main").getByText("This version does not support blocking.")).toBeVisible();
await expect(page.getByRole("button", { name: "Help: Not supported in this version" })).toBeVisible();
});
test("a refused or broken answer shows a fixed message, and the form stays available (Review Focus 1)", async ({ page }) => {
const turns: Turn[] = [{ role: "user", text: "Screen 5 factors in 8 runs." }];
const rateLimited = "Too many requests from this address. Wait 30 seconds, or fill in the form yourself.";
const cases = [
{ status: 503, body: { kind: "paused", message: MESSAGES.paused }, shown: MESSAGES.paused },
{ status: 429, body: { kind: "rate_limited", message: rateLimited, retry_after_seconds: 30 }, shown: rateLimited },
{ status: 200, body: { kind: "result", outcome: "design_request" }, shown: MESSAGES.service_error },
];
for (const c of cases) {
await page.unrouteAll();
await routeTranslate(page, [answering(turns, c.status, c.body)]);
await page.goto("/app");
await page.getByLabel("Describe the experiment").fill(turns[0].text);
await page.getByRole("button", { name: "Interpret request" }).click();
await expect(page.getByRole("main").getByText(c.shown)).toBeVisible();
}
await page.getByRole("button", { name: "Fill in the form yourself" }).click();
await expect(page).toHaveURL(/step=2$/);
});
test("Cancel stops a slow interpretation and keeps the request text (Review Focus 1)", async ({ page }) => {
await page.route("**/api/translate", () => {
// No answer: the request waits until the user cancels it.
});
await page.goto("/app");
await page.getByLabel("Describe the experiment").fill("Screen 5 factors in 8 runs.");
await page.getByRole("button", { name: "Interpret request" }).click();
await expect(page.getByText(/^Interpreting the request \(0:0\d\)$/)).toBeVisible();
await page.getByRole("button", { name: "Cancel" }).click();
await expect(page.getByRole("main").getByText(MESSAGES.cancelled)).toBeVisible();
await expect(page.getByLabel("Describe the experiment")).toHaveValue("Screen 5 factors in 8 runs.");
await expect(page.getByText("No projects yet.")).toBeVisible();
});
Run: npx vitest run --project unit replication_materials/tests/unit/examples.test.ts replication_materials/tests/unit/projects.test.ts Expected: FAIL. content/examples.json does not exist, and the notice is stripped from the request (undefined, not null).
Run: npx playwright test replication_materials/tests/e2e/describe.spec.ts Expected: FAIL in each test: step 1 has no request field ("Describe the experiment").
Create content/examples.json:
{
"note": "Example requests of step 1 (spec 9.3). Each text is the request of a recorded fixture in replication_materials/tests/fixtures/translate/, so the browser tests cover each example.",
"examples": [
{
"label": "Coating study, 7 factors in 16 runs",
"text": "We want to screen 7 factors in 16 runs: temperature 150 to 200 C, pressure 1 to 3 bar, speed slow or fast, time 10 to 20 min, feed 0.1 to 0.2, depth 1 to 2 mm, coolant off or on."
},
{
"label": "Nine factors, resolution IV",
"text": "Nine factors, main effects clear of two-factor interactions, with the fewest runs."
},
{
"label": "Spanish: 7 factors in 16 runs, two replications",
"text": "Necesitamos un diseño de 16 corridas para siete factores, con dos réplicas."
}
]
}
In lib/projects/schema.ts, change the request field to:
request: z.object({
text: z.string(),
turns: z.array(z.object({ role: z.enum(["user", "tool"]), text: z.string() })),
/** The last fixed message of step 1 (decision P7). scope names the "?" help of a scope message. */
notice: z.object({ text: z.string(), scope: z.enum(["out_of_scope", "not_supported"]).nullable() }).nullable().default(null),
}).nullable(),
Create components/steps/DescribeStep.tsx:
"use client";
import { useRef, useState } from "react";
import { WithHelp } from "@/components/help/WithHelp";
import examples from "@/content/examples.json" with { type: "json" };
import { announce } from "@/lib/a11y/announcer";
import { cardFromTranslation, emptyCard } from "@/lib/card/card";
import { nameFromRequest } from "@/lib/projects/name";
import { DEFAULT_PROJECT_NAME, newProject, type Project } from "@/lib/projects/schema";
import { requestTranslation, type TranslateReply } from "@/lib/translate/client";
import { MESSAGES } from "@/lib/translate/messages";
import type { Turn } from "@/lib/translate/schema";
import { formatElapsed } from "@/lib/webr/status-text";
import { useNow } from "@/lib/webr/use-engine";
/** At most 4,000 characters in one turn (spec 5.7, item 1). */
const MAX_CHARS = 4000;
type Notice = NonNullable<NonNullable<Project["request"]>["notice"]>;
interface Props {
project: Project | null;
ready: boolean;
privacyNotice: string;
onSave: (project: Project) => Promise<boolean>;
go: (projectId: string | null, step: number) => void;
}
function noticeOf(reply: TranslateReply): Notice {
if (reply.kind === "result") {
const scope = reply.outcome === "out_of_scope" ? "out_of_scope" : reply.outcome === "not_supported_in_version" ? "not_supported" : null;
return { text: reply.message ?? MESSAGES.service_error, scope };
}
return { text: reply.message, scope: null };
}
// Step 1, Describe (spec 4.3 and 9.3): the request field, example requests, "Interpret request",
// "Fill in the form yourself" and the privacy notice of the current mode. The request text and
// the clarification answers are the only data that leave the device (spec 4.2, rule 2).
export function DescribeStep({ project, ready, privacyNotice, onSave, go }: Props) {
const [text, setText] = useState(project?.request?.text ?? "");
const [answer, setAnswer] = useState("");
const [startedAt, setStartedAt] = useState<number | null>(null);
const [cancelled, setCancelled] = useState<string | null>(null);
const controller = useRef<AbortController | null>(null);
const now = useNow(startedAt !== null);
const busy = startedAt !== null;
const turns = project?.request?.turns ?? [];
const waiting = turns.length > 0 && turns[turns.length - 1].role === "tool";
const notice: Notice | null = cancelled ? { text: cancelled, scope: null } : (project?.request?.notice ?? null);
const base = () => project ?? newProject(new Date(), crypto.randomUUID(), Intl.DateTimeFormat().resolvedOptions().timeZone);
async function interpret(thread: Turn[]) {
const own = new AbortController();
controller.current = own;
setCancelled(null);
setStartedAt(Date.now());
announce("Interpreting the request.");
const reply = await requestTranslation(thread, { signal: own.signal });
controller.current = null;
setStartedAt(null);
if (reply.kind === "cancelled") {
setCancelled(reply.message);
announce(reply.message);
return;
}
const p = base();
const first = thread[0].text;
const name = p.name === DEFAULT_PROJECT_NAME ? nameFromRequest(first) : p.name;
if (reply.kind === "result" && reply.outcome === "design_request" && reply.spec) {
const card = cardFromTranslation(reply, new Date(), () => crypto.randomUUID());
if (await onSave({ ...p, name, request: { text: first, turns: thread, notice: null }, card, last_step: 2 })) {
announce("The specification is ready to confirm in step 2.");
go(p.id, 2);
}
return;
}
if (reply.kind === "result" && reply.outcome === "needs_clarification") {
const question = reply.clarification_question ?? MESSAGES.outcome_disagreement;
if (await onSave({ ...p, name, request: { text: first, turns: [...thread, { role: "tool", text: question }], notice: null } })) {
announce(question);
go(p.id, 1);
}
return;
}
const message = noticeOf(reply);
if (await onSave({ ...p, name, request: { text: first, turns: thread, notice: message } })) {
announce(message.text, "assertive");
go(p.id, 1);
}
}
async function fillByHand() {
const p = base();
const card = p.card ?? emptyCard(new Date(), () => crypto.randomUUID());
if (await onSave({ ...p, card, last_step: 2 })) go(p.id, 2);
}
return (
<div className="mt-4 space-y-6">
<div>
<label htmlFor="request" className="block font-semibold">Describe the experiment</label>
<p id="request-hint" className="text-sm text-tan-dark">
A request names the factors, their levels, and the number of runs or the minimum resolution. Any language is accepted.
</p>
<textarea
id="request"
aria-describedby="request-hint request-count"
value={text}
maxLength={MAX_CHARS}
rows={6}
onChange={(e) => setText(e.target.value)}
className="mt-2 w-full rounded border border-tan-medium p-3"
/>
<p id="request-count" className="text-sm text-tan-dark">
{Array.from(text).length.toLocaleString("en-US")} of {MAX_CHARS.toLocaleString("en-US")} characters
</p>
</div>
<div>
<p className="font-semibold">Example requests</p>
<ul className="mt-2 flex flex-wrap gap-2">
{examples.examples.map((example) => (
<li key={example.label}>
<button type="button" onClick={() => setText(example.text)} className="rounded border border-tan-medium px-3 py-1 text-sm hover:bg-tan-light">
{example.label}
</button>
</li>
))}
</ul>
</div>
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
disabled={!ready || busy || text.trim() === ""}
onClick={() => interpret([{ role: "user", text: text.trim() }])}
className="rounded bg-accent px-4 py-2 font-semibold text-white hover:bg-accent-dark disabled:opacity-60"
>
Interpret request
</button>
<button
type="button"
disabled={!ready || busy}
onClick={fillByHand}
className="rounded border border-accent px-4 py-2 font-semibold text-accent hover:bg-tan-light disabled:opacity-60"
>
Fill in the form yourself
</button>
{busy ? (
<>
<p>Interpreting the request ({formatElapsed(now - (startedAt ?? now))})</p>
<button type="button" onClick={() => controller.current?.abort()} className="min-h-6 underline">
Cancel
</button>
</>
) : null}
</div>
<p className="text-sm">{privacyNotice}</p>
{turns.length > 1 ? (
<section aria-labelledby="thread-heading">
<h3 id="thread-heading" className="font-display text-xl">Clarification</h3>
<ol className="mt-2 space-y-2">
{turns.slice(1).map((turn, i) => (
<li key={i} className={turn.role === "tool" ? "rounded bg-tan-light p-3" : "p-3"}>
<span className="text-sm font-semibold">{turn.role === "tool" ? "Question from the tool" : "Your answer"}</span>
<p>{turn.text}</p>
</li>
))}
</ol>
{waiting ? (
<div className="mt-3">
<label htmlFor="answer" className="block font-semibold">Answer</label>
<textarea id="answer" value={answer} maxLength={MAX_CHARS} rows={3} onChange={(e) => setAnswer(e.target.value)} className="mt-2 w-full rounded border border-tan-medium p-3" />
<button
type="button"
disabled={!ready || busy || answer.trim() === ""}
onClick={() => {
const reply = answer.trim();
setAnswer("");
void interpret([...turns, { role: "user", text: reply }]);
}}
className="mt-2 rounded bg-accent px-4 py-2 font-semibold text-white hover:bg-accent-dark disabled:opacity-60"
>
Send answer
</button>
</div>
) : null}
</section>
) : null}
{notice ? (
<div className="rounded border border-accent p-3">
{notice.scope ? (
<WithHelp helpKey={notice.scope === "out_of_scope" ? "scope.out_of_scope" : "scope.not_supported"}>
{(button) => (
<div className="flex items-start gap-2">
<p>{notice.text}</p>
{button}
</div>
)}
</WithHelp>
) : (
<p>{notice.text}</p>
)}
</div>
) : null}
</div>
);
}
Replace app/app/page.tsx with:
import type { Metadata } from "next";
import { Suspense } from "react";
import { Workspace } from "@/components/shell/Workspace";
import { currentPrivacyNotice } from "@/lib/ops/privacy";
export const metadata: Metadata = { title: "Workspace" };
export default function WorkspacePage() {
// useSearchParams() needs a Suspense boundary in a static page. The privacy notice is read on
// the server: process.env.TRANSLATE_* is empty in a client component (Global Constraints).
return (
<Suspense fallback={null}>
<Workspace privacyNotice={currentPrivacyNotice()} />
</Suspense>
);
}
Replace components/shell/StepPanel.tsx with:
"use client";
import { useEffect, useRef } from "react";
import { WithHelp } from "@/components/help/WithHelp";
import { DescribeStep } from "@/components/steps/DescribeStep";
import type { HelpKey } from "@/lib/help/keys";
import type { Project } from "@/lib/projects/schema";
import { STEPS } from "@/lib/steps";
const STEP_HELP: Partial<Record<number, HelpKey>> = { 1: "step.describe", 2: "step.confirm", 3: "step.design" };
export interface StepProps {
project: Project | null;
/** The project store is open; until then a step does not store anything. */
ready: boolean;
privacyNotice: string;
onSave: (project: Project) => Promise<boolean>;
go: (projectId: string | null, step: number) => void;
}
// The content of a step. Focus moves to the step heading after each step change (spec 9.10),
// not on the first load. A "?" button stands next to the heading (spec 9.5), not in it, so the
// heading keeps its name ("3 Design"). Each step is keyed by the project, so a change of
// project starts the step again from the stored project.
export function StepPanel({ step, ...props }: StepProps & { step: number }) {
const headingRef = useRef<HTMLHeadingElement>(null);
const shown = useRef(step);
useEffect(() => {
if (shown.current === step) return;
shown.current = step;
headingRef.current?.focus();
}, [step]);
const current = STEPS.find((s) => s.n === step) ?? STEPS[0];
const heading = (
<h2 id="step-heading" ref={headingRef} tabIndex={-1} className="font-display text-2xl">
{current.n} {current.title}
</h2>
);
const helpKey = STEP_HELP[current.n];
const key = props.project?.id ?? "none";
return (
<section aria-labelledby="step-heading">
{helpKey ? (
<WithHelp helpKey={helpKey}>{(button) => <div className="flex items-center gap-2">{heading}{button}</div>}</WithHelp>
) : (
heading
)}
{current.n === 1 ? <DescribeStep key={key} {...props} /> : null}
{current.n !== 1 && !props.project ? <p className="mt-3">Select a project, or select "New design".</p> : null}
{current.n !== 1 ? <p className="mt-3 text-tan-dark">This step is not available in this version.</p> : null}
</section>
);
}
Replace components/shell/Workspace.tsx with:
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { HelpProvider } from "@/components/help/HelpContext";
import { announce } from "@/lib/a11y/announcer";
import { projectsSize } from "@/lib/projects/format";
import { writeLastPage } from "@/lib/projects/pointer";
import { newProject, type Project } from "@/lib/projects/schema";
import { openProjectStore, type ProjectStore } from "@/lib/projects/store";
import { stepFromQuery } from "@/lib/steps";
import { ProjectSidebar } from "./ProjectSidebar";
import { SiteFooter } from "./SiteFooter";
import { SiteHeader } from "./SiteHeader";
import { SkipLink } from "./SkipLink";
import { StepPanel } from "./StepPanel";
import { StepRail } from "./StepRail";
// A store call that fails after the store opened (for example a full disk) shows this text
// in the sidebar and tells the global announcer (spec 8.2: no silent failure; spec 9.10).
const STORE_ERROR = "The project could not be stored on this device.";
// The workspace (spec 9.1): header, sidebar, step rail, the current step and the footer.
// The address holds the state: /app?project=<id>&step=<n>. The steps store the project
// through onSave.
export function Workspace({ privacyNotice }: { privacyNotice: string }) {
const params = useSearchParams();
const router = useRouter();
const step = stepFromQuery(params.get("step"));
const projectId = params.get("project");
const [store, setStore] = useState<ProjectStore | null>(null);
const [projects, setProjects] = useState<Project[]>([]);
const [storeError, setStoreError] = useState<string | null>(null);
const failStore = useCallback(() => {
setStoreError(STORE_ERROR);
announce(STORE_ERROR, "assertive");
}, []);
useEffect(() => {
let live = true;
openProjectStore()
.then(async (opened) => {
const list = await opened.list();
if (live) {
setStore(opened);
setProjects(list);
}
})
.catch(() => {
if (live) failStore();
});
return () => {
live = false;
};
}, [failStore]);
// The pointer for the next visit to / (spec 9.2).
useEffect(() => {
writeLastPage(`/app${window.location.search}`);
}, [params]);
const go = useCallback(
(project: string | null, nextStep: number) => {
const query = new URLSearchParams();
if (project) query.set("project", project);
query.set("step", String(nextStep));
router.push(`/app?${query.toString()}`);
},
[router],
);
/** Stores a project with the time of the change. False after a storage failure. */
const saveProject = useCallback(
async (project: Project): Promise<boolean> => {
if (!store) return false;
try {
await store.put({ ...project, updated_at: new Date().toISOString() });
setProjects(await store.list());
setStoreError(null);
return true;
} catch {
failStore();
return false;
}
},
[store, failStore],
);
async function newDesign() {
const project = newProject(new Date(), crypto.randomUUID(), Intl.DateTimeFormat().resolvedOptions().timeZone);
if (await saveProject(project)) go(project.id, 1);
}
async function clearAll() {
if (!store) return;
try {
await store.clear();
setProjects([]);
setStoreError(null);
} catch {
failStore();
return;
}
go(null, 1);
}
const current = projects.find((p) => p.id === projectId) ?? null;
return (
<HelpProvider>
<div className="flex min-h-screen flex-col">
<SkipLink />
<SiteHeader project={current?.name ?? null} />
<div className="mx-auto flex w-full max-w-7xl flex-1 flex-col gap-8 px-4 py-6 md:flex-row">
<ProjectSidebar
projects={projects}
currentId={current?.id ?? null}
persistent={store ? store.persistent : null}
error={storeError}
sizeBytes={projectsSize(projects)}
onNew={newDesign}
onOpen={(id) => go(id, projects.find((p) => p.id === id)?.last_step ?? 1)}
onClear={clearAll}
/>
<main id="main" tabIndex={-1} className="min-w-0 flex-1">
<h1 className="sr-only">Workspace</h1>
<StepRail current={step} onSelect={(n) => go(current?.id ?? null, n)} />
<StepPanel step={step} project={current} ready={store !== null} privacyNotice={privacyNotice} onSave={saveProject} go={go} />
</main>
</div>
<SiteFooter />
</div>
</HelpProvider>
);
}
Opening a project in the sidebar now goes to its last_step (spec 9.2: a project "can wait weeks for its data and reopen exactly").
Run: npx vitest run --project unit replication_materials/tests/unit/examples.test.ts replication_materials/tests/unit/projects.test.ts Expected: PASS.
Run: npx playwright test replication_materials/tests/e2e/describe.spec.ts replication_materials/tests/e2e/shell.spec.ts replication_materials/tests/e2e/help.spec.ts Expected: PASS.
Run: npm run check Expected: "All checks passed."
git add content/examples.json components/steps/DescribeStep.tsx lib/projects/schema.ts app/app/page.tsx components/shell/Workspace.tsx components/shell/StepPanel.tsx replication_materials/tests/e2e/helpers/translate-fixtures.ts replication_materials/tests/e2e/describe.spec.ts replication_materials/tests/unit/examples.test.ts replication_materials/tests/unit/projects.test.ts
git commit -m "feat: add step 1, Describe, with example requests, clarification turns, fixed notices and Cancel"
14Task 7: Step 2, Confirm
Files:
- Create:
components/spec-card/inputs.tsx,components/spec-card/FieldNote.tsx,components/spec-card/FactorTable.tsx,components/spec-card/VoterSummary.tsx,components/steps/ConfirmStep.tsx - Modify:
components/shell/StepPanel.tsx - Test:
replication_materials/tests/e2e/confirm.spec.ts
Interfaces:
- Consumes: the card model and labels (Task 4);
StepProps(Task 6, type only);WithHelp(Task 5);announce(Task 5);generateCode(lib/codegen/frf2.ts);toRoman(Task 3);nameFromDesign(Task 4);DEFAULT_PROJECT_NAME(Task 1);RUN_SIZES(lib/spec/validate.ts); the e2e helper (Task 6). - Produces:
ConfirmStep(props: StepProps). "Generate design" storesspec,r_code(the design code),card,last_step: 3, and clearsreport,run_sheet_sha256,versions,frf2_messagesandpending_rebuild; Task 8 starts the run from this state.
The hidden help panels hold glossary text that repeats some labels ("default (today's date)", "Check this value"), and each ? button is named "Help:
Text edits are stored 400 ms after the last key press; the other actions are stored at once. A project whose report waits for a rebuild (P1) cannot be written, so its card edits stay in the page, and a note says so; "Generate design" stores them with the new design.
Create replication_materials/tests/e2e/confirm.spec.ts:
import { expect, test, type Page } from "@playwright/test";
import { answering, loadFixture, routeTranslate } from "./helpers/translate-fixtures";
async function interpret(page: Page, text: string) {
await page.goto("/app");
await page.getByLabel("Describe the experiment").fill(text);
await page.getByRole("button", { name: "Interpret request" }).click();
await expect(page).toHaveURL(/step=2$/);
}
test("a card filled by hand states each problem as a fact and blocks Generate design (Review Focus 3)", async ({ page }) => {
await page.goto("/app");
await page.getByRole("button", { name: "Fill in the form yourself" }).click();
await expect(page).toHaveURL(/step=2$/);
const generate = page.getByRole("button", { name: "Generate design" });
await expect(generate).toBeDisabled();
await expect(page.getByText("Factor 1 needs a name.")).toBeVisible();
await expect(page.getByText("Fields marked * are required. Give the number of runs, the minimum resolution, or both.")).toBeVisible();
await page.getByRole("group", { name: "Factor 1" }).getByLabel("Name *").fill("Temp");
await page.getByRole("group", { name: "Factor 2" }).getByLabel("Name *").fill("Temp!");
await expect(page.getByText('"Temp!" and "Temp" have the same R name (Temp).')).toBeVisible();
await page.getByRole("group", { name: "Factor 2" }).getByLabel("Name *").fill("Speed");
await page.getByLabel("Runs", { exact: true }).selectOption("4");
await expect(generate).toBeEnabled();
await page.getByRole("group", { name: "Factor 2: Speed" }).getByLabel("Low level *").fill("slow");
await page.getByLabel("Center points", { exact: true }).fill("2");
await expect(page.getByText("Center points need all factors to be quantitative.", { exact: true })).toBeVisible();
await expect(generate).toBeDisabled();
await page.getByLabel("Center points", { exact: true }).fill("0");
await page.getByRole("button", { name: "Add factor" }).click();
await page.getByRole("button", { name: "Add factor" }).click();
await page.getByRole("group", { name: "Factor 3" }).getByLabel("Name *").fill("Time");
await page.getByRole("group", { name: "Factor 4" }).getByLabel("Name *").fill("Feed");
await expect(page.getByText("4 runs can hold at most 3 factors.")).toBeVisible();
await expect(generate).toBeDisabled();
});
test("the card of a translation shows quotes, defaults and agreement, and announces a move (spec 5.5, 5.6 and 9.6)", async ({ page }) => {
const coating = loadFixture("coating-design");
await routeTranslate(page);
await interpret(page, coating.request.turns[0].text);
// The engine announces "ready" once; wait for it, so that it does not replace the move below.
await expect(page.getByTestId("engine-status")).toHaveText("R engine: ready", { timeout: 240_000 });
await expect(page.getByRole("heading", { name: "Interpretation: 4 of 4 checked" })).toBeVisible();
await expect(page.getByText("Jev read the request as a request outside the scope (1 of 4).")).toBeVisible();
const first = page.getByRole("group", { name: "Factor 1: temperature" });
await expect(first.getByLabel("Low level *")).toHaveValue("150");
await expect(first.getByText("From your text: “150” · 3 of 3 agree")).toBeVisible();
await expect(page.getByText("From your text: “16 runs” · 4 of 4 agree")).toBeVisible();
await expect(page.getByText("default (today's date)", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "Move down: temperature" }).click();
await expect(page.getByRole("status")).toHaveText("temperature moved to position 2 of 7.");
await expect(page.getByRole("group", { name: "Factor 2: temperature" })).toBeVisible();
await page.reload();
await expect(page.getByRole("group", { name: "Factor 2: temperature" })).toBeVisible();
await expect(page.getByRole("button", { name: "Generate design" })).toBeEnabled();
});
test("a disagreement and a value to check block Generate design until the user decides (spec 5.6 and 5.7)", async ({ page }) => {
// An answer derived from the coating fixture: the recorded answers have no disagreement.
const coating = loadFixture("coating-design");
const body = structuredClone(coating.body) as { agreement: { fields: Record<string, unknown> }; check_fields: string[] };
body.agreement.fields.nruns = {
agree: false,
answered: 4,
values: [
{ value: "n:16", display: "16", count: 3, voters: ["openai", "anthropic", "jev"] },
{ value: "n:32", display: "32", count: 1, voters: ["google"] },
],
};
body.check_fields = ["factors[0].low"];
await routeTranslate(page, [answering(coating.request.turns, 200, body)]);
await interpret(page, coating.request.turns[0].text);
const generate = page.getByRole("button", { name: "Generate design" });
await expect(generate).toBeDisabled();
await expect(page.getByText("2 flagged values need a decision.")).toBeVisible();
await expect(page.getByRole("group", { name: "The voters gave different values: 16 runs: 3; 32 runs: 1" })).toBeVisible();
await page.getByRole("button", { name: "Use 32 runs" }).click();
await expect(page.getByLabel("Runs", { exact: true })).toHaveValue("32");
await expect(page.getByText("Check this value").filter({ visible: true })).toBeVisible();
await page.getByRole("button", { name: "Confirm value" }).click();
// The three LLMs agree on the value whose quote failed the check.
await expect(page.getByRole("group", { name: "Factor 1: temperature" }).getByText("Confirmed by you · 3 of 3 agree")).toBeVisible();
await expect(generate).toBeEnabled();
});
Run: npx playwright test replication_materials/tests/e2e/confirm.spec.ts Expected: FAIL in each test: step 2 shows "This step is not available in this version." and no "Generate design" button.
Create components/spec-card/FieldNote.tsx:
"use client";
import type { BoolField, Choice, TextField } from "@/lib/card/card";
import { agreeLabel, flagLabel, originLabel } from "@/lib/card/labels";
interface Props {
id: string;
field: TextField | BoolField;
/** The unit word of a disagreement, for example "runs" in "16 runs: 3". */
unit?: string | null;
onChoose: (choice: Choice) => void;
onConfirm: () => void;
}
// The note under a value (spec 5.5 to 5.7): its source, the agreement, and an open flag with
// its decision. The input names this note with aria-describedby.
export function FieldNote({ id, field, unit = null, onChoose, onConfirm }: Props) {
const source = [originLabel(field.origin), agreeLabel(field.agree)].filter(Boolean).join(" · ");
const flag = field.flag && !field.flag.resolved ? field.flag : null;
return (
<div id={id} className="mt-1 text-sm">
{source ? <p className="text-tan-dark">{source}</p> : null}
{flag?.kind === "check" ? (
<p className="mt-1 font-semibold">
Check this value
<button type="button" onClick={onConfirm} className="ml-2 rounded border border-accent px-2 py-0.5 font-normal text-accent hover:bg-tan-light">
Confirm value
</button>
</p>
) : null}
{flag?.kind === "disagree" ? (
<fieldset className="mt-1 rounded border border-accent p-2">
<legend className="px-1 font-semibold">{flagLabel(flag, unit)}</legend>
<div className="flex flex-wrap gap-2">
{flag.choices.map((choice) => (
<button key={choice.display} type="button" onClick={() => onChoose(choice)} className="rounded border border-tan-medium px-2 py-0.5 hover:bg-tan-light">
Use {choice.display}
{unit && choice.display !== "not given" ? ` ${unit}` : ""}
</button>
))}
</div>
</fieldset>
) : null}
</div>
);
}
Create components/spec-card/inputs.tsx:
"use client";
import { useId, type ReactNode } from "react";
import type { BoolField, Choice, FieldPath, TextField } from "@/lib/card/card";
import { FieldNote } from "./FieldNote";
export interface FieldActions {
onText: (path: FieldPath, value: string) => void;
onBool: (path: FieldPath, value: boolean) => void;
onChoose: (path: FieldPath, choice: Choice) => void;
onConfirm: (path: FieldPath) => void;
}
interface Base<F> {
label: string;
path: FieldPath;
field: F;
actions: FieldActions;
unit?: string | null;
/** The "?" button of the field, from WithHelp. */
help?: ReactNode;
}
function Label({ id, label, help }: { id: string; label: string; help?: ReactNode }) {
return (
<div className="flex items-center gap-2">
<label htmlFor={id} className="text-sm font-semibold">{label}</label>
{help}
</div>
);
}
export function TextInput({ label, path, field, actions, unit, help, inputMode }: Base<TextField> & { inputMode?: "numeric" | "decimal" | "text" }) {
const id = useId();
return (
<div>
<Label id={id} label={label} help={help} />
<input
id={id}
value={field.value}
inputMode={inputMode}
onChange={(e) => actions.onText(path, e.target.value)}
aria-describedby={`${id}-note`}
className="mt-1 w-full rounded border border-tan-medium px-2 py-1"
/>
<FieldNote id={`${id}-note`} field={field} unit={unit} onChoose={(c) => actions.onChoose(path, c)} onConfirm={() => actions.onConfirm(path)} />
</div>
);
}
export function SelectInput({ label, path, field, actions, unit, help, options }: Base<TextField> & { options: { value: string; text: string }[] }) {
const id = useId();
return (
<div>
<Label id={id} label={label} help={help} />
<select
id={id}
value={field.value}
onChange={(e) => actions.onText(path, e.target.value)}
aria-describedby={`${id}-note`}
className="mt-1 w-full rounded border border-tan-medium bg-white px-2 py-1"
>
{options.map((o) => (
<option key={o.value} value={o.value}>{o.text}</option>
))}
</select>
<FieldNote id={`${id}-note`} field={field} unit={unit} onChoose={(c) => actions.onChoose(path, c)} onConfirm={() => actions.onConfirm(path)} />
</div>
);
}
export function CheckInput({ label, path, field, actions, help, disabled }: Base<BoolField> & { disabled?: boolean }) {
const id = useId();
return (
<div>
<div className="flex items-center gap-2">
<input
id={id}
type="checkbox"
checked={field.value}
disabled={disabled}
onChange={(e) => actions.onBool(path, e.target.checked)}
aria-describedby={`${id}-note`}
className="h-4 w-4 accent-accent"
/>
<label htmlFor={id} className="text-sm font-semibold">{label}</label>
{help}
</div>
<FieldNote id={`${id}-note`} field={field} onChoose={(c) => actions.onChoose(path, c)} onConfirm={() => actions.onConfirm(path)} />
</div>
);
}
Create components/spec-card/FactorTable.tsx:
"use client";
import { WithHelp } from "@/components/help/WithHelp";
import type { Card } from "@/lib/card/card";
import { agreeLabel, flagLabel } from "@/lib/card/labels";
import type { HelpKey } from "@/lib/help/keys";
import { CheckInput, TextInput, type FieldActions } from "./inputs";
interface Props {
card: Card;
actions: FieldActions;
onMove: (id: string, step: -1 | 1) => void;
onAdd: () => void;
onRemove: (id: string) => void;
onKeep: () => void;
}
const COLUMN_HELP: { key: HelpKey; text: string }[] = [
{ key: "levels", text: "Levels" },
{ key: "unit", text: "Unit" },
{ key: "quantitative", text: "Quantitative factor" },
];
// The factors of the card (spec 9.3 and 9.6), in the order that sets the design columns.
// "Move up" and "Move down" name their factor, so a screen reader hears which one moves.
export function FactorTable({ card, actions, onMove, onAdd, onRemove, onKeep }: Props) {
const n = card.factors.length;
const count = card.nfactors;
const countFlag = count.flag && !count.flag.resolved ? count.flag : null;
return (
<section aria-labelledby="factors-heading" className="mt-6">
<WithHelp helpKey="factors">
{(button) => (
<div className="flex items-center gap-2">
<h3 id="factors-heading" className="font-display text-xl">Factors ({n})</h3>
{button}
</div>
)}
</WithHelp>
{agreeLabel(count.agree) ? <p className="text-sm text-tan-dark">Number of factors: {agreeLabel(count.agree)}</p> : null}
{countFlag ? (
<div className="mt-2 rounded border border-accent p-2 text-sm">
<p className="font-semibold">{countFlag.kind === "check" ? "Check the number of factors" : flagLabel(countFlag, "factors")}</p>
<button type="button" onClick={onKeep} className="mt-1 rounded border border-accent px-2 py-0.5 text-accent hover:bg-tan-light">
Keep these factors
</button>
</div>
) : null}
<WithHelp helpKey="factor_order">
{(button) => (
<p className="mt-2 flex items-center gap-2 text-sm">
The order sets the design column of each factor.
{button}
</p>
)}
</WithHelp>
<div className="mt-1 space-y-1 text-sm">
{COLUMN_HELP.map(({ key, text }) => (
<WithHelp key={key} helpKey={key}>
{(button) => (
<p className="flex items-center gap-2">
{text}
{button}
</p>
)}
</WithHelp>
))}
</div>
<ol className="mt-3 space-y-4">
{card.factors.map((f, i) => {
const name = f.name.value.trim();
const label = name || `Factor ${i + 1}`;
const base = `factor:${f.id}` as const;
return (
<li key={f.id}>
<fieldset className="rounded border border-tan-medium p-3">
<legend className="px-1 font-semibold">{`Factor ${i + 1}${name ? `: ${name}` : ""}`}</legend>
<div className="grid gap-3 md:grid-cols-[2fr_1fr_1fr_1fr_auto]">
<TextInput label="Name *" path={`${base}:name`} field={f.name} actions={actions} />
<TextInput label="Low level *" path={`${base}:low`} field={f.low} actions={actions} />
<TextInput label="High level *" path={`${base}:high`} field={f.high} actions={actions} />
<TextInput label="Unit" path={`${base}:unit`} field={f.unit} actions={actions} />
<CheckInput label="Quantitative" path={`${base}:quantitative`} field={f.quantitative} actions={actions} />
</div>
<div className="mt-2 flex flex-wrap gap-2 text-sm">
<button type="button" disabled={i === 0} aria-label={`Move up: ${label}`} onClick={() => onMove(f.id, -1)} className="rounded border border-tan-medium px-2 py-1 hover:bg-tan-light disabled:opacity-50">
Move up
</button>
<button type="button" disabled={i === n - 1} aria-label={`Move down: ${label}`} onClick={() => onMove(f.id, 1)} className="rounded border border-tan-medium px-2 py-1 hover:bg-tan-light disabled:opacity-50">
Move down
</button>
<button type="button" disabled={n <= 2} aria-label={`Remove: ${label}`} onClick={() => onRemove(f.id)} className="rounded border border-tan-medium px-2 py-1 hover:bg-tan-light disabled:opacity-50">
Remove
</button>
</div>
</fieldset>
</li>
);
})}
</ol>
<button type="button" onClick={onAdd} className="mt-3 rounded border border-accent px-3 py-1 text-accent hover:bg-tan-light">
Add factor
</button>
</section>
);
}
Create components/spec-card/VoterSummary.tsx:
"use client";
import { WithHelp } from "@/components/help/WithHelp";
import type { CardVoters } from "@/lib/card/card";
import { jevOutcomeText, voterLine, votersSummary } from "@/lib/card/labels";
// The voters of the interpretation (spec 5.6): "3 of 4 checked" or "Not cross-checked", and
// each voter with its state. Jev's different outcome is a flag only.
export function VoterSummary({ voters }: { voters: CardVoters }) {
const jev = jevOutcomeText(voters);
return (
<section aria-labelledby="voters-heading" className="mt-4 rounded border border-tan-medium p-3">
<WithHelp helpKey="agreement">
{(button) => (
<div className="flex items-center gap-2">
<h3 id="voters-heading" className="font-display text-lg">Interpretation: {votersSummary(voters)}</h3>
{button}
</div>
)}
</WithHelp>
<ul className="mt-2 text-sm">
{voters.lines.map((line) => (
<li key={line.voter}>{voterLine(line)}</li>
))}
</ul>
{jev ? <p className="mt-2 text-sm">{jev}</p> : null}
</section>
);
}
Create components/steps/ConfirmStep.tsx:
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { WithHelp } from "@/components/help/WithHelp";
import type { StepProps } from "@/components/shell/StepPanel";
import { FactorTable } from "@/components/spec-card/FactorTable";
import { CheckInput, SelectInput, TextInput, type FieldActions } from "@/components/spec-card/inputs";
import { VoterSummary } from "@/components/spec-card/VoterSummary";
import { announce } from "@/lib/a11y/announcer";
import {
addFactor, cardStatus, chooseValue, confirmValue, editBool, editText, emptyCard, keepFactors, moveFactor, removeFactor, type Card,
} from "@/lib/card/card";
import { generateCode } from "@/lib/codegen/frf2";
import type { HelpKey } from "@/lib/help/keys";
import { nameFromDesign } from "@/lib/projects/name";
import { DEFAULT_PROJECT_NAME } from "@/lib/projects/schema";
import { toRoman } from "@/lib/report/format";
import { RUN_SIZES } from "@/lib/spec/validate";
/** Text edits are stored this long after the last key press. */
const SAVE_DELAY_MS = 400;
const RUN_OPTIONS = [{ value: "", text: "not given" }, ...RUN_SIZES.map((n) => ({ value: String(n), text: String(n) }))];
const RESOLUTION_OPTIONS = [{ value: "", text: "not given" }, ...[3, 4, 5, 6, 7, 8, 9, 10].map((r) => ({ value: String(r), text: toRoman(r) }))];
// Step 2, Confirm (spec 4.3, 5.5 to 5.7, 6 and 9.3): the specification card. Each value shows
// its source and its agreement; each flag needs a decision; the pre-checks state facts; and
// "Generate design" is available only when the card is complete and every check passes.
export function ConfirmStep({ project, ready, onSave, go }: StepProps) {
const [card, setCard] = useState<Card | null>(project?.card ?? null);
const latest = useRef(project);
latest.current = project;
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const pending = useRef<Card | null>(null);
const held = project?.pending_rebuild != null;
const store = (next: Card) => {
const p = latest.current;
// A project whose report waits for a rebuild cannot be written before its new report (P1).
if (p && p.pending_rebuild === null) void onSave({ ...p, card: next });
};
const update = (next: Card, delay = 0) => {
setCard(next);
if (timer.current) clearTimeout(timer.current);
pending.current = null;
if (delay === 0) {
store(next);
return;
}
pending.current = next;
timer.current = setTimeout(() => {
pending.current = null;
store(next);
}, delay);
};
useEffect(
() => () => {
if (timer.current) clearTimeout(timer.current);
if (pending.current) store(pending.current);
},
// Runs once, when the step closes: it stores the last text edit.
[],
);
if (!project) return null;
if (!card) {
return (
<div className="mt-4">
<p>Interpret a request in step 1, or fill in the form yourself.</p>
<button
type="button"
disabled={!ready}
onClick={() => update(emptyCard(new Date(), () => crypto.randomUUID()))}
className="mt-3 rounded border border-accent px-4 py-2 font-semibold text-accent hover:bg-tan-light disabled:opacity-60"
>
Fill in the form yourself
</button>
</div>
);
}
const status = cardStatus(card);
const actions: FieldActions = {
onText: (path, value) => update(editText(card, path, value), path.startsWith("factor:") || path === "replications" || path === "center_points" || path === "seed" ? SAVE_DELAY_MS : 0),
onBool: (path, value) => update(editBool(card, path, value)),
onChoose: (path, choice) => update(chooseValue(card, path, choice)),
onConfirm: (path) => update(confirmValue(card, path)),
};
const messages = [...new Set([...status.issues.map((i) => i.message), ...(status.precheck?.messages ?? [])])];
const flags = status.openFlags;
async function generate() {
if (!project || !status.spec || !status.canGenerate || !card) return;
if (timer.current) clearTimeout(timer.current);
pending.current = null;
const spec = status.spec;
const name = project.name === DEFAULT_PROJECT_NAME ? nameFromDesign(spec.factors.length, status.precheck?.nruns ?? spec.nruns ?? 0) : project.name;
const saved = await onSave({
...project,
name,
card,
spec,
r_code: generateCode(spec).designCode,
report: null,
run_sheet_sha256: null,
versions: null,
frf2_messages: [],
pending_rebuild: null,
last_step: 3,
});
if (saved) {
announce("Making the design.");
go(project.id, 3);
}
}
const help = (key: HelpKey) => (render: (button: ReactNode) => ReactNode) => (
<WithHelp helpKey={key}>{render}</WithHelp>
);
return (
<div className="mt-4">
{held ? (
<p className="rounded border border-tan-medium bg-tan-light p-3 text-sm">
The report of this project waits for an update in step 3. Changes to this card are stored when a new design is made.
</p>
) : null}
{card.voters ? <VoterSummary voters={card.voters} /> : null}
<WithHelp helpKey="evidence">
{(button) => (
<p className="mt-4 flex items-center gap-2 text-sm">
Each value shows where it comes from.
{button}
</p>
)}
</WithHelp>
<p className="mt-1 text-sm">Fields marked * are required. Give the number of runs, the minimum resolution, or both.</p>
<FactorTable
card={card}
actions={actions}
onMove={(id, step) => {
const moved = moveFactor(card, id, step);
if (moved.announcement) announce(moved.announcement);
update(moved.card);
}}
onAdd={() => update(addFactor(card, crypto.randomUUID()))}
onRemove={(id) => update(removeFactor(card, id))}
onKeep={() => update(keepFactors(card))}
/>
<section aria-labelledby="size-heading" className="mt-8">
<h3 id="size-heading" className="font-display text-xl">Size and options</h3>
<div className="mt-3 grid gap-4 md:grid-cols-2">
{help("nruns")((button) => (
<SelectInput label="Runs" path="nruns" field={card.nruns} actions={actions} unit="runs" options={RUN_OPTIONS} help={button} />
))}
{help("min_resolution")((button) => (
<SelectInput label="Minimum resolution" path="min_resolution" field={card.min_resolution} actions={actions} options={RESOLUTION_OPTIONS} help={button} />
))}
{help("replications")((button) => (
<TextInput label="Replications" path="replications" field={card.replications} actions={actions} inputMode="numeric" help={button} />
))}
{help("repeat_only")((button) => (
<CheckInput label="Repeat only (repeated measurements)" path="repeat_only" field={card.repeat_only} actions={actions} help={button} />
))}
{help("center_points")((button) => (
<TextInput label="Center points" path="center_points" field={card.center_points} actions={actions} inputMode="numeric" help={button} />
))}
{help("randomize")((button) => (
<CheckInput label="Randomize the run order" path="randomize" field={card.randomize} actions={actions} help={button} />
))}
{help("seed")((button) => (
<TextInput label="Seed" path="seed" field={card.seed} actions={actions} inputMode="numeric" help={button} />
))}
</div>
</section>
<div id="generate-blockers" className="mt-8 space-y-2">
{messages.length > 0 || flags > 0 ? (
<div className="rounded border border-accent p-3">
<p className="font-semibold">Before the design can be made:</p>
<ul className="mt-1 list-disc pl-5">
{messages.map((m) => (
<li key={m}>{m}</li>
))}
{flags > 0 ? <li>{flags === 1 ? "1 flagged value needs a decision." : `${flags} flagged values need a decision.`}</li> : null}
</ul>
</div>
) : null}
{(status.precheck?.notes ?? []).map((note) => (
<p key={note}>{note}</p>
))}
</div>
<button
type="button"
disabled={!ready || !status.canGenerate}
aria-describedby="generate-blockers"
onClick={generate}
className="mt-4 rounded bg-accent px-5 py-3 font-semibold text-white hover:bg-accent-dark disabled:opacity-60"
>
Generate design
</button>
</div>
);
}
In components/shell/StepPanel.tsx, add the import import { ConfirmStep } from "@/components/steps/ConfirmStep"; and replace the three step lines at the end of the section with:
{current.n === 1 ? <DescribeStep key={key} {...props} /> : null}
{current.n === 2 ? <ConfirmStep key={key} {...props} /> : null}
{current.n !== 1 && !props.project ? <p className="mt-3">Select a project, or select "New design".</p> : null}
{current.n === 3 || current.n === 6 ? <p className="mt-3 text-tan-dark">This step is not available in this version.</p> : null}
Run: npx playwright test replication_materials/tests/e2e/confirm.spec.ts replication_materials/tests/e2e/describe.spec.ts Expected: PASS.
Run: npm run check Expected: "All checks passed."
git add components/spec-card components/steps/ConfirmStep.tsx components/shell/StepPanel.tsx replication_materials/tests/e2e/confirm.spec.ts
git commit -m "feat: add step 2, Confirm: the specification card with sources, agreement flags, pre-checks and Generate design"
15Task 8: Step 3, Design
Files:
- Create:
lib/report/sha.ts,lib/report/explain.ts - Modify:
lib/report/format.ts(display helpers) - Create:
components/design/RunSheet.tsx,components/design/Properties.tsx,components/design/RCode.tsx,components/design/DesignView.tsx,components/steps/DesignStep.tsx - Modify:
components/shell/StepPanel.tsx,app/globals.css(print rules) - Test:
replication_materials/tests/unit/explain.test.ts,replication_materials/tests/unit/report-format.test.ts,replication_materials/tests/unit/sha.test.ts,replication_materials/tests/e2e/design.spec.ts
Interfaces:
- Consumes:
Report,ReportSchema(lib/report/types.ts);RunOutcome,getEngine,useEngineStatus,useNow,engineStatusText(lib/webr/*);generateCode(lib/codegen/frf2.ts);APP_VERSION(lib/site.ts);StepProps(Task 6);WithHelp(Task 5);announce(Task 5). - Produces:
runSheetText(report)andrunSheetSha256(report): Promise<string>inlib/report/sha.ts(decision P12; 3C uses them for the.xlsxfile and the import).- In
lib/report/format.ts:designNotation(report),resolutionLabel(report),rowsText(report),wlpEntries(report). - In
lib/report/explain.ts:CHECK_FAILED,listText,notationSentences,resolutionSentences,wlpSentences,generatorLines,generatorSentences,clearValue,clearSentences,allClearSentences,allClearNote,aliasSentences,mainAliasSentences,runOrderText,runOrderSentences,runSheetSentences,runFailureText. DesignStep(props: StepProps): when the project has a spec, no report and no pending rebuild, it runs the design and storesreport,run_sheet_sha256,versions(the engine versions andapp),frf2_messagesandr_code. Task 9 adds the rebuild of a pending report.
Create replication_materials/tests/unit/sha.test.ts:
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { runSheetSha256, runSheetText } from "@/lib/report/sha";
import { ReportSchema } from "@/lib/report/types";
const coating = () => ReportSchema.parse(JSON.parse(readFileSync("replication_materials/tests/fixtures/reports/coating.json", "utf8")));
describe("run-sheet SHA-256 (decision P12)", () => {
it("hashes the labels, the run and standard order, and the real and coded levels", async () => {
const report = coating();
expect(JSON.parse(runSheetText(report))).toEqual({
labels: report.factors.map((f) => f.label),
run: report.run_sheet.run,
std: report.run_sheet.std,
levels: report.run_sheet.levels,
coded: report.run_sheet.coded,
});
expect(await runSheetSha256(report)).toBe(createHash("sha256").update(runSheetText(report), "utf8").digest("hex"));
});
it("changes when a factor is renamed or one level changes", async () => {
const report = coating();
const base = await runSheetSha256(report);
const renamed = { ...report, factors: report.factors.map((f, i) => (i === 0 ? { ...f, label: "Temperature" } : f)) };
expect(await runSheetSha256(renamed)).not.toBe(base);
const levels = report.run_sheet.levels.map((row, i) => (i === 0 ? ["150", ...row.slice(1)] : row));
expect(await runSheetSha256({ ...report, run_sheet: { ...report.run_sheet, levels } })).not.toBe(base);
});
});
Replace replication_materials/tests/unit/report-format.test.ts with:
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { designNotation, resolutionLabel, rowsText, toRoman, wlpEntries } from "@/lib/report/format";
import { ReportSchema, type Report } from "@/lib/report/types";
const coating = () => ReportSchema.parse(JSON.parse(readFileSync("replication_materials/tests/fixtures/reports/coating.json", "utf8")));
describe("display helpers of the report (spec 9.4)", () => {
it("writes resolutions as Roman numerals", () => {
expect([3, 4, 5, 9, 14, 40].map(toRoman)).toEqual(["III", "IV", "V", "IX", "XIV", "XL"]);
expect(() => toRoman(0)).toThrow();
});
it("gives the notation, the resolution and the WLP of the coating design", () => {
const report = coating();
expect(designNotation(report)).toEqual({ k: 7, p: 3 });
expect(resolutionLabel(report)).toBe("Resolution IV");
expect(wlpEntries(report)).toEqual([
{ length: 3, count: 0 },
{ length: 4, count: 7 },
{ length: 5, count: 0 },
{ length: 6, count: 0 },
]);
expect(rowsText(report)).toBeNull();
});
it("names a full factorial and a resolution that is only a lower bound", () => {
const report = coating();
expect(resolutionLabel({ ...report, resolution: { value: null, full_factorial: true, at_least: null } })).toBe("Full factorial");
expect(resolutionLabel({ ...report, resolution: { value: null, full_factorial: false, at_least: 9 } })).toBe("Resolution IX or higher");
});
it("counts the rows of replicates and center points", () => {
const report = coating();
const with2: Report = { ...report, design: { ...report.design, replications: 2, center_points: 2, nrows: 36 } };
expect(rowsText(with2)).toBe("36 rows: 2 replicates of 16 runs, each with 2 center points.");
const one: Report = { ...report, design: { ...report.design, center_points: 2, nrows: 18 } };
expect(rowsText(one)).toBe("18 rows: 16 runs and 2 center points.");
});
it("keeps the WLP short for a large design: lengths 3 to 6, then the longer lengths with words (Review Focus 4)", () => {
const report = coating();
const values = Array.from({ length: 65 }, (_, i) => ([4, 6, 8, 30].includes(i + 1) ? i + 1 : 0));
expect(wlpEntries({ ...report, wlp: { values, max_length: 65, complete: true } }).map((e) => e.length)).toEqual([3, 4, 5, 6, 8, 30]);
});
});
Create replication_materials/tests/unit/explain.test.ts:
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import {
CHECK_FAILED, aliasSentences, allClearNote, allClearSentences, clearSentences, clearValue, generatorLines, generatorSentences,
mainAliasSentences, notationSentences, resolutionSentences, runFailureText, runOrderText, wlpSentences,
} from "@/lib/report/explain";
import { ReportSchema, type Report } from "@/lib/report/types";
const coating = () => ReportSchema.parse(JSON.parse(readFileSync("replication_materials/tests/fixtures/reports/coating.json", "utf8")));
const labels = (names: string[]) => names.map((n) => ({ r_name: n, label: n }));
/** A full factorial in 7 factors: no generators, no aliasing, all 2FIs clear (R23). */
function fullFactorial(): Report {
const report = coating();
const names = report.factors.map((f) => f.label);
const clear = names.flatMap((a, i) => names.slice(i + 1).map((b) => `${a}:${b}`));
return {
...report,
design: { ...report.design, nruns: 128, nrows: 128 },
resolution: { value: null, full_factorial: true, at_least: null },
wlp: { values: [0, 0, 0, 0, 0, 0, 0], max_length: 7, complete: true },
generators: [],
aliases: { main: [], two_factor: [] },
clear_2fis: clear,
all_2fis_clear: names,
};
}
describe("sentences of the help panels (spec 9.5, layer 2)", () => {
it("explains the coating design", () => {
const report = coating();
expect(notationSentences(report)).toEqual(["7 factors and 3 generators give 16 runs."]);
expect(resolutionSentences(report)).toEqual(["The shortest word has length 4, and the design has 7 words of this length."]);
expect(wlpSentences(report)).toEqual(["The defining relation has 7 words: 7 of length 4."]);
expect(generatorLines(report)).toEqual(["Feed = Temp·Pressure·Speed", "Depth = Temp·Pressure·Time", "Coolant = Temp·Speed·Time"]);
expect(generatorSentences(report)).toEqual(["The 3 added factors come from their generators, for example Feed = Temp·Pressure·Speed (coded E=ABC)."]);
expect(clearValue(report)).toBe("0 of 21");
expect(clearSentences(report)).toEqual(["None of the 21 2FIs is clear.", "The count leaves out interactions of three or more factors (effect hierarchy)."]);
expect(allClearSentences(report)).toEqual(["No factor has all 2FIs clear."]);
expect(allClearNote(report)).toBeNull();
expect(mainAliasSentences(report)).toEqual(["No main effect is aliased with a 2FI."]);
expect(runOrderText(report)).toBe("Random run order, seed 20261014");
});
it("gives the alias example of spec 9.5", () => {
expect(aliasSentences(coating())).toEqual(["The 21 2FIs fall into 7 groups of 3. Example: Temp:Pressure = Speed:Feed = Time:Depth."]);
});
it("explains a full factorial (R23)", () => {
const report = fullFactorial();
expect(notationSentences(report)).toEqual(["7 factors give the full factorial with 128 runs."]);
expect(generatorSentences(report)).toEqual(["A full factorial has no generators."]);
expect(clearSentences(report)[0]).toBe("All 21 2FIs are clear.");
expect(allClearSentences(report)).toEqual(["Every factor has all 2FIs clear."]);
expect(allClearNote(report)).toBeNull();
expect(aliasSentences(report)).toEqual(["A full factorial has no aliasing: all 21 2FIs can be estimated separately."]);
});
it("names the positions of the factors with all 2FIs clear (spec 9.4)", () => {
const report = { ...coating(), factors: labels(["A", "B", "C", "D", "E", "F", "G", "H", "Feed"]) };
expect(allClearNote({ ...report, all_2fis_clear: ["Feed"] })).toBe(
"Only the factor in position 9 (now Feed) has all 2FIs clear. To give this property to a different factor, move that factor to position 9 in step 2.",
);
expect(allClearNote({ ...report, all_2fis_clear: ["G", "Feed"] })).toBe(
"Only the factors in positions 7 and 9 (now G and Feed) have all 2FIs clear. To give this property to different factors, move those factors to positions 7 and 9 in step 2.",
);
expect(allClearSentences({ ...report, all_2fis_clear: ["G", "Feed"] })).toEqual(["G and Feed have all 2FIs clear."]);
});
it("keeps one short sentence for a long alias list (Review Focus 4)", () => {
const names = Array.from({ length: 65 }, (_, i) => `F${i + 1}`);
const chains = Array.from({ length: 400 }, (_, i) => `F1:F${i % 64 + 2} = F3:F${(i * 7) % 60 + 4}`);
const report = { ...coating(), factors: labels(names), aliases: { main: ["F1 = F2:F3"], two_factor: chains } };
expect(aliasSentences(report)).toEqual([`800 of the 2080 2FIs fall into 400 groups of 2. Example: ${chains[0]}.`]);
expect(mainAliasSentences(report)).toEqual(["1 main effect is aliased with 2FIs. Example: F1 = F2:F3."]);
});
it("names a run that failed and a report that failed its checks (spec 7.8)", () => {
expect(runFailureText({ ok: false, reason: "r_error", message: "nruns must be a power of 2." })).toBe("R stopped with an error: nruns must be a power of 2.");
expect(runFailureText({ ok: false, reason: "cancelled", message: "The run was cancelled." })).toBe("The run was cancelled.");
expect(CHECK_FAILED).toBe("Report check failed. The design is not shown.");
});
});
Create replication_materials/tests/e2e/design.spec.ts:
import { expect, test, type Page } from "@playwright/test";
import { generateDesignCode } from "@/lib/codegen/frf2";
import { defaultSeed } from "@/lib/spec/seed";
import type { CoreSpec } from "@/lib/spec/validate";
import { loadFixture, routeTranslate } from "./helpers/translate-fixtures";
type TestGlobals = { __ffdEngine: { run(code: { designCode: string; reportCall: string }): Promise<unknown> } };
async function coatingCard(page: Page) {
const coating = loadFixture("coating-design");
await routeTranslate(page);
await page.goto("/app");
await page.getByLabel("Describe the experiment").fill(coating.request.turns[0].text);
await page.getByRole("button", { name: "Interpret request" }).click();
await expect(page).toHaveURL(/step=2$/);
return coating;
}
test("Generate design runs FrF2 in the browser and shows the design with its properties (spec 4.3 and 9.4)", async ({ page }) => {
test.setTimeout(600_000);
const coating = await coatingCard(page);
await page.getByRole("button", { name: "Generate design" }).click();
await expect(page).toHaveURL(/step=3$/);
const main = page.getByRole("main");
await expect(main.getByText(/16 runs · Resolution IV$/)).toBeVisible({ timeout: 240_000 });
await expect(main.getByText("Design from the FrF2 catalogue, entry 7-3.1.", { exact: true })).toBeVisible();
const sheet = main.getByRole("table");
await expect(sheet.locator("tbody tr")).toHaveCount(16);
await expect(sheet.getByRole("columnheader", { name: "temperature (C)" })).toBeVisible();
await expect(main.getByText("0 of 21", { exact: true })).toBeVisible();
await expect(main.getByText("temperature:pressure = speed:feed = time:depth", { exact: true })).toBeVisible();
await expect(main.getByText("To change which effects are aliased, change the factor order in step 2.", { exact: true })).toBeVisible();
await main.getByText("R code", { exact: true }).click();
const spec = { ...(coating.body.spec as CoreSpec), seed: defaultSeed(new Date()) };
await expect(main.locator("pre code")).toHaveText(generateDesignCode(spec));
await main.getByRole("button", { name: "Help: Aliased 2FIs" }).click();
await expect(main.getByText("The 21 2FIs fall into 7 groups of 3. Example: temperature:pressure = speed:feed = time:depth.", { exact: true })).toBeVisible();
await page.reload();
await expect(main.getByText(/16 runs · Resolution IV$/)).toBeVisible({ timeout: 60_000 });
await expect(page.getByRole("button", { name: /^We want to screen 7 factors … 16 runs/ })).toBeVisible();
});
test("step 3 shows the computing indicator and Cancel, and makes the design after a cancelled run (spec 7.9)", async ({ page }) => {
test.setTimeout(600_000);
await coatingCard(page);
await expect(page.getByTestId("engine-status")).toHaveText("R engine: ready", { timeout: 240_000 });
// An R loop that does not end keeps the engine busy; the design of step 3 waits behind it.
// The test build has a 3-second soft limit.
await page.evaluate(() => {
void (globalThis as unknown as TestGlobals).__ffdEngine.run({ designCode: "repeat {}", reportCall: "NULL" });
});
await page.getByRole("button", { name: "Generate design" }).click();
const main = page.getByRole("main");
await expect(main.getByText(/^R engine: computing the design \(\d+:\d\d\)$/)).toBeVisible();
await expect(main.getByText(/^R engine: still running \(\d+:\d\d\)$/)).toBeVisible({ timeout: 15_000 });
await main.getByRole("button", { name: "Cancel" }).click();
await expect(main.getByText(/16 runs · Resolution IV$/)).toBeVisible({ timeout: 240_000 });
});
test("a 256-run design has pages in the run sheet, with run or standard order and real or coded levels (Review Focus 4)", async ({ page }) => {
test.setTimeout(600_000);
await page.goto("/app");
await page.getByRole("button", { name: "Fill in the form yourself" }).click();
for (let i = 3; i <= 9; i++) await page.getByRole("button", { name: "Add factor" }).click();
for (let i = 1; i <= 9; i++) await page.getByRole("group", { name: `Factor ${i}`, exact: true }).getByLabel("Name *").fill(`X${i}`);
await page.getByLabel("Runs", { exact: true }).selectOption("256");
await page.getByRole("button", { name: "Generate design" }).click();
const main = page.getByRole("main");
await expect(main.getByText(/256 runs · Resolution IX$/)).toBeVisible({ timeout: 240_000 });
await expect(main.getByRole("table").locator("tbody tr")).toHaveCount(100);
await expect(main.getByText("Page 1 of 3", { exact: true })).toBeVisible();
await main.getByRole("button", { name: "Next rows" }).click();
await expect(main.getByText("Page 2 of 3", { exact: true })).toBeVisible();
await expect(main.locator("caption")).toContainText("Rows 101 to 200");
await main.getByRole("button", { name: "Standard order" }).click();
await main.getByRole("button", { name: "Coded levels" }).click();
await expect(main.locator("caption")).toContainText("standard order, coded levels");
await expect(page.getByRole("button", { name: /^9 factors in 256 runs/ })).toBeVisible();
});
Run: npx vitest run --project unit replication_materials/tests/unit/sha.test.ts replication_materials/tests/unit/report-format.test.ts replication_materials/tests/unit/explain.test.ts Expected: FAIL. @/lib/report/sha and @/lib/report/explain do not exist, and format.ts has no designNotation.
Run: npx playwright test replication_materials/tests/e2e/design.spec.ts Expected: FAIL in each test: step 3 shows "This step is not available in this version."
Create lib/report/sha.ts:
import type { Report } from "./types";
// The SHA-256 of a run sheet (spec 8.3 and 8.5, decision P12): of the UTF-8 text
// JSON.stringify({ labels, run, std, levels, coded }). The labels are in it, because a renamed
// factor gives another run sheet. Milestone 3C writes the same value into the hidden sheet of
// the .xlsx file and compares it on import.
export function runSheetText(report: Report): string {
return JSON.stringify({
labels: report.factors.map((f) => f.label),
run: report.run_sheet.run,
std: report.run_sheet.std,
levels: report.run_sheet.levels,
coded: report.run_sheet.coded,
});
}
export async function runSheetSha256(report: Report): Promise<string> {
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(runSheetText(report)));
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
}
In lib/report/format.ts, add after the comment block at the top:
import type { Report } from "./types";
and add at the end of the file:
/** k and p of the notation 2^(k-p) (spec 9.4). A full factorial has p = 0. */
export function designNotation(report: Report): { k: number; p: number } {
return { k: report.factors.length, p: report.generators.length };
}
export function resolutionLabel(report: Report): string {
if (report.resolution.full_factorial) return "Full factorial";
if (report.resolution.value !== null) return `Resolution ${toRoman(report.resolution.value)}`;
if (report.resolution.at_least !== null) return `Resolution ${toRoman(report.resolution.at_least)} or higher`;
return "Resolution not computed";
}
/** The rows of a design with replicates or center points; null for one replicate without them. */
export function rowsText(report: Report): string | null {
const d = report.design;
if (d.replications === 1 && d.center_points === 0) return null;
const points = `${d.center_points} center ${d.center_points === 1 ? "point" : "points"}`;
if (d.replications === 1) return `${d.nrows} rows: ${d.nruns} runs and ${points}.`;
const copies = `${d.replications} ${d.repeat_only ? "repeated measurements" : "replicates"} of ${d.nruns} runs`;
return `${d.nrows} rows: ${copies}${d.center_points > 0 ? `, each with ${points}` : ""}.`;
}
/** The WLP as the step shows it: lengths 3 to 6, and each longer length that has words. */
export function wlpEntries(report: Report): { length: number; count: number }[] {
return report.wlp.values
.map((count, i) => ({ length: i + 1, count }))
.filter((e) => e.length >= 3 && (e.length <= 6 || e.count > 0));
}
Create lib/report/explain.ts:
import type { RunOutcome } from "@/lib/webr/client";
import type { Report } from "./types";
// Sentences computed from the R output of one design: layer 2 of the help panels (spec 9.5)
// and the notes of step 3 (spec 9.4). Fixed templates; no LLM writes this text (spec 9.5).
export const CHECK_FAILED = "Report check failed. The design is not shown.";
const pairs = (k: number) => (k * (k - 1)) / 2;
const plural = (n: number, one: string, many: string) => (n === 1 ? one : many);
/** "a", "a and b", "a, b and c". */
export function listText(items: readonly string[]): string {
if (items.length <= 1) return items.join("");
return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
}
export function notationSentences(report: Report): string[] {
const k = report.factors.length;
const p = report.generators.length;
if (report.resolution.full_factorial) return [`${k} factors give the full factorial with ${report.design.nruns} runs.`];
return [`${k} factors and ${p} ${plural(p, "generator", "generators")} give ${report.design.nruns} runs.`];
}
export function resolutionSentences(report: Report): string[] {
if (report.resolution.full_factorial) return ["A full factorial has no aliasing, so no word limits its resolution."];
const r = report.resolution.value;
if (r === null) {
const atLeast = report.resolution.at_least;
return atLeast === null ? [] : [`No word of length ${report.wlp.max_length} or less exists, so the resolution is at least ${atLeast}.`];
}
const words = report.wlp.values[r - 1] ?? 0;
return [`The shortest word has length ${r}, and the design has ${words} ${plural(words, "word", "words")} of this length.`];
}
export function wlpSentences(report: Report): string[] {
if (report.resolution.full_factorial) return ["A full factorial has no defining relation, so every count is 0."];
const words = report.wlp.values.map((count, i) => ({ length: i + 1, count })).filter((w) => w.count > 0);
const total = words.reduce((sum, w) => sum + w.count, 0);
const lead = report.wlp.complete ? "The defining relation has" : `Up to length ${report.wlp.max_length}, the defining relation has`;
const out = [`${lead} ${total} ${plural(total, "word", "words")}: ${listText(words.map((w) => `${w.count} of length ${w.length}`))}.`];
if (!report.wlp.complete) out.push(`Lengths above ${report.wlp.max_length} are not computed.`);
return out;
}
/** "Feed = Temp·Pressure·Speed": each generator with the real factor names. */
export function generatorLines(report: Report): string[] {
return report.generators.map((g) => `${g.factor} = ${g.product.split(":").join("·")}`);
}
export function generatorSentences(report: Report): string[] {
const p = report.generators.length;
if (p === 0) return ["A full factorial has no generators."];
const comes = plural(p, "added factor comes from its generator", "added factors come from their generators");
return [`The ${p} ${comes}, for example ${generatorLines(report)[0]} (coded ${report.generators[0].coded}).`];
}
export function clearValue(report: Report): string {
return `${report.clear_2fis.length} of ${pairs(report.factors.length)}`;
}
export function clearSentences(report: Report): string[] {
const total = pairs(report.factors.length);
const c = report.clear_2fis.length;
const first = c === 0 ? `None of the ${total} 2FIs is clear.` : c === total ? `All ${total} 2FIs are clear.` : `${c} of the ${total} 2FIs are clear.`;
return [first, "The count leaves out interactions of three or more factors (effect hierarchy)."];
}
export function allClearSentences(report: Report): string[] {
const names = report.all_2fis_clear;
if (names.length === 0) return ["No factor has all 2FIs clear."];
if (names.length === report.factors.length) return ["Every factor has all 2FIs clear."];
return [`${listText(names)} ${plural(names.length, "has", "have")} all 2FIs clear.`];
}
/** The note of spec 9.4 when some factors, but not all, have all 2FIs clear. */
export function allClearNote(report: Report): string | null {
const names = report.all_2fis_clear;
if (names.length === 0 || names.length === report.factors.length) return null;
const positions = listText(names.map((n) => String(report.factors.findIndex((f) => f.label === n) + 1)));
if (names.length === 1) {
return `Only the factor in position ${positions} (now ${names[0]}) has all 2FIs clear. To give this property to a different factor, move that factor to position ${positions} in step 2.`;
}
return `Only the factors in positions ${positions} (now ${listText(names)}) have all 2FIs clear. To give this property to different factors, move those factors to positions ${positions} in step 2.`;
}
export function aliasSentences(report: Report): string[] {
const total = pairs(report.factors.length);
if (report.resolution.full_factorial) return [`A full factorial has no aliasing: all ${total} 2FIs can be estimated separately.`];
const groups = report.aliases.two_factor;
if (groups.length === 0) return ["No 2FI is aliased with another 2FI."];
const sizes = groups.map((g) => g.split(" = ").length);
const inGroups = sizes.reduce((sum, n) => sum + n, 0);
const min = Math.min(...sizes);
const max = Math.max(...sizes);
const shape = `${groups.length} ${plural(groups.length, "group", "groups")} of ${min === max ? min : `${min} to ${max}`}`;
const who = inGroups === total ? `The ${total} 2FIs` : `${inGroups} of the ${total} 2FIs`;
return [`${who} fall into ${shape}. Example: ${groups[0]}.`];
}
export function mainAliasSentences(report: Report): string[] {
const chains = report.aliases.main;
if (chains.length === 0) return ["No main effect is aliased with a 2FI."];
return [`${chains.length} ${plural(chains.length, "main effect is", "main effects are")} aliased with 2FIs. Example: ${chains[0]}.`];
}
export function runOrderText(report: Report): string {
return report.design.randomized ? `Random run order, seed ${report.design.seed}` : "Standard order (not randomized)";
}
export function runOrderSentences(report: Report): string[] {
return report.design.randomized
? [`FrF2 set the random run order with the seed ${report.design.seed}. The same seed, design and software versions give the same run order.`]
: ["The runs are in standard order."];
}
export function runSheetSentences(report: Report): string[] {
const rows = report.design.nrows;
return [`${rows} ${plural(rows, "row", "rows")} and ${report.factors.length} factors.`];
}
/** The text of a run that did not give a report (spec 7.9). */
export function runFailureText(outcome: Extract<RunOutcome, { ok: false }>): string {
return outcome.reason === "r_error" ? `R stopped with an error: ${outcome.message}` : outcome.message;
}
Run: npx vitest run --project unit replication_materials/tests/unit/sha.test.ts replication_materials/tests/unit/report-format.test.ts replication_materials/tests/unit/explain.test.ts Expected: PASS.
Create components/design/RunSheet.tsx:
"use client";
import { useMemo, useState } from "react";
import { WithHelp } from "@/components/help/WithHelp";
import { runSheetSentences } from "@/lib/report/explain";
import type { Report } from "@/lib/report/types";
import type { CoreSpec } from "@/lib/spec/validate";
/** Large tables use pages, not virtual scrolling (spec 9.10). */
const PAGE_SIZE = 100;
function Toggle({ pressed, onClick, children }: { pressed: boolean; onClick: () => void; children: string }) {
return (
<button
type="button"
aria-pressed={pressed}
onClick={onClick}
className={`rounded border px-2 py-1 ${pressed ? "border-accent bg-accent text-white" : "border-tan-medium bg-white hover:bg-tan-light"}`}
>
{children}
</button>
);
}
// The run sheet (spec 7.5, 9.4 and 9.10): a real table with a caption and scope headers, in
// run order or standard order, with real or coded levels, in pages of 100 rows.
export function RunSheet({ report, spec }: { report: Report; spec: CoreSpec }) {
const [standard, setStandard] = useState(false);
const [coded, setCoded] = useState(false);
const [page, setPage] = useState(0);
const sheet = report.run_sheet;
const order = useMemo(() => {
const rows = sheet.run.map((_, i) => i);
if (standard) rows.sort((a, b) => sheet.std[a] - sheet.std[b] || sheet.run[a] - sheet.run[b]);
return rows;
}, [sheet, standard]);
const pages = Math.max(1, Math.ceil(order.length / PAGE_SIZE));
const shown = order.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
const header = report.factors.map((f, j) => (spec.factors[j]?.unit ? `${f.label} (${spec.factors[j].unit})` : f.label));
return (
<section aria-labelledby="run-sheet-heading">
<WithHelp helpKey="run_sheet" computed={runSheetSentences(report)}>
{(button) => (
<div className="flex items-center gap-2">
<h3 id="run-sheet-heading" className="font-display text-xl">Run sheet</h3>
{button}
</div>
)}
</WithHelp>
<div className="mt-2 flex flex-wrap items-center gap-3 text-sm print:hidden">
<div role="group" aria-label="Row order" className="flex gap-1">
<Toggle pressed={!standard} onClick={() => setStandard(false)}>Run order</Toggle>
<Toggle pressed={standard} onClick={() => setStandard(true)}>Standard order</Toggle>
</div>
<div role="group" aria-label="Levels" className="flex gap-1">
<Toggle pressed={!coded} onClick={() => setCoded(false)}>Real levels</Toggle>
<Toggle pressed={coded} onClick={() => setCoded(true)}>Coded levels</Toggle>
</div>
<button type="button" onClick={() => window.print()} className="rounded border border-tan-medium px-2 py-1 hover:bg-tan-light">
Print
</button>
</div>
<div className="mt-2 overflow-x-auto">
<table className="min-w-full border-collapse text-sm">
<caption className="text-left text-sm text-tan-dark">
Run sheet: {report.design.nrows} rows, {report.factors.length} factors, {standard ? "standard order" : "run order"}, {coded ? "coded levels" : "real levels"}. Rows {page * PAGE_SIZE + 1} to {page * PAGE_SIZE + shown.length}.
</caption>
<thead>
<tr className="border-b border-tan-medium text-left">
<th scope="col" className="px-2 py-1">Run</th>
<th scope="col" className="px-2 py-1">Std</th>
{header.map((h) => (
<th key={h} scope="col" className="px-2 py-1">{h}</th>
))}
</tr>
</thead>
<tbody>
{shown.map((i) => (
<tr key={i} className="border-b border-tan-light">
<th scope="row" className="px-2 py-1 text-left font-normal">{sheet.run[i]}</th>
<td className="px-2 py-1">{sheet.std[i]}</td>
{(coded ? sheet.coded[i] : sheet.levels[i]).map((value, j) => (
<td key={j} className="px-2 py-1">{value === null ? "NA" : String(value)}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{pages > 1 ? (
<nav aria-label="Run sheet pages" className="mt-2 flex items-center gap-3 text-sm">
<button type="button" disabled={page === 0} onClick={() => setPage(page - 1)} className="underline disabled:text-tan-dark disabled:no-underline">
Previous rows
</button>
<span>{`Page ${page + 1} of ${pages}`}</span>
<button type="button" disabled={page === pages - 1} onClick={() => setPage(page + 1)} className="underline disabled:text-tan-dark disabled:no-underline">
Next rows
</button>
</nav>
) : null}
</section>
);
}
Create components/design/Properties.tsx:
"use client";
import type { ReactNode } from "react";
import { WithHelp } from "@/components/help/WithHelp";
import type { HelpKey } from "@/lib/help/keys";
import {
aliasSentences, allClearNote, allClearSentences, clearSentences, clearValue, generatorLines, generatorSentences,
mainAliasSentences, resolutionSentences, runOrderSentences, runOrderText, wlpSentences,
} from "@/lib/report/explain";
import { resolutionLabel, wlpEntries } from "@/lib/report/format";
import type { Report } from "@/lib/report/types";
function Row({ helpKey, label, computed, children }: { helpKey: HelpKey; label: string; computed: string[]; children: ReactNode }) {
return (
<WithHelp helpKey={helpKey} computed={computed}>
{(button) => (
<div>
<p className="flex items-center gap-2 font-semibold">
{label}
{button}
</p>
<div className="mt-1">{children}</div>
</div>
)}
</WithHelp>
);
}
// The design properties (spec 7.5 and 9.4). Each has a "?" panel with the sentences that
// lib/report/explain.ts computes for this design. Effects use the real factor names.
export function Properties({ report, frf2Messages }: { report: Report; frf2Messages: string[] }) {
const note = allClearNote(report);
return (
<section aria-labelledby="properties-heading" className="min-w-0 space-y-4">
<h3 id="properties-heading" className="font-display text-xl">Design properties</h3>
<Row helpKey="resolution" label="Resolution" computed={resolutionSentences(report)}>
<p>{resolutionLabel(report).replace(/^Resolution /, "")}</p>
</Row>
<Row helpKey="wlp" label="Word length pattern" computed={wlpSentences(report)}>
<p>
{wlpEntries(report).map((e, i) => (
<span key={e.length}>
{i > 0 ? ", " : ""}A<sub>{e.length}</sub> = {e.count}
</span>
))}
{report.wlp.complete ? null : ` (lengths above ${report.wlp.max_length} are not computed)`}
</p>
</Row>
<Row helpKey="generators" label="Generators" computed={generatorSentences(report)}>
{report.generators.length === 0 ? (
<p>none (full factorial)</p>
) : (
<ul>
{generatorLines(report).map((line) => (
<li key={line}>{line}</li>
))}
</ul>
)}
</Row>
<Row helpKey="clear_2fis" label="Clear 2FIs" computed={clearSentences(report)}>
<p>{clearValue(report)}</p>
{report.clear_2fis.length > 0 ? <p className="text-sm">{report.clear_2fis.join(", ")}</p> : null}
</Row>
<Row helpKey="all_2fis_clear" label="Factors with all 2FIs clear" computed={allClearSentences(report)}>
<p>{report.all_2fis_clear.length === 0 ? "none" : report.all_2fis_clear.join(", ")}</p>
</Row>
<Row helpKey="run_order" label="Run order" computed={runOrderSentences(report)}>
<p>{runOrderText(report)}</p>
</Row>
<Row helpKey="frf2_messages" label="FrF2 messages" computed={[]}>
{frf2Messages.length === 0 ? (
<p>none</p>
) : (
<ul className="text-sm">
{frf2Messages.map((m) => (
<li key={m}>{m}</li>
))}
</ul>
)}
</Row>
{report.aliases.main.length > 0 ? (
<Row helpKey="aliased_main" label="Aliased main effects" computed={mainAliasSentences(report)}>
<ul className="text-sm">
{report.aliases.main.map((chain) => (
<li key={chain}>{chain}</li>
))}
</ul>
</Row>
) : null}
<Row helpKey="aliased_2fis" label="Aliased two-factor interactions" computed={aliasSentences(report)}>
{report.aliases.two_factor.length === 0 ? (
<p>none</p>
) : (
<ul className="text-sm">
{report.aliases.two_factor.map((chain) => (
<li key={chain}>{chain}</li>
))}
</ul>
)}
</Row>
<p className="text-sm">
To change which effects are aliased, change the factor order in step 2.
{note ? ` ${note}` : ""}
</p>
</section>
);
}
Create components/design/RCode.tsx:
"use client";
import { WithHelp } from "@/components/help/WithHelp";
import { announce } from "@/lib/a11y/announcer";
// "R code" (spec 7.1 and 9.4): the exact code that the worker ran, collapsed, with Copy.
export function RCode({ code }: { code: string }) {
async function copy() {
try {
await navigator.clipboard.writeText(code);
announce("R code copied.");
} catch {
announce("The R code could not be copied.", "assertive");
}
}
return (
<details className="mt-6 rounded border border-tan-medium p-3">
<summary className="cursor-pointer font-semibold">R code</summary>
<WithHelp helpKey="r_code">
{(button) => (
<p className="mt-2 flex items-center gap-2 text-sm">
The exact code that ran in the browser.
{button}
</p>
)}
</WithHelp>
<pre className="mt-2 overflow-x-auto rounded bg-tan-light p-3 text-sm">
<code>{code}</code>
</pre>
<button type="button" onClick={copy} className="mt-2 rounded border border-tan-medium px-2 py-1 text-sm hover:bg-tan-light print:hidden">
Copy
</button>
</details>
);
}
Create components/design/DesignView.tsx:
"use client";
import { WithHelp } from "@/components/help/WithHelp";
import { notationSentences } from "@/lib/report/explain";
import { designNotation, resolutionLabel, rowsText } from "@/lib/report/format";
import type { Report } from "@/lib/report/types";
import type { CoreSpec } from "@/lib/spec/validate";
import { Properties } from "./Properties";
import { RCode } from "./RCode";
import { RunSheet } from "./RunSheet";
// The design of step 3 (spec 9.4; layout C of mockup 04): the headline and the guarantee
// statement, the run sheet and the properties side by side (stacked below about 1100 px), and
// the R code under the run sheet.
export function DesignView({ report, spec, code, frf2Messages }: { report: Report; spec: CoreSpec; code: string; frf2Messages: string[] }) {
const { k, p } = designNotation(report);
const rows = rowsText(report);
return (
<div className="mt-4">
<WithHelp helpKey="design_notation" computed={notationSentences(report)}>
{(button) => (
<div className="flex flex-wrap items-center gap-2">
<p className="rounded bg-accent px-3 py-1 text-lg font-semibold text-white">
2<sup>{p === 0 ? k : `${k}−${p}`}</sup> · {report.design.nruns} runs · {resolutionLabel(report)}
</p>
{button}
</div>
)}
</WithHelp>
<WithHelp helpKey="guarantee">
{(button) => (
<p className="mt-2 flex items-center gap-2">
<span>{report.catalogue.statement}</span>
{button}
</p>
)}
</WithHelp>
{rows ? <p className="mt-1">{rows}</p> : null}
<div className="mt-6 grid gap-8 min-[1100px]:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
<div className="min-w-0">
<RunSheet report={report} spec={spec} />
<RCode code={code} />
</div>
<Properties report={report} frf2Messages={frf2Messages} />
</div>
</div>
);
}
Create components/steps/DesignStep.tsx:
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { DesignView } from "@/components/design/DesignView";
import type { StepProps } from "@/components/shell/StepPanel";
import { announce } from "@/lib/a11y/announcer";
import { generateCode } from "@/lib/codegen/frf2";
import { CHECK_FAILED, runFailureText } from "@/lib/report/explain";
import { resolutionLabel } from "@/lib/report/format";
import { runSheetSha256 } from "@/lib/report/sha";
import { APP_VERSION } from "@/lib/site";
import { getEngine } from "@/lib/webr/engine";
import { engineStatusText } from "@/lib/webr/status-text";
import { useEngineStatus, useNow } from "@/lib/webr/use-engine";
type RunState =
| { kind: "idle" }
| { kind: "running" }
| { kind: "failed"; message: string }
| { kind: "check_failed"; checks: string[] };
// Step 3, Design (spec 4.3, items 7 and 8; 7.8; 7.9; 9.4; decision P15). The step runs the
// design when the project has a spec and no report, shows the engine state and Cancel while R
// runs (spec 7.9: "Milestone 3 shows the same indicator in step 3"), and stores the report only
// after every post-check passes (fail closed).
export function DesignStep({ project, ready, onSave }: StepProps) {
const [state, setState] = useState<RunState>({ kind: "idle" });
const latest = useRef(project);
latest.current = project;
const started = useRef(false);
const status = useEngineStatus();
const now = useNow(status.state === "running");
const run = useCallback(async () => {
const p = latest.current;
if (!p?.spec) return;
setState({ kind: "running" });
const code = generateCode(p.spec);
const outcome = await getEngine().run(code);
if (!outcome.ok) {
const message = runFailureText(outcome);
setState({ kind: "failed", message });
announce(message, "assertive");
return;
}
if (!outcome.report.ok) {
setState({ kind: "check_failed", checks: outcome.report.failed_checks });
announce(CHECK_FAILED, "assertive");
return;
}
const engine = getEngine().getStatus();
const versions: Record<string, string> =
engine.state === "ready" ? { ...engine.versions, app: APP_VERSION } : { ...outcome.report.versions, app: APP_VERSION };
const sha = await runSheetSha256(outcome.report);
const current = latest.current ?? p;
const saved = await onSave({
...current,
report: outcome.report,
run_sheet_sha256: sha,
versions,
frf2_messages: outcome.conditions.map((c) => c.text),
r_code: code.designCode,
pending_rebuild: null,
});
setState({ kind: "idle" });
if (saved) announce(`The design is ready: ${outcome.report.design.nruns} runs, ${resolutionLabel(outcome.report)}.`);
}, [onSave]);
useEffect(() => {
if (!ready || started.current) return;
const p = latest.current;
if (p?.spec && !p.report && !p.pending_rebuild) {
started.current = true;
void run();
}
}, [ready, run]);
if (!project) return null;
if (!project.spec) return <p className="mt-3">Confirm a specification in step 2.</p>;
const busy = state.kind === "running";
return (
<div className="mt-4">
{busy ? (
<div className="rounded border border-tan-medium p-3">
<p>{engineStatusText(status, now).visible}</p>
{status.state === "running" ? <progress className="mt-2 h-2 w-40 motion-reduce:hidden" aria-label="Step 3: computing the design" /> : null}
{status.state === "running" && status.pastSoftLimit ? (
<button type="button" onClick={() => getEngine().cancel()} className="mt-2 block min-h-6 underline">
Cancel
</button>
) : null}
</div>
) : null}
{state.kind === "failed" ? (
<div className="rounded border border-accent p-3">
<p>{state.message}</p>
<button type="button" onClick={() => void run()} className="mt-2 rounded border border-accent px-3 py-1 text-accent hover:bg-tan-light">
Run again
</button>
</div>
) : null}
{state.kind === "check_failed" ? (
<div className="rounded border border-accent p-3">
<p className="font-semibold">{CHECK_FAILED}</p>
<p className="mt-1">Failed checks: {state.checks.join(", ")}</p>
</div>
) : null}
{project.report && !busy && state.kind !== "check_failed" ? (
<DesignView report={project.report} spec={project.spec} code={project.r_code ?? generateCode(project.spec).designCode} frf2Messages={project.frf2_messages} />
) : null}
</div>
);
}
In components/shell/StepPanel.tsx, add the import import { DesignStep } from "@/components/steps/DesignStep"; and replace the line {current.n === 3 || current.n === 6 ? <p className="mt-3 text-tan-dark">This step is not available in this version.</p> : null} with:
{current.n === 3 ? <DesignStep key={key} {...props} /> : null}
{current.n === 6 ? <p className="mt-3 text-tan-dark">This step comes with the exports (Milestone 3C).</p> : null}
In app/globals.css, add at the end:
/* Print gives the run sheet and the properties of step 3, without the page frame (spec 9.4). */
@media print {
header,
footer,
aside,
nav {
display: none !important;
}
}
Run: npx playwright test replication_materials/tests/e2e/design.spec.ts replication_materials/tests/e2e/engine.spec.ts replication_materials/tests/e2e/shell.spec.ts Expected: PASS. The design tests run FrF2 in WebR; each needs up to a few minutes on a first engine start.
Run: npm run check Expected: "All checks passed."
git add lib/report components/design components/steps/DesignStep.tsx components/shell/StepPanel.tsx app/globals.css replication_materials/tests/unit/sha.test.ts replication_materials/tests/unit/report-format.test.ts replication_materials/tests/unit/explain.test.ts replication_materials/tests/e2e/design.spec.ts
git commit -m "feat: add step 3, Design: the run in the browser, the run sheet with pages, the properties with computed help, and the R code (P12, P15, P18)"
16Task 9: Reports of an older version and records that cannot be opened
Files:
- Create:
lib/report/rebuild.ts,lib/projects/download.ts - Modify:
components/steps/DesignStep.tsx(whole file),components/shell/ProjectSidebar.tsx(whole file),components/shell/Workspace.tsx(whole file) - Test:
replication_materials/tests/unit/rebuild.test.ts,replication_materials/tests/e2e/records.spec.ts
Interfaces:
- Consumes:
UnreadableRecord,ProjectStore.unreadable(),ProjectStore.removeRecord()(Task 1);REPORT_VERSION(Task 1);runSheetSha256(Task 8); everything that Task 8'sDesignStepconsumes. - Produces:
- In
lib/report/rebuild.ts:rebuildNote(from: string),REBUILT,DIFFERS,versionChanges(stored, now): string[]. - In
lib/projects/download.ts:downloadJson(filename, value),recordFileName(key). Milestone 3C can usedownloadJsonfor the project file. ProjectSidebarprops addunreadable: UnreadableRecord[],onExportRecord(record),onDeleteRecord(key).
- In
Decision P1 in step 3: a project with pending_rebuild runs its stored spec and seed again. If the new run sheet has the stored SHA-256, the new report replaces the old one and the step says so. If not, the step shows the versions that differ and stores nothing, so the old record stays as it was.
Create replication_materials/tests/unit/rebuild.test.ts:
import { describe, expect, it } from "vitest";
import { recordFileName } from "@/lib/projects/download";
import { DIFFERS, REBUILT, rebuildNote, versionChanges } from "@/lib/report/rebuild";
describe("rebuild of an older report (decision P1)", () => {
it("states what happens, as facts", () => {
expect(rebuildNote("1.1.0")).toBe("This project has a report of version 1.1.0. The tool makes the report again (version 1.2.0) from the stored specification and seed.");
expect(REBUILT).toBe("The report was updated to version 1.2.0. The rebuilt design matches the stored run sheet (SHA-256).");
expect(DIFFERS).toBe('The rebuilt run sheet differs from the stored run sheet (SHA-256). The stored project is not changed. "Generate design" in step 2 makes a new design with the current versions.');
});
it("names the software versions that differ", () => {
const stored = { R: "4.6.0", webR: "0.6.0", FrF2: "2.3.5", "DoE.base": "1.2.5" };
expect(versionChanges(stored, { ...stored, FrF2: "2.3.6" })).toEqual(["FrF2: 2.3.5 when stored, 2.3.6 now"]);
expect(versionChanges(stored, stored)).toEqual([]);
expect(versionChanges(null, stored)).toEqual(["The stored project has no record of its software versions."]);
});
});
describe("export of a stored record (decision P19)", () => {
it("makes a safe file name from the record key", () => {
expect(recordFileName("old-1")).toBe("record-old-1.json");
expect(recordFileName("a/b c")).toBe("record-a_b_c.json");
expect(recordFileName(42)).toBe("record-42.json");
});
});
Create replication_materials/tests/e2e/records.spec.ts:
import { expect, test, type Page } from "@playwright/test";
import { loadFixture, routeTranslate } from "./helpers/translate-fixtures";
/** Writes records into the project database, as an earlier version of the app or a damaged database would. */
async function putRecords(page: Page, records: unknown[]) {
await page.evaluate(async (values) => {
const db = await new Promise<IDBDatabase>((resolve, reject) => {
const req = indexedDB.open("ffd-projects", 1);
req.onupgradeneeded = () => req.result.createObjectStore("projects", { keyPath: "id" });
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
await new Promise<void>((resolve, reject) => {
const tx = db.transaction("projects", "readwrite");
for (const value of values) tx.objectStore("projects").put(value);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
db.close();
}, records);
}
async function storedProjects(page: Page): Promise<Record<string, unknown>[]> {
return page.evaluate(async () => {
const db = await new Promise<IDBDatabase>((resolve, reject) => {
const req = indexedDB.open("ffd-projects", 1);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
const all = await new Promise<Record<string, unknown>[]>((resolve, reject) => {
const req = db.transaction("projects").objectStore("projects").getAll();
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
db.close();
return all;
});
}
/** Makes the stored report look like report version 1.1.0, which had no all_2fis_clear. */
async function makeOlder(page: Page, otherSheet: boolean) {
const [project] = await storedProjects(page);
const { all_2fis_clear: _dropped, ...report } = project.report as Record<string, unknown>;
await putRecords(page, [{ ...project, report: { ...report, report_version: "1.1.0" }, ...(otherSheet ? { run_sheet_sha256: "0".repeat(64) } : {}) }]);
}
test("a stored record that matches no schema is listed with Export and Delete, never hidden (Review Focus 2)", async ({ page }) => {
await page.goto("/app");
await putRecords(page, [{ id: "old-1", name: "Old notes", text: "no schema" }]);
await page.reload();
await expect(page.getByRole("heading", { name: "Records that cannot be opened" })).toBeVisible();
await expect(page.getByText("1 stored record does not match the project format of this version.")).toBeVisible();
const download = page.waitForEvent("download");
await page.getByRole("button", { name: "Export: Old notes" }).click();
expect((await download).suggestedFilename()).toBe("record-old-1.json");
await page.getByRole("button", { name: "Delete: Old notes" }).click();
await expect(page.getByText("Delete this record? This cannot be undone.")).toBeVisible();
await page.getByRole("button", { name: "Delete record" }).click();
await expect(page.getByRole("heading", { name: "Records that cannot be opened" })).toHaveCount(0);
});
test("a report of an older version is made again, and replaces the old report only when the run sheet matches (decision P1)", async ({ page }) => {
test.setTimeout(600_000);
const coating = loadFixture("coating-design");
await routeTranslate(page);
await page.goto("/app");
await page.getByLabel("Describe the experiment").fill(coating.request.turns[0].text);
await page.getByRole("button", { name: "Interpret request" }).click();
await page.getByRole("button", { name: "Generate design" }).click();
const main = page.getByRole("main");
await expect(main.getByText(/16 runs · Resolution IV$/)).toBeVisible({ timeout: 240_000 });
await makeOlder(page, false);
await page.reload();
await expect(main.getByText("The report was updated to version 1.2.0. The rebuilt design matches the stored run sheet (SHA-256).")).toBeVisible({ timeout: 240_000 });
await expect(main.getByText(/16 runs · Resolution IV$/)).toBeVisible();
expect(((await storedProjects(page))[0].report as { report_version: string }).report_version).toBe("1.2.0");
await makeOlder(page, true);
await page.reload();
await expect(main.getByText(/^The rebuilt run sheet differs from the stored run sheet \(SHA-256\)\./)).toBeVisible({ timeout: 240_000 });
await expect(main.getByRole("table")).toHaveCount(0);
expect(((await storedProjects(page))[0].report as { report_version: string }).report_version).toBe("1.1.0");
await expect(page.getByRole("button", { name: /Report to update/ })).toBeVisible();
});
Run: npx vitest run --project unit replication_materials/tests/unit/rebuild.test.ts Expected: FAIL. @/lib/report/rebuild and @/lib/projects/download do not exist.
Run: npx playwright test replication_materials/tests/e2e/records.spec.ts Expected: FAIL. The sidebar has no "Records that cannot be opened" section, and step 3 does not rebuild a pending report.
Create lib/report/rebuild.ts:
import { REPORT_VERSION } from "./types";
// The texts of the rebuild of an older report (decision P1). Facts only (spec 9.7).
export function rebuildNote(from: string): string {
return `This project has a report of version ${from}. The tool makes the report again (version ${REPORT_VERSION}) from the stored specification and seed.`;
}
export const REBUILT = `The report was updated to version ${REPORT_VERSION}. The rebuilt design matches the stored run sheet (SHA-256).`;
export const DIFFERS =
'The rebuilt run sheet differs from the stored run sheet (SHA-256). The stored project is not changed. "Generate design" in step 2 makes a new design with the current versions.';
const KEYS = ["R", "webR", "FrF2", "DoE.base"] as const;
/** The software versions that differ between the stored project and this version (spec 8.5). */
export function versionChanges(stored: Record<string, string> | null, now: Record<string, string>): string[] {
if (!stored) return ["The stored project has no record of its software versions."];
return KEYS.filter((k) => stored[k] !== now[k]).map((k) => `${k}: ${stored[k] ?? "not recorded"} when stored, ${now[k] ?? "not recorded"} now`);
}
Create lib/projects/download.ts:
// Downloads made in the browser (decision P19). Nothing leaves the device: the file is made
// from a Blob URL.
/** The file name of an exported record: its key, with characters other than letters, digits, "_" and "-" replaced. */
export function recordFileName(key: IDBValidKey): string {
return `record-${String(key).replace(/[^A-Za-z0-9_-]/g, "_")}.json`;
}
export function downloadJson(filename: string, value: unknown): void {
const blob = new Blob([`${JSON.stringify(value, null, 2)}\n`], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.append(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 0);
}
Replace components/steps/DesignStep.tsx with:
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { DesignView } from "@/components/design/DesignView";
import type { StepProps } from "@/components/shell/StepPanel";
import { announce } from "@/lib/a11y/announcer";
import { generateCode } from "@/lib/codegen/frf2";
import { CHECK_FAILED, runFailureText } from "@/lib/report/explain";
import { resolutionLabel } from "@/lib/report/format";
import { DIFFERS, REBUILT, rebuildNote, versionChanges } from "@/lib/report/rebuild";
import { runSheetSha256 } from "@/lib/report/sha";
import { APP_VERSION } from "@/lib/site";
import { getEngine } from "@/lib/webr/engine";
import { engineStatusText } from "@/lib/webr/status-text";
import { useEngineStatus, useNow } from "@/lib/webr/use-engine";
type RunState =
| { kind: "idle" }
| { kind: "running" }
| { kind: "failed"; message: string }
| { kind: "check_failed"; checks: string[] }
| { kind: "rebuilt" }
| { kind: "differs"; changes: string[] };
// Step 3, Design (spec 4.3, items 7 and 8; 7.8; 7.9; 8.5; 9.4; decisions P1 and P15). The step
// runs the design when the project has a spec and no report, shows the engine state and Cancel
// while R runs, and stores the report only after every post-check passes (fail closed). A report
// of an older version is made again from the stored spec and seed; it replaces the stored report
// only when the run sheet has the stored SHA-256.
export function DesignStep({ project, ready, onSave }: StepProps) {
const [state, setState] = useState<RunState>({ kind: "idle" });
const latest = useRef(project);
latest.current = project;
const started = useRef(false);
const status = useEngineStatus();
const now = useNow(status.state === "running");
const run = useCallback(async () => {
const p = latest.current;
if (!p?.spec) return;
const rebuild = p.pending_rebuild !== null;
setState({ kind: "running" });
const code = generateCode(p.spec);
const outcome = await getEngine().run(code);
if (!outcome.ok) {
const message = runFailureText(outcome);
setState({ kind: "failed", message });
announce(message, "assertive");
return;
}
if (!outcome.report.ok) {
setState({ kind: "check_failed", checks: outcome.report.failed_checks });
announce(CHECK_FAILED, "assertive");
return;
}
const engine = getEngine().getStatus();
const versions: Record<string, string> =
engine.state === "ready" ? { ...engine.versions, app: APP_VERSION } : { ...outcome.report.versions, app: APP_VERSION };
const sha = await runSheetSha256(outcome.report);
if (rebuild && sha !== p.run_sheet_sha256) {
setState({ kind: "differs", changes: versionChanges(p.versions, versions) });
announce(DIFFERS, "assertive");
return;
}
const current = latest.current ?? p;
const saved = await onSave({
...current,
report: outcome.report,
run_sheet_sha256: sha,
versions,
frf2_messages: outcome.conditions.map((c) => c.text),
r_code: code.designCode,
pending_rebuild: null,
});
setState(saved && rebuild ? { kind: "rebuilt" } : { kind: "idle" });
if (saved) announce(rebuild ? REBUILT : `The design is ready: ${outcome.report.design.nruns} runs, ${resolutionLabel(outcome.report)}.`);
}, [onSave]);
useEffect(() => {
if (!ready || started.current) return;
const p = latest.current;
if (p?.spec && !p.report) {
started.current = true;
void run();
}
}, [ready, run]);
if (!project) return null;
if (!project.spec) return <p className="mt-3">Confirm a specification in step 2.</p>;
const busy = state.kind === "running";
return (
<div className="mt-4 space-y-3">
{project.pending_rebuild && state.kind !== "differs" ? (
<p className="rounded border border-tan-medium bg-tan-light p-3">{rebuildNote(project.pending_rebuild.report_version)}</p>
) : null}
{busy ? (
<div className="rounded border border-tan-medium p-3">
<p>{engineStatusText(status, now).visible}</p>
{status.state === "running" ? <progress className="mt-2 h-2 w-40 motion-reduce:hidden" aria-label="Step 3: computing the design" /> : null}
{status.state === "running" && status.pastSoftLimit ? (
<button type="button" onClick={() => getEngine().cancel()} className="mt-2 block min-h-6 underline">
Cancel
</button>
) : null}
</div>
) : null}
{state.kind === "failed" ? (
<div className="rounded border border-accent p-3">
<p>{state.message}</p>
<button type="button" onClick={() => void run()} className="mt-2 rounded border border-accent px-3 py-1 text-accent hover:bg-tan-light">
Run again
</button>
</div>
) : null}
{state.kind === "check_failed" ? (
<div className="rounded border border-accent p-3">
<p className="font-semibold">{CHECK_FAILED}</p>
<p className="mt-1">Failed checks: {state.checks.join(", ")}</p>
</div>
) : null}
{state.kind === "differs" ? (
<div className="rounded border border-accent p-3">
<p>{DIFFERS}</p>
{state.changes.length > 0 ? (
<ul className="mt-1 list-disc pl-5">
{state.changes.map((change) => (
<li key={change}>{change}</li>
))}
</ul>
) : null}
</div>
) : null}
{state.kind === "rebuilt" ? <p className="rounded border border-tan-medium bg-tan-light p-3">{REBUILT}</p> : null}
{project.report && !busy && state.kind !== "check_failed" ? (
<DesignView report={project.report} spec={project.spec} code={project.r_code ?? generateCode(project.spec).designCode} frf2Messages={project.frf2_messages} />
) : null}
</div>
);
}
Replace components/shell/ProjectSidebar.tsx with:
"use client";
import { useState } from "react";
import { designSummary, formatBytes, formatDate } from "@/lib/projects/format";
import type { Project } from "@/lib/projects/schema";
import type { UnreadableRecord } from "@/lib/projects/store";
interface Props {
projects: Project[];
unreadable: UnreadableRecord[];
currentId: string | null;
persistent: boolean | null;
error: string | null;
sizeBytes: number;
onNew: () => void;
onOpen: (id: string) => void;
onClear: () => void;
onExportRecord: (record: UnreadableRecord) => void;
onDeleteRecord: (key: IDBValidKey) => void;
}
// The sidebar (spec 8.2 and 9.1): "New design", the projects on this device, the records that
// cannot be opened (decision P19: never hidden) and the storage use. "New design" is disabled
// until the store is open (persistent is null before). "Clear" and "Delete" ask for
// confirmation in the page. At narrow widths the sidebar is a menu. "Export all" and "Import"
// come with the exports in Milestone 3C.
export function ProjectSidebar({ projects, unreadable, currentId, persistent, error, sizeBytes, onNew, onOpen, onClear, onExportRecord, onDeleteRecord }: Props) {
const [open, setOpen] = useState(false);
const [confirming, setConfirming] = useState(false);
const [deleting, setDeleting] = useState<IDBValidKey | null>(null);
const n = projects.length;
const u = unreadable.length;
return (
<aside aria-label="Projects" className="md:w-72 md:shrink-0">
<button
type="button"
className="mb-3 rounded border border-tan-medium px-3 py-2 md:hidden"
aria-expanded={open}
aria-controls="project-panel"
onClick={() => setOpen(!open)}
>
Projects
</button>
<div id="project-panel" className={`${open ? "block" : "hidden"} md:block`}>
<button
type="button"
onClick={onNew}
disabled={persistent === null}
className="w-full rounded bg-accent px-3 py-2 font-semibold text-white hover:bg-accent-dark disabled:opacity-60"
>
New design
</button>
<h2 className="mt-6 font-display text-lg">Your projects</h2>
<p className="text-sm text-tan-dark">On this device only.</p>
{persistent === false ? (
<p className="mt-2 rounded border border-tan-medium bg-tan-light p-2 text-sm">
This browser does not allow storage. Projects last only for this session.
</p>
) : null}
{error ? <p className="mt-2 rounded border border-accent p-2 text-sm">{error}</p> : null}
{n === 0 ? (
<p className="mt-3 text-sm">No projects yet.</p>
) : (
<ul className="mt-3 space-y-1">
{projects.map((p) => (
<li key={p.id}>
<button
type="button"
onClick={() => onOpen(p.id)}
aria-current={p.id === currentId ? "page" : undefined}
className={`w-full rounded px-2 py-1 text-left ${p.id === currentId ? "bg-tan-light font-semibold" : "hover:bg-tan-light"}`}
>
<span className="block">{p.name}</span>
<span className="block text-xs text-tan-dark">{designSummary(p)} · {formatDate(p.created_at)}</span>
</button>
</li>
))}
</ul>
)}
{u > 0 ? (
<section aria-labelledby="unreadable-heading" className="mt-6">
<h2 id="unreadable-heading" className="font-display text-lg">Records that cannot be opened</h2>
<p className="text-sm">
{u === 1 ? "1 stored record does not match the project format of this version." : `${u} stored records do not match the project format of this version.`}
</p>
<ul className="mt-2 space-y-2 text-sm">
{unreadable.map((r) => {
const name = r.name ?? "Unnamed record";
return (
<li key={String(r.key)}>
<span className="block">{name}</span>
{deleting === r.key ? (
<div role="group" aria-label={`Delete ${name}`} className="mt-1 rounded border border-accent p-2">
<p>Delete this record? This cannot be undone.</p>
<div className="mt-2 flex gap-2">
<button type="button" className="rounded bg-accent px-3 py-1 text-white" onClick={() => { setDeleting(null); onDeleteRecord(r.key); }}>
Delete record
</button>
<button type="button" autoFocus className="rounded border border-tan-medium px-3 py-1" onClick={() => setDeleting(null)}>
Keep record
</button>
</div>
</div>
) : (
<span className="flex gap-3">
<button type="button" aria-label={`Export: ${name}`} className="min-h-6 underline" onClick={() => onExportRecord(r)}>
Export
</button>
<button type="button" aria-label={`Delete: ${name}`} className="min-h-6 underline" onClick={() => setDeleting(r.key)}>
Delete
</button>
</span>
)}
</li>
);
})}
</ul>
</section>
) : null}
<h2 className="mt-6 font-display text-lg">Storage</h2>
<p className="text-sm">{n} {n === 1 ? "project" : "projects"} · {formatBytes(sizeBytes)}</p>
{confirming ? (
<div role="group" aria-labelledby="clear-question" className="mt-2 rounded border border-accent p-2">
<p id="clear-question" className="text-sm">
{n === 1 ? "Delete the project on this device?" : `Delete the ${n} projects on this device?`}
{u > 0 ? ` The ${u === 1 ? "record" : `${u} records`} that cannot be opened ${u === 1 ? "is" : "are"} deleted too.` : ""} This cannot be undone.
</p>
<div className="mt-2 flex gap-2">
<button type="button" className="rounded bg-accent px-3 py-1 text-white" onClick={() => { setConfirming(false); onClear(); }}>
Delete all
</button>
<button type="button" autoFocus className="rounded border border-tan-medium px-3 py-1" onClick={() => setConfirming(false)}>
Keep projects
</button>
</div>
</div>
) : (
<button type="button" className="mt-2 min-h-6 text-sm underline disabled:text-tan-dark" onClick={() => setConfirming(true)} disabled={n === 0 && u === 0}>
Clear
</button>
)}
</div>
</aside>
);
}
Replace components/shell/Workspace.tsx with:
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { HelpProvider } from "@/components/help/HelpContext";
import { announce } from "@/lib/a11y/announcer";
import { downloadJson, recordFileName } from "@/lib/projects/download";
import { projectsSize } from "@/lib/projects/format";
import { writeLastPage } from "@/lib/projects/pointer";
import { newProject, type Project } from "@/lib/projects/schema";
import { openProjectStore, type ProjectStore, type UnreadableRecord } from "@/lib/projects/store";
import { stepFromQuery } from "@/lib/steps";
import { ProjectSidebar } from "./ProjectSidebar";
import { SiteFooter } from "./SiteFooter";
import { SiteHeader } from "./SiteHeader";
import { SkipLink } from "./SkipLink";
import { StepPanel } from "./StepPanel";
import { StepRail } from "./StepRail";
// A store call that fails after the store opened (for example a full disk) shows this text
// in the sidebar and tells the global announcer (spec 8.2: no silent failure; spec 9.10).
const STORE_ERROR = "The project could not be stored on this device.";
// The workspace (spec 9.1): header, sidebar, step rail, the current step and the footer.
// The address holds the state: /app?project=<id>&step=<n>. The steps store the project
// through onSave.
export function Workspace({ privacyNotice }: { privacyNotice: string }) {
const params = useSearchParams();
const router = useRouter();
const step = stepFromQuery(params.get("step"));
const projectId = params.get("project");
const [store, setStore] = useState<ProjectStore | null>(null);
const [projects, setProjects] = useState<Project[]>([]);
const [unreadable, setUnreadable] = useState<UnreadableRecord[]>([]);
const [storeError, setStoreError] = useState<string | null>(null);
const failStore = useCallback(() => {
setStoreError(STORE_ERROR);
announce(STORE_ERROR, "assertive");
}, []);
useEffect(() => {
let live = true;
openProjectStore()
.then(async (opened) => {
const [list, broken] = await Promise.all([opened.list(), opened.unreadable()]);
if (live) {
setStore(opened);
setProjects(list);
setUnreadable(broken);
}
})
.catch(() => {
if (live) failStore();
});
return () => {
live = false;
};
}, [failStore]);
// The pointer for the next visit to / (spec 9.2).
useEffect(() => {
writeLastPage(`/app${window.location.search}`);
}, [params]);
const go = useCallback(
(project: string | null, nextStep: number) => {
const query = new URLSearchParams();
if (project) query.set("project", project);
query.set("step", String(nextStep));
router.push(`/app?${query.toString()}`);
},
[router],
);
/** Stores a project with the time of the change. False after a storage failure. */
const saveProject = useCallback(
async (project: Project): Promise<boolean> => {
if (!store) return false;
try {
await store.put({ ...project, updated_at: new Date().toISOString() });
setProjects(await store.list());
setStoreError(null);
return true;
} catch {
failStore();
return false;
}
},
[store, failStore],
);
async function newDesign() {
const project = newProject(new Date(), crypto.randomUUID(), Intl.DateTimeFormat().resolvedOptions().timeZone);
if (await saveProject(project)) go(project.id, 1);
}
async function clearAll() {
if (!store) return;
try {
await store.clear();
setProjects([]);
setUnreadable([]);
setStoreError(null);
} catch {
failStore();
return;
}
go(null, 1);
}
async function deleteRecord(key: IDBValidKey) {
if (!store) return;
try {
await store.removeRecord(key);
setUnreadable(await store.unreadable());
announce("The record was deleted.");
} catch {
failStore();
}
}
const current = projects.find((p) => p.id === projectId) ?? null;
return (
<HelpProvider>
<div className="flex min-h-screen flex-col">
<SkipLink />
<SiteHeader project={current?.name ?? null} />
<div className="mx-auto flex w-full max-w-7xl flex-1 flex-col gap-8 px-4 py-6 md:flex-row">
<ProjectSidebar
projects={projects}
unreadable={unreadable}
currentId={current?.id ?? null}
persistent={store ? store.persistent : null}
error={storeError}
sizeBytes={projectsSize(projects)}
onNew={newDesign}
onOpen={(id) => go(id, projects.find((p) => p.id === id)?.last_step ?? 1)}
onClear={clearAll}
onExportRecord={(record) => downloadJson(recordFileName(record.key), record.raw)}
onDeleteRecord={deleteRecord}
/>
<main id="main" tabIndex={-1} className="min-w-0 flex-1">
<h1 className="sr-only">Workspace</h1>
<StepRail current={step} onSelect={(n) => go(current?.id ?? null, n)} />
<StepPanel step={step} project={current} ready={store !== null} privacyNotice={privacyNotice} onSave={saveProject} go={go} />
</main>
</div>
<SiteFooter />
</div>
</HelpProvider>
);
}
Run: npx vitest run --project unit replication_materials/tests/unit/rebuild.test.ts Expected: PASS.
Run: npx playwright test replication_materials/tests/e2e/records.spec.ts replication_materials/tests/e2e/shell.spec.ts Expected: PASS.
Run: npm run check Expected: "All checks passed."
git add lib/report/rebuild.ts lib/projects/download.ts components/steps/DesignStep.tsx components/shell/ProjectSidebar.tsx components/shell/Workspace.tsx replication_materials/tests/unit/rebuild.test.ts replication_materials/tests/e2e/records.spec.ts
git commit -m "feat: rebuild a report of an older version from the spec and seed, and list the records that cannot be opened (P1, P19)"
17Task 10: The privacy page and its links
Files:
- Create:
app/privacy/page.tsx - Modify:
app/page.tsx(the "Details" link),components/steps/DescribeStep.tsx(the "Privacy details" link) - Test:
replication_materials/tests/e2e/privacy.spec.ts
Interfaces:
- Consumes:
currentPrivacyNotice(lib/ops/privacy.ts);SiteHeader,SiteFooter,SkipLink. - Produces: the page
/privacy.
The page states only facts that the project has checked: the server pipeline and log (spec 5.7 and 13.1, 3A), the rate limit (3A decision P11), OpenAI's store: false (spec 5.8), the TypeSafe terms that 3A read on 2026-10-15 (3A plan, "Facts measured during planning"), and the names of the local stores. It makes no claim about the retention of Anthropic, Google or OpenAI beyond "the API terms of the provider apply".
Create replication_materials/tests/e2e/privacy.spec.ts:
import { expect, test } from "@playwright/test";
import { ENSEMBLE_NOTICE } from "@/lib/ops/privacy";
import { APP_NAME } from "@/lib/site";
test("the landing page links to the privacy details: what leaves the device and what stays (spec 13.3)", async ({ page }) => {
await page.goto("/?home");
await page.getByRole("link", { name: "Details" }).click();
await expect(page).toHaveURL(/\/privacy$/);
await expect(page).toHaveTitle(`Privacy | ${APP_NAME}`);
await expect(page.getByRole("heading", { level: 1, name: "Privacy" })).toBeVisible();
await expect(page.getByText(ENSEMBLE_NOTICE)).toBeVisible();
await expect(page.getByText(/perpetual right to derive telemetry, including "learnings"/)).toBeVisible();
await expect(page.getByText(/The log has no request text and no IP address\./)).toBeVisible();
await expect(page.getByText(/^IndexedDB database "ffd-projects"/)).toBeVisible();
await expect(page.getByText(/"ffd:last-page"/)).toBeVisible();
});
test("step 1 links to the privacy details next to the notice (spec 13.3)", async ({ page }) => {
await page.goto("/app");
await expect(page.getByRole("link", { name: "Privacy details" })).toHaveAttribute("href", "/privacy");
});
Run: npx playwright test replication_materials/tests/e2e/privacy.spec.ts Expected: FAIL. The landing page has no "Details" link, and step 1 has no "Privacy details" link.
Create app/privacy/page.tsx:
import type { Metadata } from "next";
import { SiteFooter } from "@/components/shell/SiteFooter";
import { SiteHeader } from "@/components/shell/SiteHeader";
import { SkipLink } from "@/components/shell/SkipLink";
import { currentPrivacyNotice } from "@/lib/ops/privacy";
export const metadata: Metadata = { title: "Privacy" };
// The privacy details (spec 13.3 and 17.1): what leaves the device, what the server keeps, what
// the terms of each provider allow, and what stays on the device. Facts only (spec 9.7).
export default function PrivacyPage() {
return (
<div className="flex min-h-screen flex-col">
<SkipLink />
<SiteHeader project={null} />
<main id="main" tabIndex={-1} className="mx-auto w-full max-w-3xl flex-1 px-4 py-10">
<h1 className="font-display text-4xl">Privacy</h1>
<p className="mt-4">{currentPrivacyNotice()}</p>
<h2 className="mt-10 font-display text-2xl">What leaves this device</h2>
<ul className="mt-3 list-disc space-y-2 pl-5">
<li>"Interpret request" sends the request text and the answers to clarification questions to the server of this tool. The server sends them to the providers named above. Nothing else leaves the device during design work.</li>
<li>"Fill in the form yourself" sends nothing. The tool works without the translation service.</li>
<li>The design, the run sheet, the report and the R code are made in the browser. They do not leave the device.</li>
</ul>
<h2 className="mt-10 font-display text-2xl">What the server keeps</h2>
<ul className="mt-3 list-disc space-y-2 pl-5">
<li>The server keeps no request text. It has no accounts and no database.</li>
<li>For each interpretation, the log has one line: the time, the outcome, the mode, the tokens, the duration, the cost, the model ids, and the versions of the prompt, the schema and the Jev questions. The log has no request text and no IP address.</li>
<li>The rate limit counts the requests of each IP address in memory, for one minute and for one day (UTC). The address is not written to the log.</li>
</ul>
<h2 className="mt-10 font-display text-2xl">The providers</h2>
<ul className="mt-3 list-disc space-y-2 pl-5">
<li>OpenAI: the tool sends each request with the option store set to false, so OpenAI does not keep the response for later retrieval. The OpenAI API terms apply.</li>
<li>Anthropic and Google: the API terms of each provider apply.</li>
<li>TypeSafe (Jev): TypeSafe states that it does not train on inputs and that it hosts data in the United States. It offers zero data retention only to enterprise customers. The TypeSafe Master Customer Agreement (Sections 4.1 and 4.3) gives TypeSafe a perpetual right to derive telemetry, including "learnings", from requests, and to use it without restriction.</li>
</ul>
<h2 className="mt-10 font-display text-2xl">What stays on this device</h2>
<ul className="mt-3 list-disc space-y-2 pl-5">
<li>IndexedDB database "ffd-projects": the projects (the request, the card, the design and the report). "Clear" in the sidebar deletes them.</li>
<li>IndexedDB: the R packages of the R engine (WebR), so that a later visit downloads nothing, and the empty database "ffd-probe", which tests that IndexedDB works.</li>
<li>localStorage: "ffd:last-page" (the last page of the workspace, which a later visit opens) and "ffd:show-explanations" (the "Show explanations" switch).</li>
<li>The tool sets no cookies and loads no analytics.</li>
</ul>
<p className="mt-3">Without storage, for example in private browsing, projects last only for the session.</p>
<h2 className="mt-10 font-display text-2xl">Later versions</h2>
<p className="mt-3">Phase 2 adds "Explain this chart with AI", off by default. Before the first request, a dialog shows the exact data to be sent and the provider.</p>
</main>
<SiteFooter />
</div>
);
}
In app/page.tsx, replace <p className="mt-3">{currentPrivacyNotice()}</p> with:
<p className="mt-3">
{currentPrivacyNotice()} <a href="/privacy" className="underline">Details</a>
</p>
In components/steps/DescribeStep.tsx, replace <p className="text-sm">{privacyNotice}</p> with:
<p className="text-sm">
{privacyNotice} <a href="/privacy" className="underline">Privacy details</a>
</p>
Run: npx playwright test replication_materials/tests/e2e/privacy.spec.ts replication_materials/tests/e2e/shell.spec.ts replication_materials/tests/e2e/describe.spec.ts Expected: PASS. The notice checks of shell.spec.ts and describe.spec.ts match the notice inside the longer paragraph.
Run: npm run check Expected: "All checks passed."
git add app/privacy/page.tsx app/page.tsx components/steps/DescribeStep.tsx replication_materials/tests/e2e/privacy.spec.ts
git commit -m "feat: add the privacy details page, with the TypeSafe terms and the local stores, and link it (spec 13.3)"
18Task 11: Specification version 4, the process record and the README
Files:
- Modify:
replication_materials/plans/2026-10-14-ff-design-translator-design.md(version 4),scripts/build-spec-page.mjs(ribbon and description),replication_materials/plans/spec-page/(rebuilt) - Modify:
r/ffd_report.R(one comment, pre-3B minor 6),replication_materials/process/process.json(event e25),README.md - Test:
npm run check(the R comment needsnpm run setup:runtimesfirst)
Interfaces: none. This task records Tasks 1 to 10 in the documents.
Each change below is an exact replacement: the first block is the text as it is, the second block is the new text. Make each change with the Edit tool. If an old text is not found, the spec changed after this plan; stop and record a ruling.
In the spec, replace
| Version | 3, dated 2026-10-16. Version 2 is dated 2026-10-15 and version 1 is dated 2026-10-14. Version 2 applies the review decisions R1 to R17. Version 3 applies R18 to R23, from the review by Alan R. Vazquez. |
with
| Version | 4, dated 2026-10-16. Version 3 is dated 2026-10-16, version 2 is dated 2026-10-15 and version 1 is dated 2026-10-14. Version 2 applies the review decisions R1 to R17. Version 3 applies R18 to R23, from the review by Alan R. Vazquez. Version 4 records the decisions of the Milestone 3B plan (`2026-10-16-m3b-interface-plan.md`, P1 to P20). |
Replace
Published studies show that these principles hold in practice (references: Section 17.1).
with
Wu and Hamada (2021, Section 4.6) state the three principles. Box and Meyer (1986) is the source of effect sparsity, and Hamada and Wu (1992) of effect heredity. Published studies show that the principles hold in practice (Li, Sudarsanam and Frey, 2006; Ockuly et al., 2017). Alan R. Vazquez confirms this list (decision P4 of the Milestone 3B plan).
Replace
- The glossary (Section 9.5) has entries for effect hierarchy, effect sparsity, effect heredity and clear 2FI.
with
- The glossary (Section 9.5) has entries for effect hierarchy, effect sparsity, effect heredity and clear 2FI. Claude drafted them in Milestone 3B; each shows "Draft, not yet reviewed" until Alan R. Vazquez reviews it (decision P3).
Replace
10. Return the result with `prompt_version`, `schema_version`, the mode and the model id that each provider returned.
with
10. Return the result with `prompt_version`, `schema_version`, `jev_questions_version`, the mode, the voters of the mode that were left out before the call (`left_out`) and the model id that each provider returned.
Replace
| Logs | Time, outcome, mode, tokens, latency, cost, model ids, prompt version. No request text. |
with
| Logs | Time, outcome, mode, tokens, latency, cost, model ids, and the versions of the prompt, the schema and the Jev questions. No request text and no IP address. |
Replace
| `nfactors` or the total runs are outside the supported limits. | "This version supports at most {limit}." |
with
| `nfactors` is above the catalogue (160 factors), or the rows $(N + c) \times r$ are above `max_total_rows`. | "This version supports at most {limit} factors." or "This version supports at most {limit} rows. This design has {rows} rows." |
| `nruns` is given, and the catalogue has no entry for this number of factors in this run size (for example 40 factors in 1024 runs; FrF2 stops with "subscript out of bounds"). | "This version supports at most {limit} factors in {nruns} runs." (decision P8 of the Milestone 3B plan) |
| The catalogue entry that FrF2 selects has the status `broken` (7 default sizes; FrF2 uses `26-17.1` for 26 factors in 512 runs without a message). | "The FrF2 catalogue entry {entry} for {m} factors in {nruns} runs fails the checks of this tool." (decision P8) |
Replace
- Codegen never passes `nruns` and `resolution` together to FrF2.
with
- Codegen never passes `nruns` and `resolution` together to FrF2.
- The rules are in `lib/spec/precheck.ts`. `replication_materials/tests/fixtures/precheck-cases.json` has one input for each rule: a unit test checks the messages, and a native R test checks the FrF2 behaviour (Milestone 3B).
Replace
interactions of three or more factors are not looked at (FrF2's definition; Section 1.8).
with
interactions of three or more factors are not taken into account (FrF2's definition; Section 1.8).
Replace
A hard limit (default 10 minutes, set in config) stops a worker that does not respond. Milestone 3 shows the same indicator in step 3.
with
A hard limit (default 10 minutes, set in config) stops a worker that does not respond. Step 3 shows the same indicator and Cancel (Milestone 3B).
Replace
- IndexedDB holds the projects.
with
- IndexedDB holds the projects. A write is complete only when its transaction completes, and the connection closes when another tab opens a newer version of the database.
- A stored record that matches no schema is listed in the sidebar as "cannot be opened", with Export and Delete. It is never hidden (decision P19 of the Milestone 3B plan).
- A report with another report version is made again from the stored spec and seed in step 3. It replaces the stored report only if the new run sheet has the stored SHA-256; otherwise the stored project stays unchanged and step 3 names the versions that differ (decision P1).
Replace
- A test checks that each file is the same from each place where it is offered.
with
- A test checks that each file is the same from each place where it is offered.
- The SHA-256 of a run sheet is the SHA-256 of the UTF-8 text `JSON.stringify({ labels, run, std, levels, coded })` of the report (`lib/report/sha.ts`, decision P12).
Replace
"Clear" does not look at interactions of three or more factors (hierarchy principle, Section 1.8).
with
"Clear" does not take interactions of three or more factors into account (hierarchy principle, Section 1.8).
Replace
- **Sources:** `content/glossary.json` (fields `key`, `term`, `text`, `reference`, `reviewed_by`, `reviewed_on`) and `lib/report/explain.ts` (fixed sentence templates). No LLM writes help text.
with
- **Sources:** `content/glossary.json` (fields `key`, `term`, `text`, `reference`, `reviewed_by`, `reviewed_on`) and `lib/report/explain.ts` (fixed sentence templates). No LLM writes help text. `scripts/build-glossary.mjs` builds the MathML into `data/glossary.json` (decision P16). An entry without a reviewer shows "Draft, not yet reviewed" (decision P3). Until the video exists, a panel has layers 1 and 2 and a link to the glossary entry (decision P13).
Replace
- Skip link. One global announcer (ChatISA pattern).
with
- Skip link. One global announcer (ChatISA pattern): the markup of the app has one polite and one assertive live region (`components/a11y/Announcer.tsx`). The route announcer of Next.js, in a shadow root, reads only the page title after a navigation.
Replace
each `?` key has a reviewed glossary entry;
with
each `?` key has a glossary entry, and the built glossary equals its source (each entry has a reviewer: release gate with `FFD_RELEASE=1`, decision P3);
Replace
| Review of the glossary (Section 9.5). | Alan R. Vazquez | Milestone 3 |
with
| Review of the glossary (Section 9.5): 35 entries drafted in Milestone 3B, each marked "Draft, not yet reviewed" until reviewed. | Alan R. Vazquez | Public release (release gate, decision P3) |
Delete each of these three rows, which are done in Milestone 3B (they are not next to each other in the table):
| The link from the landing-page Privacy block to the privacy details (Section 13.3). The details page does not exist yet. | Fadel M. Megahed | Milestone 3 |
| One global announcer (Section 9.10). In Milestone 2 the engine status has its own polite live region; Milestone 3 moves it into the global announcer, and shows the computing indicator in step 3 too. | Fadel M. Megahed | Milestone 3 |
| The privacy page states TypeSafe's right to derive telemetry and "learnings" from requests (Section 3.3). | Fadel M. Megahed | Milestone 3B |
In the row that starts with | Download progress for WebR's own files during the engine start., replace its last cell | Milestone 3 | with | Milestone 3C (decision P17 of the Milestone 3B plan) |.
Replace
| Confirm the clear-2FI definition for full factorials (R23) and the Dark2 purple of R19. | Alan R. Vazquez | Milestone 3B |
| References for the principles in Section 1.8, and the glossary entries for effect hierarchy, effect sparsity, effect heredity and clear 2FI. | Alan R. Vazquez | Milestone 3B |
with
| Confirm the clear-2FI definition for full factorials (R23) and the Dark2 purple of R19. A note of 2026-10-16 asks him (decision P2 of the Milestone 3B plan). | Alan R. Vazquez | Public release |
| Confirm the references of Section 1.8 (decision P4) and review the glossary entries for effect hierarchy, effect sparsity, effect heredity and clear 2FI. | Alan R. Vazquez | Public release |
| Measure the main-thread parse of the largest reports (up to 32,768 rows) in the real flow (Milestone 2 review). | Fadel M. Megahed | Milestone 3C (decision P17) |
| The focus after "Clear" and after Cancel, and the size of the Home link (Milestone 2 review). | Fadel M. Megahed | Milestone 3C, axe sweep (decision P17) |
Replace the lines of Section 19 from - Block, R. M. to - Ying, D. D. (the line before - Software and documentation: stays) with:
- Block, R. M., and Mee, R. W. (2005). Resolution IV designs with 128 runs. Journal of Quality Technology, 37(4), 282-293. Corrigenda (2006), 38, 196.
- Box, G. E. P., and Meyer, R. D. (1986). An analysis for unreplicated fractional factorials. Technometrics, 28(1), 11-18. https://doi.org/10.1080/00401706.1986.10488093
- Chen, J., Sun, D. X., and Wu, C. F. J. (1993). A catalogue of two-level and three-level fractional factorial designs with small runs. International Statistical Review, 61(1), 131-145.
- Groemping, U. (2014). R package FrF2 for creating and analyzing fractional factorial 2-level designs. Journal of Statistical Software, 56(1), 1-56. https://doi.org/10.18637/jss.v056.i01
- Hamada, M., and Wu, C. F. J. (1992). Analysis of designed experiments with complex aliasing. Journal of Quality Technology, 24(3), 130-137. https://doi.org/10.1080/00224065.1992.11979383
- Harrower, M., and Brewer, C. A. (2003). ColorBrewer.org: An online tool for selecting colour schemes for maps. The Cartographic Journal, 40(1), 27-37.
- Li, X., Sudarsanam, N., and Frey, D. D. (2006). Regularities in data from factorial experiments. Complexity, 11(5), 32-45. https://doi.org/10.1002/cplx.20123
- Machado, G. M., Oliveira, M. M., and Fernandes, L. A. F. (2009). A physiologically-based model for simulation of color vision deficiency. IEEE Transactions on Visualization and Computer Graphics, 15(6), 1291-1298.
- Megahed, F. M., Chen, Y.-J., Ferris, J. A., Resatar, C., Ross, K., Lee, Y., and Jones-Farmer, L. A. (2024). ChatISA: A prompt-engineered, in-house multi-modal generative AI chatbot for information systems education. arXiv:2407.15010 (v2, May 2025). Source code: https://github.com/fmegahed/chatisa
- Ockuly, R. A., Weese, M. L., Smucker, B. J., Edwards, D. J., and Chang, L. (2017). Response surface experiments: A meta-analysis. Chemometrics and Intelligent Laboratory Systems, 164, 64-75. https://doi.org/10.1016/j.chemolab.2017.03.009
- Ottosson, B. (2020). A perceptual color space for image processing. https://bottosson.github.io/posts/oklab/
- Ryan, K. J., and Bulutoglu, D. A. (2010). Minimum aberration fractional factorial designs with large N. Technometrics, 52(2), 250-255.
- Vazquez, A. R., Rother, K. M., and Charles-Gonzalez, M. V. (2026). A systematic assessment of large language models for constructing two-level fractional factorial designs. Quality Engineering. https://doi.org/10.1080/08982112.2026.2625951
- Wu, C. F. J., and Hamada, M. S. (2021). Experiments: Planning, analysis, and optimization (3rd ed.). Wiley. https://doi.org/10.1002/9781119470007
- Xu, H. (2009). Algorithmic construction of efficient fractional factorial designs with large run sizes. Technometrics, 51(3), 262-277.
- Xu, H., and Wu, C. F. J. (2001). Generalized minimum aberration for asymmetrical fractional factorial designs. Annals of Statistics, 29(4), 1066-1077. https://doi.org/10.1214/aos/1013699993
- Ying, D. D., Vazquez, A. R., and Xu, H. (2026). Prompt engineering using order-of-addition experiments: An application to generating two-level fractional factorial designs. arXiv:2607.05537.
In r/ffd_report.R, replace the comment line # other 2FI is aliased with it; interactions of three or more factors are not looked at with # other 2FI is aliased with it; interactions of three or more factors are not taken into account. Then run npm run setup:runtimes (it copies the report helper to public/r/). The report version does not change: only a comment changes.
In scripts/build-spec-page.mjs, change "Draft for review · version 3" to "Draft for review · version 4", and "Design specification, version 3, of EXPERTA to "Design specification, version 4, of EXPERTA.
In README.md, replace
Milestone 3A of Phase 1 is complete: the translation service (`POST /api/translate`) with three language models and TypeSafe Jev. The interface for the translation step and the design view come in Milestone 3B.
with
Milestone 3B of Phase 1 is complete: the interface. Step 1 sends a request to the translation service (Milestone 3A), step 2 shows the specification card with its sources, agreement flags and pre-checks, and step 3 makes the design with FrF2 in the browser and shows its properties. Each "?" button opens help from a glossary that is a draft until Alan R. Vazquez reviews it. The exports, the import and the Report step come in Milestone 3C.
Run: node scripts/build-spec-page.mjs Expected: the page files in replication_materials/plans/spec-page/ are written, with the ribbon "Draft for review · version 4".
Add this event at the end of events in replication_materials/process/process.json, in the compact style of e21 to e24, with time and source.time set to the UTC time of the Task 11 commit (date -u +%Y-%m-%dT%H:%M:%SZ) and source.transcript set to the first 8 characters of the executing session's transcript id:
{ "id": "e25", "time": "<UTC time of the commit>", "stage": "build", "actors": ["claude", "agents", "records"],
"title": "Milestone 3B: interface",
"detail": "Claude carries out the eleven tasks of the plan, test first: storage ready for reports, the translation client, the pre-checks, the card model, the global announcer with help panels and the glossary, steps 1 to 3, the rebuild of older reports and the privacy page. The browser tests answer the translation route from the recorded fixtures, so the build makes no LLM call. A fresh reviewer checks the whole branch.",
"records": ["tests/"], "source": { "transcript": "<transcript id>", "time": "<UTC time of the commit>" } }
Change status_date at the top of the file to the date of the commit. If the execution differs from the detail text (for example a task that was split, or a review finding that changed a step), change the detail to the facts and record a ruling.
Run: npm run check Expected: "All checks passed."
git add replication_materials/plans/2026-10-14-ff-design-translator-design.md replication_materials/plans/spec-page scripts/build-spec-page.mjs r/ffd_report.R replication_materials/process/process.json README.md
git commit -m "docs: specification version 4 with the Milestone 3B decisions, the references of Section 1.8 and the build event"
The main session then republishes the spec page to its artifact (https://claude.ai/artifact/6JHDuu3rpunPsRA6P3Fjnw) and updates the plan page status.
19Verification (end to end)
- Run
npm run check. Expected: "All checks passed." The browser tests answer/api/translatefrom the recorded fixtures, so the check makes no LLM call. - Run
FFD_RELEASE=1 npx vitest run --project unit replication_materials/tests/unit/glossary.test.ts. Expected: one failure, "has a reviewer for every entry", because the 35 entries are drafts (decision P3). Record the count in the ledger; the gate passes after the review of Alan R. Vazquez. - Walk through the app in Chrome with
npm run build && npm run start, with "Fill in the form yourself", because "Interpret request" calls the paid providers (each live run needs the approval of the authors). Check with the keyboard only: fill a card with 7 factors in 16 runs, move a factor, open help with "Show explanations", generate the design, change the run-sheet order, print, open the R code, reload, and open the glossary and the privacy page. - Confirm that the branch has no change to
prompts/,lib/translate/translate.tsor the recorded fixtures: the translation contract of Milestone 3A stays as it is.
20After approval (main session)
- Plan commit, before execution. The main session commits, on the execution branch: this plan; its page (
replication_materials/plans/m3b-plan-page/, built byscripts/build-plan-page.mjs); the probe scripts and outputs (replication_materials/plans/m3b-probes/); the plan event e24 inprocess.json; and the accent change of the document pages (P5:scripts/spec-page.css,replication_materials/plans/figures/architecture.svg, the caption inscripts/build-spec-page.mjs) with the rebuilt spec page. - The note for Alan R. Vazquez (P2) is published during planning. It asks him to confirm R23, the Dark2 purple and the references of P4.
- Workspace. Execution runs in a worktree (
EnterWorktree, branchworktree-m3b-interface). Task 5 installs KaTeX: ask the user to pause Dropbox sync first. - After execution. A fresh reviewer on the most capable model checks the whole branch. The merge into
mainwaits for the decision of the user. Then the main session republishes the spec page (version 4) and sets the status of the plan page to done.
21Execution recommendation
- The tasks share interfaces. Tasks 6 to 9 use the
StepPropsof Task 6 and the card model of Task 4; Tasks 1, 4 and 6 each changeProjectSchema. The order of the tasks matters. - The plan is long (eleven tasks), so an inline executor would run the later tasks on a compacted context.
- The code was run during planning. The code of Tasks 1 to 10 was applied to a scratch copy of
main(ata5c3801), and its tests ran there: unit tests, types, the R behaviour tests of the pre-checks and every browser test. The plan text has the corrected code. An implementer mostly transcribes it.
Recommendation: subagent-driven execution, with the cheapest model for the implementers (the plan gives the complete code), a mid-tier model for the task reviews, and the most capable model for the final review.