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 (
.npmrchassave-exact=trueandlegacy-peer-deps=true). Two new packages, installed by the controller during execution setup (one Dropbox pause):fflate@0.8.3as a dependency (published 2026-05-16) and@axe-core/playwright@4.13.0as a devDependency (published 2026-08-11; it bringsaxe-core~4.13.0; 4.14.0 is from 2026-10-15 and too new). No other new package. Implementers do not runnpm install. - Dropbox. The repository is in Dropbox, which locks
node_moduleswhile 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.randomorcrypto.randomUUID. A builder that writes a date takesnow: Date. Zip entries get an explicitmtime. 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.tsorlib/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/translatefrom 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 usesaria-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_rebuildset); 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 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. 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 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.
- 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). - 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
.zipwithout 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). - The largest design in every file: 32,768 rows in
.xlsx,.csvand 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). - 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).
- 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(addfactorHeaders),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 }andisDesignProject(p: Project | null): p is DesignProjectinlib/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[]inlib/exports/names.ts.factorHeaders(report: Report, spec: CoreSpec): string[]inlib/report/format.ts.runSheetCsv(report: Report, spec: CoreSpec): string,runSheetTsv(report: Report, spec: CoreSpec): stringinlib/exports/csv.ts.rScript(project: DesignProject, now: Date): stringinlib/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): ProjectFileReadinlib/exports/project-file.ts.coatingProject(fields?: Partial<Project>): DesignProjectinreplication_materials/tests/fixtures/projects.ts: id6f1c2a7e-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_sha256fromrunSheetSha256(report)computed once in the fixture module withnode:crypto(same text asrunSheetText),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)).lengthis 60.exportFileName("Coating thickness", k)gives, forxlsx,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
runSheetCsvisRun,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 areNA; runSheetTsvhas 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, thengenerateDesignCode(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: nullwritesunknownfor 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 norequire(.
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.requestis null and every card field origin of kindquotebecomes{ kind: "model" }(use a card fromcardFromTranslationof the coating fixture answer); readProjectFileof that text givesok: trueand a project equal to the input; of"not json"givesreason: "not_json"; of{"format":"other"}givesnot_project_file; of a file withformat_version: 2givesnewer_formatwithversion: 2; of a file whose project hasid: "x"givesinvalid_project;- a file whose project has a report with
report_version: "1.1.0"givesok: true,project.reportnull andproject.pending_rebuild{ report_version: "1.1.0" }, andrecord.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) inlib/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>inlib/exports/archive.ts.readmeText(project: DesignProject, now: Date): stringinlib/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.xmlnames the sheets "Run sheet" and "experta", and the second hasstate="hidden";- sheet 1, row 1: Run, Std, the 7
factorHeaders, Response; row 2:1,16,200,3as number cells (<v>),fastas 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; readHiddenSheetgives{ 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 asFeed <&> "rate"in sheet 1; docProps/core.xmlhas<dcterms:created xsi:type="dcterms:W3CDTF">2026-10-14T13:05:07Z</dcterms:created>(the project'screated_atin 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 entriescoating-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.jsonis{ 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 eachsha256equalssha256Hexof that entry;- with
includeRequest: false, the project file entry hasproject.requestnull; - two calls with the same
nowgive equal bytes; exportAllZip([a, b, aAgain], now)hascoating-thickness.project.json,other.project.json,coating-thickness-2.project.json, each equal toprojectFileText(record, { includeRequest: true });unzipTextofexportAllZip(...)gives the three texts by name;readmeTextnames 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 subtitleEXPERTA: Fractional Factorial Design Translator, version 0.2.0 · 2026-10-17 · FrF2 2.3.5 in WebR 0.6.0; six<h2>headings1. Request…6. Versions and citationin 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 generatorsFeed = Temp:Pressure:Speed, the WLP, "0 of 21" clear 2FIs, and each alias chain ofaliasSentences; - the run sheet section is a
<table>with a<caption>,<th scope="col">headers (Run, Std andfactorHeaders), 16 body rows in run order, and the first row1, 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
20261014and the text ofCITATION; - without
requestthe 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 namedA & Bappear escaped; the text has no<script, no<link, nosrc=, nohref=, nohttp://and nohttps://, no@importand nourl(; - the CSS has
@media print,break-before: pageforh2,display: table-header-groupforthead, 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): voidinlib/projects/download.ts(downloadJsonstays).DownloadMenu({ project, go }: { project: DesignProject; go: (project: string | null, step: number) => void }).DesignViewgets the propproject: DesignProjectandgo, and rendersDownloadMenubeside "Print" (thereport,spec,code,frf2Messagesprops come fromproject).- In
helpers/files.ts:captureDownload(page: Page, click: () => Promise<void>): Promise<{ name: string; bytes: Buffer }>(waits for thedownloadevent and reads the file) andunzipEntries(bytes: Buffer): Record<string, Buffer>(withfflate).
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
.xlsxdownload is namedwe-want-to-screen-7-factors-run-sheet.xlsx(the name of the project from its request, 3B P14) and itsxl/workbook.xmlhasstate="hidden"; its hidden sheet'sproject_idequals theprojectquery value of the page; - the
.csvdownload equalsrunSheetCsv(project.report, project.spec)of the stored project (read it withstoredProjectsfromhelpers/records.ts, which Task 5 creates; until then read IndexedDB in the test asrecords.spec.tsdoes) and has 17 lines; - the
.Rdownload contains the text of the page'spre codeelement after its four header lines; - "Copy table" (with
context.grantPermissions(["clipboard-read", "clipboard-write"])) puts text on the clipboard whose first line startsRun\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) andputRecord(record: Record<string, unknown>): Promise<void>(writes the record unchanged afterreadRecord(record).kind === "project"; rejects otherwise withINVALID_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)", newcreated_atwith 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 fromrecords.spec.tswithout change.
- In
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 withputRecord,get(id)givesreport: nullandpending_rebuild, andraw(id)still has the old report; putRecord({ id: "x" })rejects withINVALID_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 newerupdated_at) is[a2, b];withSaved([], p)is[p];renameProject(p, " New name ")?.nameis "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 equalspec,report,run_sheet_sha256,card,request;- exact texts of
NAME_EMPTYandHELD_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_EMPTYand 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.jsonwhose text equalsprojectFileText(storedRecord, { includeRequest: true })(read the stored record withstoredProjects); - Delete asks
Delete the project "{name}"? This cannot be undone.with "Delete project" and "Keep project"; "Delete project" removes the open project, the address losesproject=, and focus is on "New design"; "Keep project" returns focus to "Project actions: {name}"; - "Export all (.zip)" downloads
experta-projects-{today}.zipwith one.project.jsonper project; - a project made older with
makeOlder(page, false)showsHELD_NOTEin its menu, Rename and Duplicate are absent, and Export project gives a file whoseproject.report.report_versionis the old version; - after an IndexedDB
onversionchange(open the database with version 2 from a second page of the same context), a rename showsCLOSED_ERRORin 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 originimportedandcardFromSpec),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 andcard: nullgetscard: cardFromSpec(spec, newId), C9); textsIMPORT_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) andIMPORT_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" }inOriginSchema(additive, like 3B P7), andcardFromSpec(spec: CoreSpec, newId: () => string): Card(each value from the spec, originimported;cardToSpec(cardFromSpec(s))givess). Inlib/card/labels.ts: the label "From the project file" for that origin. StepPropsgetsimportCheck: boolean(true when the project id is in the workspace's in-memory set of imported ids that step 3 has not checked yet) andonImportChecked: (id: string) => void.
- In
import.test.ts:
- one
.project.jsonofcoatingProject()with no clash gives one record equal to the file's project and no message; - the same file with the id in
existingIdsgives a record with the new id and the name "Coating thickness (imported)"; - an
.jsonwith text "hello", an.jsonof another format, an.jsonofformat_version: 2, and an.jsonwhose project hasid: "x"give the four exact messages, with the file name in place of{file}; - a
.zipfromexportAllZipwith two projects gives two records; a.zipwith onlynotes.txtgivesIMPORT_EMPTY_ZIP; bytes that are not a zip but named.zipgiveIMPORT_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)andimportedCount(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_MATCHis 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_sha256is changed to 64 zeros (edit the downloaded text in the test) showsIMPORT_DIFFERS, and the stored record keeps its SHA-256 and report; - a text file named
notes.jsonshowsnotes.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: nulland 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); textsNO_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.htmlwhose text equals the iframe'ssrcdocattribute; - "Print / PDF" calls the frame's
print(replaceprinton the frame's window in the test and check that it was called); - "All files (.zip)" downloads
{stem}-all-files.zip; its.R,.xlsxand.csventries are byte-equal to the step 3 downloads of the same project (Task 4), its.project.jsonentry 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": nulland no"kind": "quote"; - a project without a design shows
NO_DESIGN; a project made older showsHELD; - the caption
ZIP_CAPTIONis 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:
engineStatusTextwith these visible texts: stateidle, and stateloadingwithtotal0: "R engine: starting R";loadingwith0 < loaded < total: unchanged ("R engine: loading (n%), x of y MB");loadingwithloaded === 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 tagswcag2a,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:- empty tab stops: every focusable, visible element has a non-empty accessible name (
aria-label,aria-labelledby, text,title,altor a label element), and no focusable element has a zero-size box; - labels on generic elements: no
divorspanwithout a role hasaria-labeloraria-labelledby; - placeholder contrast: every
::placeholdercolour has at least 4.5:1 on its input's background; - 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; - page titles: the title is not empty and ends with "| EXPERTA" (or is the full name on the landing page).
- empty tab stops: every focusable, visible element has a non-empty accessible name (
Workspacesetsdocument.titletoStep ${n}: ${title} | EXPERTAon each step change (C13).TextInputgetsrequired?: boolean: the label shows the name and<span aria-hidden="true"> *</span>; the input getsaria-required="true".FactorTablepassesrequiredfor name, low and high (labels "Name", "Low level", "High level").SelectInputadds the option{ value, text: \${value} (not available)` }` first when the field value is not empty and not in the options (C16).
- In
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" hasaria-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,sessionStoragehas noffd-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)":
/?homeshows the landing page; "Start a design" opens the workspace at step 1 (titleStep 1: Describe | EXPERTA).- The request of
needs-clarification.jsongives the question; the answer ofclarification-answer.jsongives the card in step 2. - In step 2, move a factor down; the move is announced; "Generate design" opens step 3, where the design and its properties show.
- Download the
.xlsxand the.csvfrom step 3. - In step 6, download "All files (.zip)"; its
.xlsxand.csventries equal the step 3 downloads; its manifest's SHA-256 values match its entries. - Rename the project to "Flow test" with the project menu; the header shows "Flow test".
- 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. /?homestill 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
.xlsxfile is written as OOXML with fflate; its hidden sheetexpertaholds 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-sizepasses), each save reads all projects (C5). Add one row: "Phase 2 upload reads.xlsxfiles 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"