Milestone 3C Plan
Plan · done on 2026-10-17

Implementation Plan, Milestone 3C

EXPERTA

Implementation plan for Milestone 3C of EXPERTA, the Fractional Factorial Design Translator.

Plan
replication_materials/plans/2026-10-17-m3c-exports-plan.md
Specification
replication_materials/plans/2026-10-14-ff-design-translator-design.md
Status
Done on 2026-10-17. Eleven tasks committed test first, each checked by a fresh reviewer; a whole-branch review and one fix wave followed, and the full check passed. Merge into main is pending the authors' decision.
Tasks
11, each test-first and committed on its own
Maintained by
Alan R. Vazquez (Tecnológico de Monterrey) and Fadel M. Megahed (Miami University)

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, 3A and 3B and the pre-3B review changes are merged into main (main at b0bbd1a, 2026-10-17). This plan is the last part of Milestone 3 (split of 2026-10-15): the exports, the import, the Report step (step 6), the full end-to-end flow and the accessibility sweep, and the items that the 3B plan and its review moved here (3B decision P17, the 3B ledger's M3, M4, M5, M7 and M8).

On 2026-10-17 Fadel M. Megahed decided the next steps: the open questions for Alan R. Vazquez do not block the work; 3C comes first, then a separate alpha release plan (hosting, a shared store for the rate limit and the spend caps, a "Review questions" page that files each answer as a GitHub issue, a private GitHub repository). He also decided two questions of this plan (C1 and C2 below) and noted that the 2026-10-14 mockups still show the red accent of that date (C17).

Goal: A person can take every file of a design out of the browser (run sheet as .xlsx and .csv, R script, project file, self-contained report, all files as .zip), move projects between devices with a checked import, rename, duplicate and delete projects, and use every page with a keyboard and a screen reader; the tests prove that each file is the same from each place where it is offered.

Architecture: Every file is made by a pure builder in lib/exports/ (text in, bytes or text out, no clock inside: the caller passes now), with vitest unit tests in Node. The .xlsx and .zip files are written with fflate from hand-built OOXML; no spreadsheet library. Components are thin: a Download menu in step 3, a project menu in the sidebar, an import control, and the Report step. The project store gets two raw-record calls, so that a project whose report waits for a rebuild (3B decision P1) can be exported and imported unchanged. Chromium tests run against the production build with /api/translate answered from the recorded fixtures; an axe and custom-check sweep runs on every page.

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, fflate 0.8.3 (new), vitest 4.1.11 with fake-indexeddb 6.2.5, Playwright 1.63.0 (Chromium) with @axe-core/playwright 4.13.0 (new), testthat in native R 4.6.1.

Spec: replication_materials/plans/2026-10-14-ff-design-translator-design.md (version 4; Task 11 makes version 5), Sections 7.1, 7.2, 7.3, 7.9, 8.1 to 8.3, 8.5, 9.1, 9.3, 9.7, 9.8, 9.10, 11, 13.1 and 17.1. Mockup replication_materials/mockups/2026-10-14-ui/05b-exports.html (option C: three places) gives the layout and the labels. The 3B plan (2026-10-16-m3b-interface-plan.md, decisions P1, P12, P14, P15, P17, P19) and the 3B ledger (2026-10-16-m3b-execution-ledger.md) give the items carried here.

2Global Constraints

  • Versions. Pin every npm package exactly (.npmrc has save-exact=true and legacy-peer-deps=true). Two new packages, installed by the controller during execution setup (one Dropbox pause): fflate@0.8.3 as a dependency (published 2026-05-16) and @axe-core/playwright@4.13.0 as a devDependency (published 2026-08-11; it brings axe-core ~4.13.0; 4.14.0 is from 2026-10-15 and too new). No other new package. Implementers do not run npm install.
  • Dropbox. The repository is in Dropbox, which locks node_modules while it syncs (EBUSY). Only the controller installs packages, after the user pauses Dropbox.
  • Browser only. Every file is made in the browser from a Blob. No export or import goes through a server route. Nothing leaves the device.
  • Deterministic builders. No builder in lib/exports/ reads the clock, Math.random or crypto.randomUUID. A builder that writes a date takes now: Date. Zip entries get an explicit mtime. Equal inputs give equal bytes.
  • Escaping. Every user text (project name, factor names, levels, units, request text) is escaped for its format: RFC 4180 quoting in CSV, XML escaping in OOXML, HTML escaping in the report, a comment-safe single line in the R header. File names come from fileStem() only.
  • Client and server. As in 3B: client components never import lib/translate/config.ts, service.ts, handler.ts, llm-voter.ts, jev-voter.ts, translate.ts, agreement.ts or lib/ops/* at run time.
  • 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/translate from the recorded fixtures (routeTranslate). No live call in this plan.
  • Fixed texts. Every new message is a fixed string in one module with a unit test that asserts the exact text. No advice words ("try", "instead", "recommend", "consider", "should") in messages; the unit test builds its whole-word pattern with new RegExp(\\b${word}\b`)` (two backslashes in a template literal).
  • Wording. Plain academic wording (spec 9.7): no marketing, no exclamation marks. Labels exactly as this plan gives them. Comments and documents about 80% ASD-STE100.
  • Colours. The mockups fix the layout and the labels only. Colours come from the interface tokens of spec 9.8 (accent #625d96, accent dark #4c4977, tans, warm white), as the app uses them now; the report file uses the same accent (C17).
  • Accessibility. WCAG 2.1 AA plus the WCAG 2.2 target size and focus-not-obscured additions. One global announcer (lib/a11y/announcer.ts). Every control has a visible label. A button that would disable itself while it has focus uses aria-disabled="true" and ignores clicks instead (3B Task 7 pattern). Focus moves to the step heading after a step change (existing).
  • 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 from its in-memory form (pending_rebuild set); raw-record calls write the stored record unchanged or a validated imported record.
  • R. R runs through the PowerShell tool: & "C:\Program Files\R\R-4.6.1\bin\Rscript.exe" <file>. Native R tests run with npm run test:r.
  • Tests. All tests are in replication_materials/tests/. npm run check must pass at the end of each task that changes code.
  • Outside contact. None: no push, no deployment, no live API call. The main session publishes the private plan and spec pages.
  • Commits. One commit per task unless a task says otherwise. Every commit message ends with these two lines (the git commit commands 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.

  1. Unusual text in names and levels: a project or factor name with a comma, a double quote, <, &, a line break, accents (Diseño, °C), 200 characters, or only punctuation. Each file stays valid: CSV quoting, XML escaping, HTML escaping, a one-line R comment, and a file name stem with a fallback. Tests: Task 1 (names, CSV, R script), Task 2 (.xlsx), Task 3 (report).
  2. Files that import does not expect: an empty file, text that is not JSON, JSON of another program, a newer format version, a project that matches no schema, a .zip without project files, a project of an older report version, and a project id that is already on the device. Each gives one fixed message or a correct import; nothing on the device is overwritten. Tests: Task 6 (unit and Chromium).
  3. The largest design in every file: 32,768 rows in .xlsx, .csv and the report, and the archive of all files. The files stay correct and are made in a measured time; the preview stays usable. Tests: Task 2 and Task 3 (synthetic 32,768-row report, time budget = 3 × the measured time), Task 7 (Chromium with a 256-run design).
  4. Project actions at awkward moments: rename to an empty name or only spaces, duplicate or delete the project that is open, actions on a project whose report waits for a rebuild, and a database that another tab closed. The sidebar shows a fixed text, the address never points to a deleted project, and nothing is written from a held project. Tests: Task 5 (unit and Chromium).
  5. Keyboard and screen-reader use of the new controls: the Download menu and the project menu open and close with the keyboard (Escape closes and returns focus), the result of a download, a copy and an import is announced once, and focus never drops to the page body after Clear, Delete, Keep, Cancel or a page change of the run sheet. Tests: Task 4, Task 5, Task 7 and Task 9 (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
C1 Step 6 has a "Request text" check box, checked by default. It decides whether the report and the project file inside "All files (.zip)" contain the request and the clarification turns. Without the request, the project file also drops the quotes of the card ("From your text" values become interpretation values). "Export project" in the project menu always gives the whole project. Decision of F. M. Megahed (2026-10-17). Spec 8.1: the request is optional in exports; mockup 05b shows the box checked. A quote is part of the request text. One check box and one function (withoutRequest).
C2 An imported project whose id is already on the device gets a new id and the name "{name} (imported)". Nothing on the device is overwritten. Decision of F. M. Megahed (2026-10-17). One branch in the import.
C3 No byte progress for R.wasm. Until the first package progress message, the footer says "R engine: starting R" (no percentage). After the last package byte, it says "R engine: preparing the R packages", and the stall timer stops, because the save to IndexedDB and the FrF2 load send no progress. Measured (Facts): a GET of R.wasm before WebR starts does not serve WebR's own request from the HTTP cache; both requests transfer the full 12.3 MB. A prefetch would double the largest download of a first visit. The old text showed "0%" for the whole WebR start. A first visit shows no byte count for 12.3 MB; a stalled R.wasm download shows no Retry (as now).
C4 The report stays parsed on the main thread. Measured: JSON.parse plus ReportSchema.safeParse of the largest report (32,768 rows, 3.9 MB) takes 40 ms in Chromium, under the 50 ms long-task line. A slow device may see a short pause after a large design.
C5 After a save, the workspace replaces the saved project in its list (withSaved) and does not read all projects again (3B M5). Measured with the largest report: store.list() 73 ms per save, store.put() 111 ms, size 14 ms. The read is the part that the workspace does not need. put keeps its 111 ms (zod parse and the IndexedDB copy).
C6 The .xlsx file is hand-built OOXML (inline strings, one hidden sheet, a minimal style sheet) zipped with fflate. The same library writes and reads the .zip files. Measured: Microsoft Excel 16 opens the probe file with both sheets, the second hidden, numbers as numbers, text and escaped characters intact. One small dependency instead of a spreadsheet library. Phase 2 upload needs an .xlsx reader for files that Excel saves (a reader library then).
C7 The project file is { "format": "experta-project", "format_version": 1, "app_version": "...", "project": <stored record> } as UTF-8 JSON with two-space indentation and a final newline. It has no export time, so equal projects give equal files. A project whose report waits for a rebuild is exported from its stored record. Import reads the record with the store's readRecord (current or older report version). Spec 8.3: a versioned schema. A stored record already has schema_version, created_at and time_zone (spec 7.3). A later format needs version 2 and a reader for version 1.
C8 "Export all (.zip)" gives experta-projects-{yyyy-mm-dd}.zip with one {stem}.project.json per readable project (names made unique with -2, -3). "Import project" accepts .json files and .zip files; it imports every *.project.json in a .zip. Spec 8.2 names "Export all" and "Import" but no format. Each file in the archive can also be imported alone. One more file kind in the import.
C9 The import check of spec 8.5 runs in step 3 of the imported project, in this session: R makes the design again from the spec and the seed and compares the run-sheet SHA-256. Equal: "Project imported. The rebuilt design matches the stored run sheet (SHA-256)." Different: the stored run sheet stays, and the step names the versions that differ. A project with an older report version uses the P1 rebuild path. A project with a spec and no card gets a card from its spec, with a new origin imported that shows the label "From the project file". Spec 8.5. "Confirmed by you" would claim a confirmation that the person who imports the file did not make. Step 3 already runs R and compares the SHA-256 (P1). A batch import of 20 projects would otherwise run R 20 times before the user sees anything. A project imported and never opened in step 3 in that session is not checked; its stored run sheet stays the reference.
C10 The project menu is a disclosure button per project ("Project actions: {name}", aria-expanded), with four buttons: Rename, Duplicate, Export project (.json), Delete. It is not an ARIA menu. Rename and Duplicate are not available for a project whose report waits for a rebuild (3B M3). Delete asks for confirmation in the page. A disclosure with buttons is accessible with no custom key handling. A held project cannot be written from memory (P1). A second pattern if an ARIA menu is wanted later.
C11 The report file is one HTML document: inline CSS, no script, no link, no external resource, system fonts (Georgia for headings, Arial for text), the accent #625d96, a print style sheet (each section starts on a new page, table headers repeat). Step 6 shows it in an <iframe sandbox="allow-same-origin"> with srcdoc (no script can run); "Print / PDF" prints that frame. Spec 8.3 and 9.3. The preview is the same text as the file. A frame in a page with COEP; measured in Task 7.
C12 The .csv file: RFC 4180 quoting, CRLF line ends, UTF-8 without a byte order mark, "NA" for a missing value, a final line end. Columns: Run, Std, the real levels (headers with units), then the coded levels (headers "{name} (coded)"). Spec 8.3. Excel users have the .xlsx file; R and Python read UTF-8 without a BOM directly. Excel shows accents wrongly when it opens the .csv by double-click.
C13 Each step of the workspace sets the page title "Step {n}: {title} | EXPERTA". Required fields (factor name, low and high level) have aria-required="true", and their "*" is aria-hidden. The accessibility sweep is a guard in npm run check on every page; it defines the five custom checks of spec 11 as Playwright assertions. Measured: 0 axe violations on every page of main; all steps had the same title "Workspace | EXPERTA"; step 2 had 21 "*" markers and no required field. One title per step.
C14 Focus rules (3B M4 and P17): "Delete all" moves focus to "New design"; "Keep projects" returns it to "Clear"; "Delete record" moves it to the "Your projects" heading; "Keep record" returns it to that record's "Delete"; Cancel of a translation returns it to the request field (or the Answer field); Cancel of an R run moves it to the step heading; "Previous rows" and "Next rows" use aria-disabled at the ends; "Interpret request" and "Send answer" use aria-disabled while busy. No focus may drop to the page body (WCAG 2.4.3). Each rule is one line.
C15 The unsent text of step 1 is kept in sessionStorage (key ffd-draft:{project id or "new"}) and comes back after a plain link and the browser's Back button; it is removed when the interpretation is stored (3B M8). A typed request is lost now. Spec 8.2 lists the localStorage values; sessionStorage ends with the tab. One spec 8.2 line.
C16 A select of step 2 whose value is not in its list (a disagreement chose a run size or resolution that the list does not have) shows that value as "{value} (not available)"; the field's error stays and "Generate design" stays unavailable (3B M7). An empty select hides the value that blocks the design. One option.
C17 The 2026-10-14 mockups show the red accent of that date. They fix the layout and the labels only; colours come from the tokens of spec 9.8 (purple). The mockups stay unchanged, because they record what was selected then. Note of F. M. Megahed (2026-10-17); 3B decision P5 did the same for the document pages. None.
C18 The controller installs fflate and @axe-core/playwright once, before Task 1, while Dropbox is paused, and commits the two package files. One Dropbox pause; subagents cannot ask the user. None.

5Facts measured during planning (2026-10-17)

Measured in a copy of main (b0bbd1a) outside Dropbox, Chromium of Playwright 1.63.0, production build.

Fact Value
axe 4.13.0, tags wcag2a, wcag2aa, wcag21a, wcag21aa, wcag22aa 0 violations on the landing page, the glossary, the privacy page, step 1, step 2 (coating card), step 3 (design), step 3 with "Show explanations", and step 6 (placeholder). The Home link passes target-size.
Custom checks on the same pages No focusable element without a name; no placeholder attribute; no aria-label on a div or span without a role; step 2: 21 labels with "*", 0 elements with required or aria-required; every workspace step has the title "Workspace | EXPERTA".
Largest report: 4096 runs × 8 replications, 15 factors 32,768 rows, report JSON 3,914,786 characters. R: design 326 ms, report 228 ms, run 6.6 s in total. Main thread: parse 40 ms; store.put 111 ms; store.list 73 ms; projectsSize 14 ms; JSON.stringify 8 ms.
R.wasm load With a full GET in the worker before new WebR(), the network log shows two GETs of R.wasm, both status 200, both 12,344,383 bytes of body, neither with If-None-Match; Cache-Control: public, max-age=0 from next start.
.xlsx probe (m3c-probes/probe-xlsx.mjs) 3,124 bytes; equal bytes for equal input (fixed mtime). Excel 16 (Workbooks.Open, read-only, hidden): 2 sheets, "Run sheet" and "experta", the second Visible = 0; Temperature (°C) and Feed <&> "rate" intact; 150 read as Double; the Response column empty; the hidden sheet holds the project id and a 64-character SHA-256.
Package dates fflate 0.8.3: 2026-05-16. @axe-core/playwright 4.13.0: 2026-08-11 (dependency axe-core ~4.13.0, 4.13.0 published 2026-08-05).
ChatISA ChatISA's tests run @axe-core/playwright with the WCAG 2.0/2.1 A and AA tags; it has no code for the five custom checks that spec 11 names, so Task 9 defines them.

The probe scripts and outputs are copied to replication_materials/plans/m3c-probes/ with this plan.

6File structure

File Responsibility
lib/exports/types.ts DesignProject (a project with a spec, a report and R code) and its guard
lib/exports/names.ts File name stems and file names, local dates, unique names
lib/exports/csv.ts Run sheet as CSV (C12) and as tab-separated text for "Copy table"
lib/exports/rscript.ts The .R file with its version header (spec 7.1)
lib/exports/project-file.ts Project file text and reader (C7), withoutRequest (C1)
lib/exports/xlsx.ts The run sheet workbook (spec 8.3, C6)
lib/exports/archive.ts, lib/exports/readme.ts "All files (.zip)" with README and manifest; "Export all"; reading .zip files
lib/exports/report-html.ts The self-contained report (spec 8.3, 9.3, C11)
lib/exports/import.ts Import of .json and .zip files (spec 8.5, C2, C8) and its texts
lib/projects/download.ts Blob downloads (existing downloadJson, new downloadBytes, downloadText)
lib/projects/list.ts withSaved, duplicateProject, renameProject, name checks (C5, C10)
lib/projects/store.ts New raw(id) and putRecord(record) (C7, C10)
lib/report/format.ts New factorHeaders (shared by the table, CSV, .xlsx and report)
lib/report/rebuild.ts Import check texts (C9)
lib/card/card.ts New cardFromSpec (C9)
lib/webr/status-text.ts, lib/webr/client.ts Engine start texts and stall rule (C3)
components/design/DownloadMenu.tsx, components/design/RunSheet.tsx, components/design/DesignView.tsx Step 3 downloads, "Copy table", pages with aria-disabled
components/shell/ProjectMenu.tsx, components/shell/ProjectSidebar.tsx, components/shell/Workspace.tsx Project menu, "Export all", "Import project", import state, page titles, focus rules
components/steps/ReportStep.tsx, components/shell/StepPanel.tsx Step 6
components/steps/DesignStep.tsx Import check, focus after Cancel
components/steps/DescribeStep.tsx, components/spec-card/inputs.tsx, components/spec-card/FactorTable.tsx Draft (C15), aria-disabled while busy, required fields, select value (C16)
replication_materials/tests/fixtures/projects.ts, replication_materials/tests/fixtures/exports/coating-design.R Project fixture for unit tests; the exported R script that native R runs
replication_materials/tests/e2e/helpers/records.ts, helpers/files.ts, helpers/a11y.ts Shared Chromium helpers: stored records, downloads and archives, the accessibility sweep

7Execution setup (controller, before Task 1)


8Task 1: Text exports: file names, CSV, R script, project file

Files:

  • Create: lib/exports/types.ts, lib/exports/names.ts, lib/exports/csv.ts, lib/exports/rscript.ts, lib/exports/project-file.ts
  • Modify: lib/report/format.ts (add factorHeaders), components/design/RunSheet.tsx (use it)
  • Create: replication_materials/tests/fixtures/projects.ts, replication_materials/tests/fixtures/exports/coating-design.R
  • Test: replication_materials/tests/unit/exports-names.test.ts, exports-csv.test.ts, exports-rscript.test.ts, project-file.test.ts; replication_materials/tests/r/test-export-script.R

Interfaces:

  • Consumes: Project, ProjectSchema (lib/projects/schema.ts); readRecord (lib/projects/store.ts); Report (lib/report/types.ts); CoreSpec; Card (lib/card/card.ts); APP_FULL_NAME, APP_VERSION (lib/site.ts).
  • Produces:
    • type DesignProject = Project & { spec: CoreSpec; report: Report; r_code: string } and isDesignProject(p: Project | null): p is DesignProject in lib/exports/types.ts.
    • fileStem(name: string): string, type ExportKind = "xlsx" | "csv" | "r" | "project" | "report" | "zip", exportFileName(name: string, kind: ExportKind): string, localDate(now: Date): string (yyyy-mm-dd, local), exportAllFileName(now: Date): string, uniqueNames(names: string[]): string[] in lib/exports/names.ts.
    • factorHeaders(report: Report, spec: CoreSpec): string[] in lib/report/format.ts.
    • runSheetCsv(report: Report, spec: CoreSpec): string, runSheetTsv(report: Report, spec: CoreSpec): string in lib/exports/csv.ts.
    • rScript(project: DesignProject, now: Date): string in lib/exports/rscript.ts.
    • PROJECT_FILE_FORMAT = "experta-project", PROJECT_FILE_VERSION = 1, withoutRequest(record: Record<string, unknown>): Record<string, unknown>, projectFileText(record: unknown, options: { includeRequest: boolean }): string, type ProjectFileRead = { ok: true; record: Record<string, unknown>; project: Project } | { ok: false; reason: "not_json" | "not_project_file" | "newer_format" | "invalid_project"; version?: number }, readProjectFile(text: string): ProjectFileRead in lib/exports/project-file.ts.
    • coatingProject(fields?: Partial<Project>): DesignProject in replication_materials/tests/fixtures/projects.ts: id 6f1c2a7e-0d4b-4e9a-9b7c-2f8e1a3d5c60, name "Coating thickness", created_at "2026-10-14T09:05:07-04:00", time_zone "America/New_York", updated_at "2026-10-14T13:10:00.000Z", request { text: "We want to screen 7 factors for coating thickness in 16 runs.", turns: [], notice: null }, spec: coatingSpec, report: replication_materials/tests/fixtures/reports/coating.json, r_code: generateDesignCode(coatingSpec), versions: { R: "4.6.0", webR: "0.6.0", FrF2: "2.3.5", "DoE.base": "1.2.5", jsonlite: "2.0.0", report: "1.2.0", app: "0.2.0" }, run_sheet_sha256 from runSheetSha256(report) computed once in the fixture module with node:crypto (same text as runSheetText), last_step: 3, card: null, frf2_messages: [], pending_rebuild: null.

exports-names.test.ts:

  • fileStem("Coating thickness") is "coating-thickness"; fileStem("Diseño de 7 factores (°C)") is "diseno-de-7-factores-c"; fileStem("a, \"b\" <c> & d\ne") is "a-b-c-d-e"; fileStem("!!!") is "design"; fileStem("x".repeat(200)).length is 60.
  • exportFileName("Coating thickness", k) gives, for xlsx, csv, r, project, report, zip: coating-thickness-run-sheet.xlsx, coating-thickness-run-sheet.csv, coating-thickness.R, coating-thickness.project.json, coating-thickness-report.html, coating-thickness-all-files.zip.
  • localDate(new Date(2026, 9, 7, 23, 59)) is "2026-10-07"; exportAllFileName(new Date(2026, 9, 17, 8)) is "experta-projects-2026-10-17.zip".
  • uniqueNames(["a.project.json", "a.project.json", "b.project.json", "a.project.json"]) is ["a.project.json", "a-2.project.json", "b.project.json", "a-3.project.json"].

exports-csv.test.ts (with coatingSpec and the coating report):

  • the first line of runSheetCsv is Run,Std,Temp (°C),Pressure (bar),Speed,Time (min),Feed,Depth (mm),Coolant,Temp (coded),Pressure (coded),Speed (coded),Time (coded),Feed (coded),Depth (coded),Coolant (coded);
  • the second line is 1,16,200,3,fast,20,0.2,2,on,1,1,1,1,1,1,1;
  • the text has 17 lines, each ended by \r\n, and does not start with \uFEFF;
  • a factor named Feed, "rate" gives the header field "Feed, ""rate"""; a level with a line break is quoted; a null level and a null coded value are NA;
  • runSheetTsv has the same header and rows, separated by tabs, ended by \n;
  • factorHeaders(report, coatingSpec) is ["Temp (°C)", "Pressure (bar)", "Speed", "Time (min)", "Feed", "Depth (mm)", "Coolant"].

exports-rscript.test.ts:

  • rScript(coatingProject(), new Date(2026, 9, 17, 10)) starts with these four lines, then one empty line, then generateDesignCode(coatingSpec) and a final \n:
    # EXPERTA: Fractional Factorial Design Translator, version 0.2.0
    # R 4.6.0 (WebR 0.6.0), FrF2 2.3.5, DoE.base 1.2.5
    # Made on 2026-10-17 from the project "Coating thickness".
    # The code calls FrF2::FrF2() and attaches no package.
  • a project name with a line break gives a third line with a space in place of the break; a project with versions: null writes unknown for each version;
  • the output equals the committed file replication_materials/tests/fixtures/exports/coating-design.R (write that file from the function once, then commit it);
  • the text has no library( and no require(.

project-file.test.ts:

  • projectFileText(coatingProject(), { includeRequest: true }) parses to { format: "experta-project", format_version: 1, app_version: "0.2.0", project: <the project> }, uses two-space indentation and ends with \n; two calls give equal text;
  • with includeRequest: false, project.request is null and every card field origin of kind quote becomes { kind: "model" } (use a card from cardFromTranslation of the coating fixture answer);
  • readProjectFile of that text gives ok: true and a project equal to the input; of "not json" gives reason: "not_json"; of {"format":"other"} gives not_project_file; of a file with format_version: 2 gives newer_format with version: 2; of a file whose project has id: "x" gives invalid_project;
  • a file whose project has a report with report_version: "1.1.0" gives ok: true, project.report null and project.pending_rebuild { report_version: "1.1.0" }, and record.report.report_version "1.1.0" (the record is kept for the store).

test-export-script.R:

test_that("the exported R script runs in a fresh session without attaching a package", {
  res <- ffd_run_r(file = file.path(ffd_root, "replication_materials", "tests", "fixtures", "exports", "coating-design.R"))
  expect_identical(res$status, 0L)
  expect_false(any(grepl("Warning", res$output, fixed = TRUE)))
})

Run: npx vitest run --project unit replication_materials/tests/unit/exports-names.test.ts replication_materials/tests/unit/exports-csv.test.ts replication_materials/tests/unit/exports-rscript.test.ts replication_materials/tests/unit/project-file.test.ts Expected: FAIL (modules not found).

fileStem: Unicode NFKD, remove combining marks, lower case, every run of characters other than a-z and 0-9 becomes one -, trim -, cut to 60 characters (trim a final - again), empty result → "design". factorHeaders is the header rule that RunSheet.tsx has now ("{label} ({unit})" when the spec factor has a unit); RunSheet.tsx imports it. projectFileText applies withoutRequest when includeRequest is false. readProjectFile: JSON.parse in try/catch; format must equal PROJECT_FILE_FORMAT; a format_version above 1 is newer_format; then readRecord(file.project) must give kind: "project".

Run: the vitest command of Step 2, then npm run test:r. Expected: PASS; the R output has the new test with no failure.

Run: npm run check → "All checks passed."

git add lib/exports lib/report/format.ts components/design/RunSheet.tsx replication_materials/tests
git commit -m "feat: text exports: file names, run sheet CSV, R script with version header, project file (spec 7.1, 8.3, C1, C7, C12)"

9Task 2: Binary exports: run sheet workbook, archive of all files, Export all

Files:

  • Create: lib/exports/xlsx.ts, lib/exports/archive.ts, lib/exports/readme.ts
  • Test: replication_materials/tests/unit/exports-xlsx.test.ts, exports-archive.test.ts

Interfaces:

  • Consumes: Task 1 (DesignProject, exportFileName, uniqueNames, factorHeaders, runSheetCsv, rScript, projectFileText, localDate); fflate (zipSync, unzipSync, strToU8, strFromU8).
  • Produces:
    • HIDDEN_SHEET = "experta", runSheetXlsx(project: DesignProject): Uint8Array, readHiddenSheet(bytes: Uint8Array): Record<string, string> (for tests and Phase 2) in lib/exports/xlsx.ts.
    • sha256Hex(bytes: Uint8Array): Promise<string>, type ArchiveOptions = { includeRequest: boolean; reportHtml: string; now: Date }, allFilesZip(project: DesignProject, options: ArchiveOptions): Promise<Uint8Array>, exportAllZip(records: Record<string, unknown>[], now: Date): Uint8Array, unzipText(bytes: Uint8Array): Record<string, string> in lib/exports/archive.ts.
    • readmeText(project: DesignProject, now: Date): string in lib/exports/readme.ts.

exports-xlsx.test.ts (unzip with unzipSync and check the XML text):

  • the archive has exactly these parts: [Content_Types].xml, _rels/.rels, docProps/core.xml, xl/workbook.xml, xl/_rels/workbook.xml.rels, xl/styles.xml, xl/worksheets/sheet1.xml, xl/worksheets/sheet2.xml;
  • xl/workbook.xml names the sheets "Run sheet" and "experta", and the second has state="hidden";
  • sheet 1, row 1: Run, Std, the 7 factorHeaders, Response; row 2: 1, 16, 200, 3 as number cells (<v>), fast as an inline string, and no cell in column J (Response empty);
  • a level of a qualitative factor that looks like a number (low "1", high "2", quantitative: false) is an inline string; a level of a quantitative factor is a number cell;
  • readHiddenSheet gives { project_id: <id>, run_sheet_sha256: <stored sha>, app_version: "0.2.0", report_version: "1.2.0", format_version: "1" };
  • a factor named Feed <&> "rate" appears as Feed &lt;&amp;&gt; &quot;rate&quot; in sheet 1;
  • docProps/core.xml has <dcterms:created xsi:type="dcterms:W3CDTF">2026-10-14T13:05:07Z</dcterms:created> (the project's created_at in UTC);
  • two calls give equal bytes;
  • a synthetic report with 32,768 rows and 15 factors (build it in the test from the shapes of ReportSchema) gives a workbook with 32,769 rows in sheet 1; record the build time in the test output (console.log).

exports-archive.test.ts:

  • allFilesZip(coatingProject(), { includeRequest: true, reportHtml: "<html></html>", now }) has the entries coating-thickness-report.html, coating-thickness-run-sheet.xlsx, coating-thickness-run-sheet.csv, coating-thickness.R, coating-thickness.project.json, README.txt, manifest.json;
  • each entry's bytes equal the builder's output for the same input (runSheetXlsx, runSheetCsv, rScript(project, now), projectFileText(project, { includeRequest: true }), the given HTML) — the "same file from each place" rule of spec 8.3;
  • manifest.json is { format: "experta-archive", format_version: 1, app_version: "0.2.0", created: <now as ISO>, project_id, project_name, run_sheet_sha256, versions, files: [{ name, bytes, sha256 }] } with one row per other entry, and each sha256 equals sha256Hex of that entry;
  • with includeRequest: false, the project file entry has project.request null;
  • two calls with the same now give equal bytes;
  • exportAllZip([a, b, aAgain], now) has coating-thickness.project.json, other.project.json, coating-thickness-2.project.json, each equal to projectFileText(record, { includeRequest: true });
  • unzipText of exportAllZip(...) gives the three texts by name;
  • readmeText names each file of the archive, the versions and the run-sheet SHA-256, has no advice words (whole-word pattern of the Global Constraints), and no sentence longer than 25 words;
  • the archive of the synthetic 32,768-row report is made; record the time with console.log.

Run: npx vitest run --project unit replication_materials/tests/unit/exports-xlsx.test.ts replication_materials/tests/unit/exports-archive.test.ts Expected: FAIL (modules not found).

The part set and the XML of replication_materials/plans/m3c-probes/probe-xlsx.mjs are the template (inline strings, xml:space="preserve", the minimal style sheet, state="hidden"). All zip entries of the workbook get mtime = the project's created_at; the archive entries get mtime = now. sha256Hex uses crypto.subtle.digest. Number cells: a quantitative factor's level whose text is a finite number by Number(); every other value is an inline string.

Run: the vitest command of Step 2. Expected: PASS. Then set two time budgets in the large tests as 3 × the logged times (rounded up to 100 ms), as expect(ms).toBeLessThan(<budget>), with a comment that names the measured value and the date. Run again: PASS.

Write the coating workbook to the session scratchpad from a one-off npx tsx script and open it with the PowerShell COM commands of the planning probe (Workbooks.Open($path, 0, $true), Visible = $false, DisplayAlerts = $false). Expected: 2 sheets, "experta" Visible = 0, C2 a Double. Record the output in the task report. If Excel is not available, record that.

Run: npm run check → "All checks passed."

git add lib/exports replication_materials/tests/unit
git commit -m "feat: run sheet workbook with a hidden project sheet, archive of all files with manifest, Export all (spec 8.3, C6, C8)"

10Task 3: The report file

Files:

  • Create: lib/exports/report-html.ts
  • Test: replication_materials/tests/unit/report-html.test.ts

Interfaces:

  • Consumes: Task 1 (DesignProject, factorHeaders, localDate); lib/report/explain.ts (aliasSentences, mainAliasSentences, generatorLines, clearValue, allClearNote, runOrderText, listText); lib/report/format.ts (designNotation, resolutionLabel, rowsText, wlpEntries); APP_FULL_NAME, APP_VERSION, CITATION.
  • Produces:
    • REPORT_SECTIONS = ["request", "specification", "properties", "run_sheet", "r_code", "versions"] as const, type ReportSection = (typeof REPORT_SECTIONS)[number], SECTION_TITLES: Record<ReportSection, string> = Request, Specification, Design properties, Run sheet, R code, Versions and citation; CONTENTS_LABELS: Record<ReportSection, string> = "Request text", "Confirmed specification", "Design properties and aliasing", "Run sheet", "R code (design)", "Versions, seed and citation".
    • reportHtml(project: DesignProject, sections: ReadonlySet<ReportSection>, now: Date): string.

report-html.test.ts (with coatingProject() and now = new Date(2026, 9, 17, 10)):

  • with all sections: <title>Coating thickness: design report</title>; an <h1> with the same text; the subtitle EXPERTA: Fractional Factorial Design Translator, version 0.2.0 · 2026-10-17 · FrF2 2.3.5 in WebR 0.6.0; six <h2> headings 1. Request … 6. Versions and citation in this order;
  • the properties section has 2<sup>7−3</sup> · 16 runs · Resolution IV, the statement "Design from the FrF2 catalogue, entry 7-3.1.", the generators Feed = Temp:Pressure:Speed, the WLP, "0 of 21" clear 2FIs, and each alias chain of aliasSentences;
  • the run sheet section is a <table> with a <caption>, <th scope="col"> headers (Run, Std and factorHeaders), 16 body rows in run order, and the first row 1, 16, 200, 3, fast, 20, 0.2, 2, on;
  • the R code section has generateDesignCode(coatingSpec) HTML-escaped in <pre><code>;
  • the versions section has R, WebR, FrF2, DoE.base, report and app versions, the seed 20261014 and the text of CITATION;
  • without request the text has no <h2> "Request", does not contain the request text, and the headings are numbered 1 to 5;
  • a project whose request has two clarification turns lists each turn with "You:" or "EXPERTA:";
  • a factor named <script>alert(1)</script> and a project named A & B appear escaped; the text has no <script, no <link, no src=, no href=, no http:// and no https://, no @import and no url(;
  • the CSS has @media print, break-before: page for h2, display: table-header-group for thead, and the colour #625d96;
  • two calls give equal text;
  • the synthetic 32,768-row report gives a text with 32,768 body rows; record the time with console.log, then pin a budget of 3 × the measured time (as in Task 2).

Run: npx vitest run --project unit replication_materials/tests/unit/report-html.test.ts Expected: FAIL (module not found).

One escapeHtml(text) for every inserted text. Sections are numbered in the order of REPORT_SECTIONS, counting only the included ones. The notation uses <sup> as DesignView.tsx does (spec 9.9: no LaTeX). The style sheet is inline in <head>.

Run: the command of Step 2. Expected: PASS.

Run: npm run check → "All checks passed."

git add lib/exports/report-html.ts replication_materials/tests/unit/report-html.test.ts
git commit -m "feat: self-contained report file with print style sheet and contents selection (spec 8.3, 9.3, C11)"

11Task 4: Step 3 downloads

Files:

  • Create: components/design/DownloadMenu.tsx, replication_materials/tests/e2e/helpers/files.ts
  • Modify: lib/projects/download.ts, components/design/DesignView.tsx, components/design/RunSheet.tsx, components/steps/DesignStep.tsx
  • Test: replication_materials/tests/e2e/downloads.spec.ts

Interfaces:

  • Consumes: Tasks 1 and 2 (runSheetXlsx, runSheetCsv, runSheetTsv, rScript, exportFileName, isDesignProject); announce.
  • Produces:
    • downloadBytes(filename: string, bytes: Uint8Array, type: string): void, downloadText(filename: string, text: string, type: string): void in lib/projects/download.ts (downloadJson stays).
    • DownloadMenu({ project, go }: { project: DesignProject; go: (project: string | null, step: number) => void }).
    • DesignView gets the prop project: DesignProject and go, and renders DownloadMenu beside "Print" (the report, spec, code, frf2Messages props come from project).
    • In helpers/files.ts: captureDownload(page: Page, click: () => Promise<void>): Promise<{ name: string; bytes: Buffer }> (waits for the download event and reads the file) and unzipEntries(bytes: Buffer): Record<string, Buffer> (with fflate).

downloads.spec.ts (reach step 3 with the coating fixture, as design.spec.ts does):

  • a button "Download" with aria-expanded="false"; a click opens a panel with the group label "Run sheet" (buttons "Excel workbook, with a response column (.xlsx)", "CSV (.csv)", "Copy table"), the group label "Code" (button "R script (.R)") and a link "Report and all files (step 6)";
  • Escape closes the panel and focus returns to "Download";
  • the .xlsx download is named we-want-to-screen-7-factors-run-sheet.xlsx (the name of the project from its request, 3B P14) and its xl/workbook.xml has state="hidden"; its hidden sheet's project_id equals the project query value of the page;
  • the .csv download equals runSheetCsv(project.report, project.spec) of the stored project (read it with storedProjects from helpers/records.ts, which Task 5 creates; until then read IndexedDB in the test as records.spec.ts does) and has 17 lines;
  • the .R download contains the text of the page's pre code element after its four header lines;
  • "Copy table" (with context.grantPermissions(["clipboard-read", "clipboard-write"])) puts text on the clipboard whose first line starts Run\tStd\t, and the polite region announces "Run sheet copied.";
  • the link "Report and all files (step 6)" leads to step=6;
  • with a 256-run design (from a card filled by hand: 9 factors, 256 runs), "Next rows" on the last page has aria-disabled="true" and keeps the focus (C14).

Run: npx playwright test downloads Expected: FAIL (no "Download" button).

The menu is a disclosure (aria-expanded, aria-controls), closed by Escape and by a click outside. downloadBytes/downloadText follow downloadJson (Blob URL, revoked after the click). Copy failure announces "The run sheet could not be copied." (assertive). The fixed texts live in DownloadMenu.tsx as constants and are asserted by the Chromium test. RunSheet uses aria-disabled for "Previous rows" and "Next rows" at the ends and ignores those clicks. DesignStep passes the project and go.

Run: npx playwright test downloads design Expected: PASS (the existing design tests still pass).

Run: npm run check → "All checks passed."

git add components lib/projects/download.ts replication_materials/tests/e2e
git commit -m "feat: Download menu in step 3: workbook, CSV, Copy table, R script (spec 8.3, mockup 05b)"

12Task 5: Project menu, Export all, raw records and the saved-project list

Files:

  • Create: components/shell/ProjectMenu.tsx, lib/projects/list.ts, replication_materials/tests/e2e/helpers/records.ts
  • Modify: lib/projects/store.ts, components/shell/ProjectSidebar.tsx, components/shell/Workspace.tsx, replication_materials/tests/e2e/records.spec.ts (use the helper)
  • Test: replication_materials/tests/unit/projects.test.ts (extend), replication_materials/tests/unit/project-list.test.ts, replication_materials/tests/e2e/project-menu.spec.ts

Interfaces:

  • Consumes: Task 1 (projectFileText, exportFileName), Task 2 (exportAllZip, exportAllFileName), Task 4 (downloadBytes, downloadText); isoWithOffset, CLOSED_ERROR.
  • Produces:
    • In ProjectStore: raw(id: string): Promise<Record<string, unknown> | undefined> (the stored record as stored) and putRecord(record: Record<string, unknown>): Promise<void> (writes the record unchanged after readRecord(record).kind === "project"; rejects otherwise with INVALID_RECORD_ERROR = "The record does not match the project format."). The memory store implements both.
    • In lib/projects/list.ts: withSaved(projects: Project[], saved: Project): Project[] (newest first, the saved project replaces the one with its id); renameProject(project: Project, name: string): Project | null (trimmed; null for an empty name; at most 120 characters, longer names cut); duplicateProject(project: Project, id: string, now: Date, timeZone: string): Project (name "{name} (copy)", new created_at with offset, time_zone, updated_at; everything else equal); NAME_EMPTY = "A project name needs at least one character."; HELD_NOTE = "Rename and Duplicate are available after step 3 makes this design again.".
    • ProjectMenu({ project, onRename, onDuplicate, onExport, onDelete }) and the sidebar buttons "Export all (.zip)" (disabled with no project) and, from Task 6, "Import project".
    • In helpers/records.ts: putRecords(page, records), storedProjects(page), makeOlder(page, otherSheet) moved from records.spec.ts without change.

projects.test.ts, new describe("raw records (3C C7, C10)") with fake-indexeddb:

  • after put(p), raw(p.id) deep-equals the stored object; for a record with report version "1.1.0" written with putRecord, get(id) gives report: null and pending_rebuild, and raw(id) still has the old report;
  • putRecord({ id: "x" }) rejects with INVALID_RECORD_ERROR; raw("missing") is undefined; the memory store passes the same three tests.

project-list.test.ts:

  • withSaved([b, a], a2) (a2 has a's id and a newer updated_at) is [a2, b]; withSaved([], p) is [p];
  • renameProject(p, " New name ")?.name is "New name"; renameProject(p, " ") is null; a 200-character name gives 120 characters;
  • duplicateProject(p, newId, now, "Europe/Madrid") has the new id, the name "Coating thickness (copy)", time_zone "Europe/Madrid", and equal spec, report, run_sheet_sha256, card, request;
  • exact texts of NAME_EMPTY and HELD_NOTE, with no advice words.

project-menu.spec.ts:

  • each project row has a button "Project actions: {name}" with aria-expanded; the panel has "Rename", "Duplicate", "Export project (.json)", "Delete"; Escape closes it and returns focus;
  • Rename: a field "Project name" with the current name, "Save name" stores the new name (sidebar, header and stored record), "Cancel" keeps it; an empty name shows NAME_EMPTY and keeps the old name;
  • Duplicate of the open project adds "… (copy)" to the list, opens the copy at the same step, and the stored copy has a new id;
  • Export project downloads {stem}.project.json whose text equals projectFileText(storedRecord, { includeRequest: true }) (read the stored record with storedProjects);
  • Delete asks Delete the project "{name}"? This cannot be undone. with "Delete project" and "Keep project"; "Delete project" removes the open project, the address loses project=, and focus is on "New design"; "Keep project" returns focus to "Project actions: {name}";
  • "Export all (.zip)" downloads experta-projects-{today}.zip with one .project.json per project;
  • a project made older with makeOlder(page, false) shows HELD_NOTE in its menu, Rename and Duplicate are absent, and Export project gives a file whose project.report.report_version is the old version;
  • after an IndexedDB onversionchange (open the database with version 2 from a second page of the same context), a rename shows CLOSED_ERROR in the sidebar ("The project database was closed because another tab changed it. Reload the page."), not the generic storage text (3B M3);
  • "Delete all" moves focus to "New design"; "Keep projects" returns it to "Clear" (C14).

Run: npx vitest run --project unit replication_materials/tests/unit/projects.test.ts replication_materials/tests/unit/project-list.test.ts and npx playwright test project-menu Expected: FAIL.

Workspace.saveProject uses withSaved instead of store.list() after put (C5). A store error whose message is CLOSED_ERROR shows that text; other errors keep STORE_ERROR. Export project and Export all read the records with store.raw() (C7). Delete of the open project calls go(null, 1).

Run: the commands of Step 2, then npx playwright test records (the moved helpers). Expected: PASS.

Run: npm run check → "All checks passed."

git add components lib/projects replication_materials/tests
git commit -m "feat: project menu (rename, duplicate, export, delete), Export all, raw store records, no list read after a save (spec 8.2, 8.3, C5, C7, C10, 3B M3)"

13Task 6: Import

Files:

  • Create: lib/exports/import.ts
  • Modify: lib/card/card.ts (add the origin imported and cardFromSpec), lib/card/labels.ts (label "From the project file"), lib/report/rebuild.ts (import texts), components/shell/ProjectSidebar.tsx, components/shell/Workspace.tsx, components/shell/StepPanel.tsx, components/steps/DesignStep.tsx
  • Test: replication_materials/tests/unit/import.test.ts, replication_materials/tests/unit/card.test.ts (extend), replication_materials/tests/unit/rebuild.test.ts (extend), replication_materials/tests/e2e/import.spec.ts

Interfaces:

  • Consumes: Task 1 (readProjectFile), Task 2 (unzipText), Task 5 (putRecord, withSaved); versionChanges, REBUILT, DIFFERS.
  • Produces:
    • In lib/exports/import.ts: type ImportFile = { name: string; bytes: Uint8Array }; type ImportOutcome = { records: Record<string, unknown>[]; projects: Project[]; messages: string[] }; importFiles(files: ImportFile[], existingIds: ReadonlySet<string>, newId: () => string): ImportOutcome (renames a clash per C2, in the record and in the project; a record with a spec and card: null gets card: cardFromSpec(spec, newId), C9); texts IMPORT_NOT_PROJECT = "{file}: this file is not an EXPERTA project file.", IMPORT_NEWER = "{file}: this project file has format version {version}. This version of the tool reads version 1.", IMPORT_INVALID = "{file}: the project in this file does not match the project format.", IMPORT_EMPTY_ZIP = "{file}: the archive has no project file.", importedCount(n: number): string ("1 project imported." / "{n} projects imported. Each one is checked when its step 3 opens.").
    • In lib/report/rebuild.ts: IMPORT_MATCH = "Project imported. The rebuilt design matches the stored run sheet (SHA-256)." (spec 8.5, exact) and IMPORT_DIFFERS = "Project imported. The rebuilt design differs from the stored run sheet (SHA-256). The stored run sheet is kept, because it is the design that was run. Versions that differ:".
    • In lib/card/card.ts: the origin { kind: "imported" } in OriginSchema (additive, like 3B P7), and cardFromSpec(spec: CoreSpec, newId: () => string): Card (each value from the spec, origin imported; cardToSpec(cardFromSpec(s)) gives s). In lib/card/labels.ts: the label "From the project file" for that origin.
    • StepProps gets importCheck: boolean (true when the project id is in the workspace's in-memory set of imported ids that step 3 has not checked yet) and onImportChecked: (id: string) => void.

import.test.ts:

  • one .project.json of coatingProject() with no clash gives one record equal to the file's project and no message;
  • the same file with the id in existingIds gives a record with the new id and the name "Coating thickness (imported)";
  • an .json with text "hello", an .json of another format, an .json of format_version: 2, and an .json whose project has id: "x" give the four exact messages, with the file name in place of {file};
  • a .zip from exportAllZip with two projects gives two records; a .zip with only notes.txt gives IMPORT_EMPTY_ZIP; bytes that are not a zip but named .zip give IMPORT_NOT_PROJECT;
  • a project file with report version "1.1.0" gives a record with the old report and a project with pending_rebuild;
  • importedCount(1) and importedCount(3) exact texts.

card.test.ts: cardToSpec(cardFromSpec(coatingSpec, id)) is { ok: true, spec: coatingSpec }; every field origin is imported; CardSchema parses a card with that origin; the label function gives "From the project file".

rebuild.test.ts: exact IMPORT_MATCH and IMPORT_DIFFERS texts; no advice words.

import.spec.ts:

  • the sidebar has a file input labelled "Import project" (accepts .json,.zip);
  • export a project with the project menu, Clear all, import the file: the project is listed, step 3 of it opens, R runs, and IMPORT_MATCH is shown and announced; the stored record equals the exported project;
  • import the same file again: a second project "… (imported)" appears;
  • a file whose stored run_sheet_sha256 is changed to 64 zeros (edit the downloaded text in the test) shows IMPORT_DIFFERS, and the stored record keeps its SHA-256 and report;
  • a text file named notes.json shows notes.json: this file is not an EXPERTA project file. in the sidebar and imports nothing;
  • an Export all archive with two projects imports both and shows "2 projects imported. Each one is checked when its step 3 opens.";
  • an imported file whose project has card: null and a spec shows the card in step 2 with the label "From the project file" (C9).

Run: npx vitest run --project unit replication_materials/tests/unit/import.test.ts replication_materials/tests/unit/card.test.ts replication_materials/tests/unit/rebuild.test.ts and npx playwright test import Expected: FAIL.

The workspace reads the chosen files (File.arrayBuffer()), calls importFiles, stores each record with putRecord, adds each imported project id to its in-memory set importChecks (the source of StepProps.importCheck), lists the messages under the import control (polite announcement of the count, assertive for errors), and opens step 3 of the first imported project. DesignStep with importCheck true and a current report runs the design, compares runSheetSha256 with the stored value, shows IMPORT_MATCH or IMPORT_DIFFERS with versionChanges(stored.versions, current), never replaces the stored report, and calls onImportChecked. A held imported project uses the P1 path; its text is prefixed with "Project imported. ".

Run: the commands of Step 2, then npx playwright test design records. Expected: PASS.

Run: npm run check → "All checks passed."

git add lib components replication_materials/tests
git commit -m "feat: import of project files and archives with the rebuild check of step 3 (spec 8.5, C2, C8, C9)"

14Task 7: Step 6, Report

Files:

  • Create: components/steps/ReportStep.tsx
  • Modify: components/shell/StepPanel.tsx (remove the placeholder text of step 6)
  • Test: replication_materials/tests/e2e/report.spec.ts

Interfaces:

  • Consumes: Task 2 (allFilesZip), Task 3 (reportHtml, REPORT_SECTIONS, CONTENTS_LABELS), Task 4 (downloadBytes, downloadText), isDesignProject, StepProps.
  • Produces: ReportStep(props: StepProps); texts NO_DESIGN = "Make the design in step 3 first.", HELD = "Step 3 makes this design again first.", ZIP_CAPTION = "Report, run sheets, R script, project file, README and checksums.".

report.spec.ts (coating fixture to step 3, then the step rail to "6 Report"):

  • a fieldset with the legend "Report contents" and six checked check boxes with the CONTENTS_LABELS, then two disabled check boxes "Responses, effects, plots and model (Phase 2)" and "AI-generated chart explanations (Phase 2)";
  • an iframe with the title "Report preview" whose document has the heading "… : design report" and six numbered sections; unchecking "Run sheet" leaves five, and the preview has no <table> in a section "Run sheet";
  • "Report (.html)" downloads {stem}-report.html whose text equals the iframe's srcdoc attribute;
  • "Print / PDF" calls the frame's print (replace print on the frame's window in the test and check that it was called);
  • "All files (.zip)" downloads {stem}-all-files.zip; its .R, .xlsx and .csv entries are byte-equal to the step 3 downloads of the same project (Task 4), its .project.json entry is byte-equal to "Export project (.json)" of the project menu (Task 5), and its report entry equals the "Report (.html)" download (spec 8.3: the same file from each place);
  • with "Request text" unchecked: the preview and the report download do not contain the request text, and the archive's project file has "request": null and no "kind": "quote";
  • a project without a design shows NO_DESIGN; a project made older shows HELD;
  • the caption ZIP_CAPTION is next to "All files (.zip)";
  • with a 256-run design, the preview loads and the report download has 256 body rows; record the time from the step 6 heading to the loaded frame in the test output.

Run: npx playwright test report Expected: FAIL (step 6 shows the placeholder).

The preview is <iframe title="Report preview" sandbox="allow-same-origin" srcDoc={html}>. The contents are React state (all checked at the start, C1). The HTML is built with useMemo from the project, the selected sections and the date of the first render.

Run: npx playwright test report downloads project-menu Expected: PASS.

Run: npm run check → "All checks passed."

git add components replication_materials/tests/e2e/report.spec.ts
git commit -m "feat: step 6 Report: contents, preview, report file, print, all files (spec 9.3, 8.3, C1, C11)"

15Task 8: Engine start texts and the stall rule

Files:

  • Modify: lib/webr/status-text.ts, lib/webr/client.ts
  • Test: replication_materials/tests/unit/status-text.test.ts, replication_materials/tests/unit/engine-client.test.ts (extend)

Interfaces:

  • Consumes: the existing engine client and its fake-worker tests.
  • Produces: engineStatusText with these visible texts: state idle, and state loading with total 0: "R engine: starting R"; loading with 0 < loaded < total: unchanged ("R engine: loading (n%), x of y MB"); loading with loaded === total > 0: "R engine: preparing the R packages". The announced text of every loading form stays "R engine: loading" (or "R engine: loading has stopped").

status-text.test.ts: the three visible texts above for { state: "idle" }, { state: "loading", loaded: 0, total: 0, stalled: false } and { state: "loading", loaded: 5e6, total: 5e6, stalled: false }; the announced texts unchanged.

engine-client.test.ts: with the fake worker and fake timers, after a progress message with loaded === total, advancing the time by 3 × the stall limit leaves stalled: false; after a progress message with loaded < total, advancing by the stall limit gives stalled: true (existing behaviour, kept).

Run: npx vitest run --project unit replication_materials/tests/unit/status-text.test.ts replication_materials/tests/unit/engine-client.test.ts Expected: FAIL.

In client.ts, the progress case arms the stall timer only when loaded < total, and clears it otherwise. Update the comment at the spawn (the stall rule and the measured reason of C3).

Run: the command of Step 2, then npx playwright test engine shell. Expected: PASS (update an existing Chromium assertion of "loading (0%)" to "starting R" if one exists, and say so in the report).

Run: npm run check → "All checks passed."

git add lib/webr replication_materials/tests
git commit -m "fix: engine start says starting R, and no stall after the last package byte (spec 7.9, 17.1, C3)"

16Task 9: Accessibility sweep, focus rules and the carried items

Files:

  • Create: replication_materials/tests/e2e/helpers/a11y.ts, replication_materials/tests/e2e/a11y.spec.ts
  • Modify: components/shell/Workspace.tsx (page titles), components/shell/ProjectSidebar.tsx (record focus), components/spec-card/inputs.tsx, components/spec-card/FactorTable.tsx (required fields, select value), components/steps/DescribeStep.tsx (draft, aria-disabled, Cancel focus), components/steps/DesignStep.tsx (Cancel focus), replication_materials/tests/unit/explain.test.ts (regex)
  • Test: a11y.spec.ts; describe.spec.ts, confirm.spec.ts, records.spec.ts (extend)

Interfaces:

  • Consumes: every page and control of Tasks 4 to 7.
  • Produces:
    • In helpers/a11y.ts: expectNoAxeViolations(page: Page): Promise<void> (AxeBuilder with the tags wcag2a, wcag2aa, wcag21a, wcag21aa, wcag22aa; on failure the message lists rule id, impact and the first three targets); expectCustomChecks(page: Page): Promise<void> with the five checks of spec 11:
      1. empty tab stops: every focusable, visible element has a non-empty accessible name (aria-label, aria-labelledby, text, title, alt or a label element), and no focusable element has a zero-size box;
      2. labels on generic elements: no div or span without a role has aria-label or aria-labelledby;
      3. placeholder contrast: every ::placeholder colour has at least 4.5:1 on its input's background;
      4. required markers: every label with a visible "" belongs to a control with aria-required="true", every such control has the "", and the page shows "Fields marked * are required." when it has one;
      5. page titles: the title is not empty and ends with "| EXPERTA" (or is the full name on the landing page).
    • Workspace sets document.title to Step ${n}: ${title} | EXPERTA on each step change (C13).
    • TextInput gets required?: boolean: the label shows the name and <span aria-hidden="true"> *</span>; the input gets aria-required="true". FactorTable passes required for name, low and high (labels "Name", "Low level", "High level").
    • SelectInput adds the option { value, text: \${value} (not available)` }` first when the field value is not empty and not in the options (C16).

a11y.spec.ts: one test per page state, each calling expectNoAxeViolations and expectCustomChecks: the landing page, the glossary (which also has no .katex-error element, spec 11 "Rendering"), the privacy page, step 1, step 1 with a clarification question, step 2 (coating card), step 2 with a disagreement flag (a fixture derived as in confirm.spec.ts), step 3, step 3 with "Show explanations" and the Download menu open, the project menu open with Rename open, step 3 after an import (the match text), step 6 (all contents), and the sidebar with a record that cannot be opened. Each workspace state also asserts its title (for example Step 3: Design | EXPERTA).

Extend the Chromium tests with the focus rules of C14:

  • records.spec.ts: "Delete record" moves focus to the heading "Your projects"; "Keep record" returns it to that record's "Delete";
  • describe.spec.ts: Cancel of a translation returns focus to the request field; while a request is in flight, "Interpret request" has aria-disabled="true" and keeps the focus; the draft: type text, follow the footer link "Glossary", go back, and the field has the text; after a successful interpretation, sessionStorage has no ffd-draft: key (C15);
  • design.spec.ts (existing Cancel test): after Cancel, the step heading has focus;
  • confirm.spec.ts: a disagreement on the number of runs with the choices 16 and 12 (derived fixture); choosing 12 shows the select value "12 (not available)", the error list names it, and "Generate design" stays unavailable (C16).

Fix the regex of explain.test.ts:80 to new RegExp(\\b${word}\b`, "i")` and add, in the same test, an assertion that the pattern matches "Try this." (so the check can fail) (3B deferred minor).

Run: npx playwright test a11y describe confirm records design and npx vitest run --project unit replication_materials/tests/unit/explain.test.ts Expected: FAIL (required markers, titles and focus rules); the explain test passes with the new assertion only after the fix.

Page titles, required fields, the select option, the focus moves of C14 (ref.focus() after the state change, in an effect where the target mounts later), aria-disabled while busy, and the draft in sessionStorage (all access in try/catch, spec 8.2).

Run: the commands of Step 2. Expected: PASS.

Run: npm run check → "All checks passed."

git add components replication_materials/tests
git commit -m "feat: accessibility sweep with the five custom checks, step titles, required fields, focus rules, kept draft (spec 9.10, 11, C13 to C16)"

17Task 10: The full flow and the returning visit

Files:

  • Create: replication_materials/tests/e2e/flow.spec.ts

Interfaces:

  • Consumes: everything above.
  • Produces: one Chromium test of the full Phase 1 flow (spec 11, "End-to-end").

flow.spec.ts, one test "the full flow from the landing page to the report, and a returning visit (spec 9.2, 11)":

  1. /?home shows the landing page; "Start a design" opens the workspace at step 1 (title Step 1: Describe | EXPERTA).
  2. The request of needs-clarification.json gives the question; the answer of clarification-answer.json gives the card in step 2.
  3. In step 2, move a factor down; the move is announced; "Generate design" opens step 3, where the design and its properties show.
  4. Download the .xlsx and the .csv from step 3.
  5. In step 6, download "All files (.zip)"; its .xlsx and .csv entries equal the step 3 downloads; its manifest's SHA-256 values match its entries.
  6. Rename the project to "Flow test" with the project menu; the header shows "Flow test".
  7. A new page in the same context opens /: it goes to the last page (/app?project=…&step=6) before the landing page is drawn (spec 9.2), and step 6 shows "Flow test: design report" in the preview.
  8. /?home still shows the landing page.

Run: npx playwright test flow Expected: PASS. A failure here is a defect in Tasks 1 to 9: fix it in the module that owns it, add a unit or Chromium test that fails without the fix, and say so in the report.

Run: npm run check → "All checks passed."

git add replication_materials/tests/e2e/flow.spec.ts
git commit -m "test: full Phase 1 flow from the landing page to the report, with a returning visit (spec 11)"

18Task 11: Documents

Files:

  • Modify: replication_materials/plans/2026-10-14-ff-design-translator-design.md (version 5), replication_materials/plans/spec-page/ (rebuilt), README.md, replication_materials/process/process.json

Interfaces:

  • Consumes: the decisions C1 to C18 and the measured facts.
  • Produces: spec version 5; the build event of Milestone 3C.
  • Version row: "5, dated 2026-10-17. … Version 5 records the decisions of the Milestone 3C plan (2026-10-17-m3c-exports-plan.md, C1 to C18)."
  • 8.1: after "the request text and the clarification turns (optional in exports)", add "(Step 6, "Request text", checked by default; decision C1)".
  • 8.2: add "sessionStorage keeps the unsent text of step 1 until the interpretation is stored (decision C15)."; "Export all" and "Import" formats of C8.
  • 8.3: add the project file format of C7, the CSV rules of C12, and "The .xlsx file is written as OOXML with fflate; its hidden sheet experta holds the project id, the run-sheet SHA-256, the app version, the report version and the format version (C6)."
  • 8.5: add C2 and C9.
  • 9.3, row "2 Confirm": add "From the project file" to the list of labels (C9).
  • 9.10: add the step titles and the five custom checks of C13 and the focus rules of C14.
  • 7.9: replace "A progress bar shows bytes." with "A progress bar shows the bytes of the R packages. WebR's own start (R.wasm, 12.3 MB compressed) sends no progress; the footer says "R engine: starting R" (decision C3, measured)."
  • 17.1: mark these rows done, each with its measured fact: download progress for WebR's own files (C3), the main-thread parse of the largest reports (C4: 40 ms), the focus after "Clear" and after Cancel and the size of the Home link (C14; axe target-size passes), each save reads all projects (C5). Add one row: "Phase 2 upload reads .xlsx files that Excel saves (shared strings, styles); the 3C writer uses inline strings only. | Fadel M. Megahed | Phase 2".
  • Run node scripts/build-spec-page.mjs.

README.md: one short section "Exports and import" (the six files, where each is offered, the import check). process.json: add event e27, stage build, actors ["claude", "agents", "records"], title "Milestone 3C: exports, import and report", one detail paragraph in the style of e25, records ["tests/"]; set status_date to the commit date. Run node scripts/build-spec-page.mjs again (the process figure is in the spec page).

Run: npm run check → "All checks passed."

git add README.md replication_materials
git commit -m "docs: specification version 5 with the Milestone 3C decisions, README exports section, build event"