From 9fcd4d87e308da82feba72db41068cc8d27a4d05 Mon Sep 17 00:00:00 2001
From: iGor milhit
Date: Sun, 11 Jan 2026 19:59:31 +0100
Subject: [PATCH] web: refactor in order to get an actual interface
- Splits the app in pages, components and services.
- Handles the routing.
- Creates an API service.
- Uses Pico CSS.
- Validates the form. TODO: to add validation state to the form.
- Updates the validation rules in the backend to be coherent on both
sides.
- Adds tests.
Co-Authored-by: iGor milhit
---
bread.py | 8 +-
tests/test_bread.py | 12 -
web/.env.development | 2 +
web/.env.example | 2 +
web/.gitignore | 10 +
web/package-lock.json | 797 ++++++++++++++++++
web/package.json | 10 +-
web/src/App.css | 49 --
web/src/App.jsx | 260 +-----
web/src/App.test.jsx | 12 -
web/src/components/BreadForm.jsx | 139 +++
web/src/components/BreadResult.jsx | 63 ++
web/src/components/ErrorMessage.jsx | 18 +
web/src/components/Layout.jsx | 27 +
web/src/components/Navigation.jsx | 40 +
.../components/__tests__/BreadForm.test.jsx | 60 ++
.../components/__tests__/BreadResult.test.jsx | 57 ++
.../__tests__/ErrorMessage.test.jsx | 36 +
web/src/main.jsx | 13 +-
web/src/pages/About.jsx | 62 ++
web/src/pages/Calculator.jsx | 67 ++
web/src/pages/Home.jsx | 77 ++
web/src/pages/NotFound.jsx | 24 +
web/src/services/__tests__/api.test.js | 108 +++
web/src/services/api.js | 71 ++
web/src/styles/_mixins.scss | 42 +
web/src/styles/main.scss | 25 +
web/src/test/setup.js | 3 +
web/vite.config.js | 8 +-
29 files changed, 1792 insertions(+), 310 deletions(-)
create mode 100644 web/.env.development
create mode 100644 web/.env.example
delete mode 100644 web/src/App.css
delete mode 100644 web/src/App.test.jsx
create mode 100644 web/src/components/BreadForm.jsx
create mode 100644 web/src/components/BreadResult.jsx
create mode 100644 web/src/components/ErrorMessage.jsx
create mode 100644 web/src/components/Layout.jsx
create mode 100644 web/src/components/Navigation.jsx
create mode 100644 web/src/components/__tests__/BreadForm.test.jsx
create mode 100644 web/src/components/__tests__/BreadResult.test.jsx
create mode 100644 web/src/components/__tests__/ErrorMessage.test.jsx
create mode 100644 web/src/pages/About.jsx
create mode 100644 web/src/pages/Calculator.jsx
create mode 100644 web/src/pages/Home.jsx
create mode 100644 web/src/pages/NotFound.jsx
create mode 100644 web/src/services/__tests__/api.test.js
create mode 100644 web/src/services/api.js
create mode 100644 web/src/styles/_mixins.scss
create mode 100644 web/src/styles/main.scss
create mode 100644 web/src/test/setup.js
diff --git a/bread.py b/bread.py
index 86653cb..ca22153 100644
--- a/bread.py
+++ b/bread.py
@@ -29,19 +29,19 @@ class BreadRecipe(BaseModel):
All percentages follow baker's percentage (relative to total flour weight).
"""
# What the user provides
- total_flour: float = Field(gt=0, description="Total flour weight in grams")
+ total_flour: float = Field(gt=1, description="Total flour weight in grams")
hydration: float = Field(
ge=50, le=100, description="Total hydration % (water/flour)"
)
sourdough_percentage: float = Field(
- ge=0,
- le=50,
+ ge=10,
+ le=100,
description="Sourdough as % of total flour weight"
)
sourdough_ratio: SourdoughRatio = Field(
description="Flour:water ratio in starter"
)
- salt: float = Field(ge=0, le=5, description="Salt as % of total flour")
+ salt: float = Field(ge=0, le=2, description="Salt as % of total flour")
# What Pydantic calculates automatically
@computed_field
diff --git a/tests/test_bread.py b/tests/test_bread.py
index 97e722c..a176b49 100644
--- a/tests/test_bread.py
+++ b/tests/test_bread.py
@@ -108,15 +108,3 @@ class TestBreadRecipe:
)
assert recipe.salt_weight == 10.0
- def test_no_sourdough_recipe(self):
- """Test recipe without sourdough (0%)."""
- recipe = BreadRecipe(
- total_flour=500,
- hydration=70,
- sourdough_percentage=0,
- sourdough_ratio=SourdoughRatio(flour_parts=1, water_parts=1),
- salt=2
- )
- assert recipe.sourdough_weight == 0.0
- assert recipe.flour_to_add == 500.0
- assert recipe.water_to_add == 350.0
diff --git a/web/.env.development b/web/.env.development
new file mode 100644
index 0000000..372f043
--- /dev/null
+++ b/web/.env.development
@@ -0,0 +1,2 @@
+# web/.env.development
+VITE_API_URL=http://localhost:8000
diff --git a/web/.env.example b/web/.env.example
new file mode 100644
index 0000000..ede63aa
--- /dev/null
+++ b/web/.env.example
@@ -0,0 +1,2 @@
+# API URL (required)
+VITE_API_URL=http://localhost:8000
diff --git a/web/.gitignore b/web/.gitignore
index 503bb51..5314e65 100644
--- a/web/.gitignore
+++ b/web/.gitignore
@@ -24,3 +24,13 @@ dist-ssr
*.sw?
.vite
+
+.env
+.env.local
+.env*.local
+!.env.example
+!.env.template
+!env.development
+!env.production
+
+coverage
diff --git a/web/package-lock.json b/web/package-lock.json
index 5960304..27ec3a6 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -13,16 +13,22 @@
},
"devDependencies": {
"@eslint/js": "^9.39.1",
+ "@picocss/pico": "^2.1.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.1",
+ "@testing-library/user-event": "^14.6.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
+ "@vitest/coverage-v8": "^4.0.16",
+ "@vitest/ui": "^4.0.16",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"jsdom": "^27.4.0",
+ "react-router-dom": "^7.12.0",
+ "sass": "^1.97.2",
"vite": "^7.2.4",
"vitest": "^4.0.16"
}
@@ -389,6 +395,16 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@bcoe/v8-coverage": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
+ "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@csstools/color-helpers": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
@@ -1245,6 +1261,330 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@parcel/watcher": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz",
+ "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "detect-libc": "^1.0.3",
+ "is-glob": "^4.0.3",
+ "micromatch": "^4.0.5",
+ "node-addon-api": "^7.0.0"
+ },
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "@parcel/watcher-android-arm64": "2.5.1",
+ "@parcel/watcher-darwin-arm64": "2.5.1",
+ "@parcel/watcher-darwin-x64": "2.5.1",
+ "@parcel/watcher-freebsd-x64": "2.5.1",
+ "@parcel/watcher-linux-arm-glibc": "2.5.1",
+ "@parcel/watcher-linux-arm-musl": "2.5.1",
+ "@parcel/watcher-linux-arm64-glibc": "2.5.1",
+ "@parcel/watcher-linux-arm64-musl": "2.5.1",
+ "@parcel/watcher-linux-x64-glibc": "2.5.1",
+ "@parcel/watcher-linux-x64-musl": "2.5.1",
+ "@parcel/watcher-win32-arm64": "2.5.1",
+ "@parcel/watcher-win32-ia32": "2.5.1",
+ "@parcel/watcher-win32-x64": "2.5.1"
+ }
+ },
+ "node_modules/@parcel/watcher-android-arm64": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz",
+ "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-darwin-arm64": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz",
+ "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-darwin-x64": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz",
+ "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-freebsd-x64": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz",
+ "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-linux-arm-glibc": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz",
+ "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-linux-arm-musl": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz",
+ "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-linux-arm64-glibc": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz",
+ "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-linux-arm64-musl": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz",
+ "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-linux-x64-glibc": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz",
+ "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-linux-x64-musl": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz",
+ "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-win32-arm64": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz",
+ "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-win32-ia32": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz",
+ "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-win32-x64": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz",
+ "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@picocss/pico": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@picocss/pico/-/pico-2.1.1.tgz",
+ "integrity": "sha512-kIDugA7Ps4U+2BHxiNHmvgPIQDWPDU4IeU6TNRdvXQM1uZX+FibqDQT2xUOnnO2yq/LUHcwnGlu1hvf4KfXnMg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@polka/url": {
+ "version": "1.0.0-next.29",
+ "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
+ "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.53",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz",
@@ -1643,6 +1983,20 @@
}
}
},
+ "node_modules/@testing-library/user-event": {
+ "version": "14.6.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz",
+ "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": ">=7.21.4"
+ }
+ },
"node_modules/@types/aria-query": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
@@ -1770,6 +2124,38 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
+ "node_modules/@vitest/coverage-v8": {
+ "version": "4.0.16",
+ "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.16.tgz",
+ "integrity": "sha512-2rNdjEIsPRzsdu6/9Eq0AYAzYdpP6Bx9cje9tL3FE5XzXRQF1fNU9pe/1yE8fCrS0HD+fBtt6gLPh6LI57tX7A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@bcoe/v8-coverage": "^1.0.2",
+ "@vitest/utils": "4.0.16",
+ "ast-v8-to-istanbul": "^0.3.8",
+ "istanbul-lib-coverage": "^3.2.2",
+ "istanbul-lib-report": "^3.0.1",
+ "istanbul-lib-source-maps": "^5.0.6",
+ "istanbul-reports": "^3.2.0",
+ "magicast": "^0.5.1",
+ "obug": "^2.1.1",
+ "std-env": "^3.10.0",
+ "tinyrainbow": "^3.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@vitest/browser": "4.0.16",
+ "vitest": "4.0.16"
+ },
+ "peerDependenciesMeta": {
+ "@vitest/browser": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@vitest/expect": {
"version": "4.0.16",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.16.tgz",
@@ -1867,6 +2253,29 @@
"url": "https://opencollective.com/vitest"
}
},
+ "node_modules/@vitest/ui": {
+ "version": "4.0.16",
+ "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.0.16.tgz",
+ "integrity": "sha512-rkoPH+RqWopVxDnCBE/ysIdfQ2A7j1eDmW8tCxxrR9nnFBa9jKf86VgsSAzxBd1x+ny0GC4JgiD3SNfRHv3pOg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@vitest/utils": "4.0.16",
+ "fflate": "^0.8.2",
+ "flatted": "^3.3.3",
+ "pathe": "^2.0.3",
+ "sirv": "^3.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "vitest": "4.0.16"
+ }
+ },
"node_modules/@vitest/utils": {
"version": "4.0.16",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.16.tgz",
@@ -1985,6 +2394,25 @@
"node": ">=12"
}
},
+ "node_modules/ast-v8-to-istanbul": {
+ "version": "0.3.10",
+ "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.10.tgz",
+ "integrity": "sha512-p4K7vMz2ZSk3wN8l5o3y2bJAoZXT3VuJI5OLTATY/01CYWumWvwkUw0SqDBnNq6IiTO3qDa1eSQDibAV8g7XOQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.31",
+ "estree-walker": "^3.0.3",
+ "js-tokens": "^9.0.1"
+ }
+ },
+ "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
+ "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -2023,6 +2451,20 @@
"concat-map": "0.0.1"
}
},
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/browserslist": {
"version": "4.28.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
@@ -2116,6 +2558,22 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
+ "node_modules/chokidar": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
+ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "readdirp": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 14.16.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -2150,6 +2608,20 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/cookie": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
+ "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -2264,6 +2736,20 @@
"node": ">=6"
}
},
+ "node_modules/detect-libc": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
+ "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "bin": {
+ "detect-libc": "bin/detect-libc.js"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
"node_modules/dom-accessibility-api": {
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
@@ -2607,6 +3093,13 @@
}
}
},
+ "node_modules/fflate": {
+ "version": "0.8.2",
+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
+ "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -2620,6 +3113,20 @@
"node": ">=16.0.0"
}
},
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/find-up": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
@@ -2749,6 +3256,13 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
+ "node_modules/html-escaper": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
+ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/http-proxy-agent": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
@@ -2787,6 +3301,13 @@
"node": ">= 4"
}
},
+ "node_modules/immutable": {
+ "version": "5.1.4",
+ "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz",
+ "integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -2847,6 +3368,17 @@
"node": ">=0.10.0"
}
},
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
@@ -2861,6 +3393,60 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/istanbul-lib-coverage": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
+ "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/istanbul-lib-report": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
+ "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "istanbul-lib-coverage": "^3.0.0",
+ "make-dir": "^4.0.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/istanbul-lib-source-maps": {
+ "version": "5.0.6",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz",
+ "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.23",
+ "debug": "^4.1.1",
+ "istanbul-lib-coverage": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/istanbul-reports": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
+ "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "html-escaper": "^2.0.0",
+ "istanbul-lib-report": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -3046,6 +3632,47 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
+ "node_modules/magicast": {
+ "version": "0.5.1",
+ "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.1.tgz",
+ "integrity": "sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.28.5",
+ "@babel/types": "^7.28.5",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/make-dir": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
+ "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.5.3"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/make-dir/node_modules/semver": {
+ "version": "7.7.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
+ "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/mdn-data": {
"version": "2.12.2",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz",
@@ -3053,6 +3680,35 @@
"dev": true,
"license": "CC0-1.0"
},
+ "node_modules/micromatch": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "braces": "^3.0.3",
+ "picomatch": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=8.6"
+ }
+ },
+ "node_modules/micromatch/node_modules/picomatch": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
"node_modules/min-indent": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
@@ -3076,6 +3732,16 @@
"node": "*"
}
},
+ "node_modules/mrmime": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
+ "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -3109,6 +3775,14 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/node-addon-api": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
+ "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/node-releases": {
"version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
@@ -3368,6 +4042,60 @@
"node": ">=0.10.0"
}
},
+ "node_modules/react-router": {
+ "version": "7.12.0",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz",
+ "integrity": "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "^1.0.1",
+ "set-cookie-parser": "^2.6.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-router-dom": {
+ "version": "7.12.0",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.12.0.tgz",
+ "integrity": "sha512-pfO9fiBcpEfX4Tx+iTYKDtPbrSLLCbwJ5EqP+SPYQu1VYCXdy79GSj0wttR0U4cikVdlImZuEZ/9ZNCgoaxwBA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "react-router": "7.12.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
+ "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.18.0"
+ },
+ "funding": {
+ "type": "individual",
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
"node_modules/redent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
@@ -3444,6 +4172,28 @@
"fsevents": "~2.3.2"
}
},
+ "node_modules/sass": {
+ "version": "1.97.2",
+ "resolved": "https://registry.npmjs.org/sass/-/sass-1.97.2.tgz",
+ "integrity": "sha512-y5LWb0IlbO4e97Zr7c3mlpabcbBtS+ieiZ9iwDooShpFKWXf62zz5pEPdwrLYm+Bxn1fnbwFGzHuCLSA9tBmrw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "chokidar": "^4.0.0",
+ "immutable": "^5.0.2",
+ "source-map-js": ">=0.6.2 <2.0.0"
+ },
+ "bin": {
+ "sass": "sass.js"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "optionalDependencies": {
+ "@parcel/watcher": "^2.4.1"
+ }
+ },
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
@@ -3473,6 +4223,13 @@
"semver": "bin/semver.js"
}
},
+ "node_modules/set-cookie-parser": {
+ "version": "2.7.2",
+ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
+ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -3503,6 +4260,21 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/sirv": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
+ "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@polka/url": "^1.0.0-next.24",
+ "mrmime": "^2.0.0",
+ "totalist": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -3637,6 +4409,30 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/totalist": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
+ "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/tough-cookie": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz",
@@ -3799,6 +4595,7 @@
"integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@vitest/expect": "4.0.16",
"@vitest/mocker": "4.0.16",
diff --git a/web/package.json b/web/package.json
index 76b1963..42ca851 100644
--- a/web/package.json
+++ b/web/package.json
@@ -8,7 +8,9 @@
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview",
- "test": "vitest"
+ "test": "vitest",
+ "test:ui": "vitest --ui",
+ "test:coverage": "vitest --coverage"
},
"dependencies": {
"react": "^19.2.0",
@@ -16,16 +18,22 @@
},
"devDependencies": {
"@eslint/js": "^9.39.1",
+ "@picocss/pico": "^2.1.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.1",
+ "@testing-library/user-event": "^14.6.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
+ "@vitest/coverage-v8": "^4.0.16",
+ "@vitest/ui": "^4.0.16",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"jsdom": "^27.4.0",
+ "react-router-dom": "^7.12.0",
+ "sass": "^1.97.2",
"vite": "^7.2.4",
"vitest": "^4.0.16"
}
diff --git a/web/src/App.css b/web/src/App.css
deleted file mode 100644
index 4062ddb..0000000
--- a/web/src/App.css
+++ /dev/null
@@ -1,49 +0,0 @@
-#root {
- max-width: 1280px;
- margin: 0 auto;
- padding: 2rem;
- /* text-align: center; */
-}
-
-.logo {
- height: 6em;
- padding: 1.5em;
- will-change: filter;
- transition: filter 300ms;
-}
-.logo:hover {
- filter: drop-shadow(0 0 2em #646cffaa);
-}
-.logo.react:hover {
- filter: drop-shadow(0 0 2em #61dafbaa);
-}
-
-@keyframes logo-spin {
- from {
- transform: rotate(0deg);
- }
- to {
- transform: rotate(360deg);
- }
-}
-
-@media (prefers-reduced-motion: no-preference) {
- a:nth-of-type(2) .logo {
- animation: logo-spin infinite 20s linear;
- }
-}
-
-.card {
- padding: 2em;
-}
-
-.read-the-docs {
- color: #888;
-}
-
-form,
-label,
-input,
-button {
- display: block;
-}
diff --git a/web/src/App.jsx b/web/src/App.jsx
index 5845686..4609215 100644
--- a/web/src/App.jsx
+++ b/web/src/App.jsx
@@ -1,229 +1,39 @@
-import { useState } from "react";
-import "./App.css";
+// web/src/App.jsx
+import { createBrowserRouter, RouterProvider } from 'react-router-dom'
+import Layout from './components/Layout'
+import Home from './pages/Home'
+import About from "./pages/About";
+import Calculator from './pages/Calculator'
+import NotFound from './pages/NotFound'
+
+// Define the routes
+const router = createBrowserRouter([
+ {
+ path: '/',
+ element: ,
+ children: [
+ {
+ index: true,
+ element: ,
+ },
+ {
+ path: 'about',
+ element: ,
+ },
+ {
+ path: 'calculator',
+ element: ,
+ },
+ {
+ path: '*',
+ element: ,
+ },
+ ],
+ },
+])
function App() {
- const [formData, setFormData] = useState({
- total_flour: "",
- hydration: "",
- sourdough_percentage: "",
- sourdough_flour_parts: "1",
- sourdough_water_parts: "1",
- salt: "",
- });
-
- const [result, setResult] = useState(null);
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState(null);
-
- const handleChange = (e) => {
- const { name, value } = e.target;
- setFormData((prev) => ({
- ...prev,
- [name]: value,
- }));
- };
-
- const handleSubmit = async (e) => {
- e.preventDefault();
- setLoading(true);
- setError(null);
-
- try {
- // Transform form data to API format
- const apiData = {
- total_flour: parseFloat(formData.total_flour),
- hydration: parseFloat(formData.hydration),
- sourdough_percentage: parseFloat(formData.sourdough_percentage),
- sourdough_ratio: {
- flour_parts: parseFloat(formData.sourdough_flour_parts),
- water_parts: parseFloat(formData.sourdough_water_parts),
- },
- salt: parseFloat(formData.salt),
- };
-
- const response = await fetch("http://localhost:8000/calculate", {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify(apiData),
- });
-
- if (!response.ok) {
- const errorData = await response.json();
- throw new Error(
- errorData.detail || `HTTP error! status: ${response.status}`
- );
- }
-
- const data = await response.json();
- setResult(data);
- } catch (err) {
- setError(err.message);
- } finally {
- setLoading(false);
- }
- };
-
- return (
-
-
Bread Dough Calculator
-
-
-
- {error && (
-
- )}
-
- {result && (
-
-
Ingredients to Add
-
-
- Flour:
- {result.ingredients_to_add.flour}g
-
-
- Water:
- {result.ingredients_to_add.water}g
-
-
- Sourdough:
-
- {result.ingredients_to_add.sourdough}g
-
-
-
- Salt:
- {result.ingredients_to_add.salt}g
-
-
-
-
Recipe Details
-
-
- Total flour:
- {result.details.total_flour}g
-
-
- Total water:
- {result.details.total_water}g
-
-
- Flour in sourdough:
-
- {result.details.flour_in_sourdough}g
-
-
-
- Water in sourdough:
-
- {result.details.water_in_sourdough}g
-
-
-
-
- )}
-
- );
+ return
}
-export default App;
+export default App
diff --git a/web/src/App.test.jsx b/web/src/App.test.jsx
deleted file mode 100644
index 2c5d00f..0000000
--- a/web/src/App.test.jsx
+++ /dev/null
@@ -1,12 +0,0 @@
-// web/src/App.test.jsx
-import { describe, it, expect } from 'vitest'
-import { render, screen } from '@testing-library/react'
-import App from './App'
-
-describe('App', () => {
- it('affiche le formulaire', () => {
- render( )
- expect(screen.getByLabelText(/farine/i)).toBeInTheDocument()
- expect(screen.getByRole('button', { name: /calculer/i })).toBeInTheDocument()
- })
-})
diff --git a/web/src/components/BreadForm.jsx b/web/src/components/BreadForm.jsx
new file mode 100644
index 0000000..4d57d75
--- /dev/null
+++ b/web/src/components/BreadForm.jsx
@@ -0,0 +1,139 @@
+// web/src/components/BreadForm.jsx
+
+function BreadForm({ onSubmit, loading }) {
+ return (
+
+ );
+}
+
+export default BreadForm;
diff --git a/web/src/components/BreadResult.jsx b/web/src/components/BreadResult.jsx
new file mode 100644
index 0000000..e343517
--- /dev/null
+++ b/web/src/components/BreadResult.jsx
@@ -0,0 +1,63 @@
+// web/src/components/BreadResult.jsx
+
+function BreadResult({ result }) {
+ if (!result) {
+ return null;
+ }
+
+ return (
+ <>
+
+
+ ✅ Ingrédients à ajouter
+
+
+
+
+ Farine
+ {result.ingredients_to_add.flour} g
+
+
+ Eau
+ {result.ingredients_to_add.water} g
+
+
+ Levain
+ {result.ingredients_to_add.sourdough} g
+
+
+ Sel
+ {result.ingredients_to_add.salt} g
+
+
+
+
+
+ Détails de la recette
+
+
+
+ Farine totale
+ {result.details.total_flour} g
+
+
+ Eau totale
+ {result.details.total_water} g
+
+
+ Farine dans le levain
+ {result.details.flour_in_sourdough} g
+
+
+ Eau dans le levain
+ {result.details.water_in_sourdough} g
+
+
+
+
+
+ >
+ );
+}
+
+export default BreadResult;
diff --git a/web/src/components/ErrorMessage.jsx b/web/src/components/ErrorMessage.jsx
new file mode 100644
index 0000000..5ce8fa0
--- /dev/null
+++ b/web/src/components/ErrorMessage.jsx
@@ -0,0 +1,18 @@
+// web/src/components/ErrorMessage.jsx
+
+function ErrorMessage({ error }) {
+ if (!error) {
+ return null;
+ }
+
+ return (
+
+
+ {error}
+
+ );
+}
+
+export default ErrorMessage;
diff --git a/web/src/components/Layout.jsx b/web/src/components/Layout.jsx
new file mode 100644
index 0000000..87fd3f8
--- /dev/null
+++ b/web/src/components/Layout.jsx
@@ -0,0 +1,27 @@
+// web/src/components/Layout.jsx
+import { Outlet } from 'react-router-dom'
+import Navigation from './Navigation'
+
+function Layout() {
+ return (
+ <>
+
+
+
+
+
+
+ Fait avec ❤️ pour les amateurs et le amatrices de pain au levain •
+
+ Source
+
+
+
+ >
+ );
+}
+
+export default Layout;
diff --git a/web/src/components/Navigation.jsx b/web/src/components/Navigation.jsx
new file mode 100644
index 0000000..f1dd2a1
--- /dev/null
+++ b/web/src/components/Navigation.jsx
@@ -0,0 +1,40 @@
+// web/src/components/Navigation.jsx
+import { Link } from 'react-router-dom'
+
+function Navigation() {
+
+ return (
+
+
+
+
+ Calculette à pâton
+
+
+
+
+
+
+
+ Menu
+
+
+ Accueil
+
+
+ À propos
+
+
+ Calculette
+
+
+
+
+
+
+
+
+ );
+}
+
+export default Navigation;
diff --git a/web/src/components/__tests__/BreadForm.test.jsx b/web/src/components/__tests__/BreadForm.test.jsx
new file mode 100644
index 0000000..f76fe12
--- /dev/null
+++ b/web/src/components/__tests__/BreadForm.test.jsx
@@ -0,0 +1,60 @@
+// web/src/components/__tests__/BreadForm.test.jsx
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import BreadForm from '../BreadForm'
+
+describe('BreadForm', () => {
+ it('should render all form fields', () => {
+ render( )
+
+ expect(screen.getByLabelText(/farine totale/i)).toBeInTheDocument()
+ expect(screen.getByLabelText(/hydratation/i)).toBeInTheDocument()
+ expect(screen.getByLabelText(/sel/i)).toBeInTheDocument()
+ expect(screen.getByLabelText(/levain.*%/i)).toBeInTheDocument()
+ expect(screen.getByLabelText(/ratio farine/i)).toBeInTheDocument()
+ expect(screen.getByLabelText(/ratio eau/i)).toBeInTheDocument()
+ })
+
+ it('should have default values', () => {
+ render( )
+
+ expect(screen.getByLabelText(/farine totale/i)).toHaveValue(500)
+ expect(screen.getByLabelText(/hydratation/i)).toHaveValue(60)
+ expect(screen.getByLabelText(/sel/i)).toHaveValue(1.6)
+ expect(screen.getByLabelText(/levain.*%/i)).toHaveValue(20)
+ })
+
+ it('should call onSubmit when form is submitted', async () => {
+ const user = userEvent.setup()
+ const handleSubmit = vi.fn((e) => e.preventDefault())
+
+ render( )
+
+ const form = screen.getByTestId('bread-form')
+ form.setAttribute('novalidate', '')
+
+ const submitButton = screen.getByRole('button', { name: /calculer/i })
+ await user.click(submitButton)
+
+ expect(handleSubmit).toHaveBeenCalledTimes(1)
+ })
+
+ it('should disable submit button when loading', () => {
+ render( )
+
+ const submitButton = screen.getByRole('button')
+ expect(submitButton).toBeDisabled()
+ expect(submitButton).toHaveTextContent('Calcul en cours...')
+ })
+
+ it('should allow user to change input values', async () => {
+ const user = userEvent.setup()
+ render( )
+
+ const flourInput = screen.getByLabelText(/farine totale/i)
+ await user.clear(flourInput)
+ await user.type(flourInput, '1000')
+
+ expect(flourInput).toHaveValue(1000)
+ })
+})
diff --git a/web/src/components/__tests__/BreadResult.test.jsx b/web/src/components/__tests__/BreadResult.test.jsx
new file mode 100644
index 0000000..a31bc4c
--- /dev/null
+++ b/web/src/components/__tests__/BreadResult.test.jsx
@@ -0,0 +1,57 @@
+// web/src/components/__tests__/BreadResult.test.jsx
+import { render, screen } from '@testing-library/react'
+import BreadResult from '../BreadResult'
+
+describe('BreadResult', () => {
+ const mockResult = {
+ ingredients_to_add: {
+ flour: 400,
+ water: 260,
+ sourdough: 100,
+ salt: 10,
+ },
+ details: {
+ total_flour: 500,
+ total_water: 325,
+ flour_in_sourdough: 50,
+ water_in_sourdough: 50,
+ },
+ }
+
+ it('should not render when result is null', () => {
+ render( )
+
+ const article = screen.queryByRole('article')
+ expect(article).not.toBeInTheDocument()
+ })
+
+ it('should render ingredients to add', () => {
+ render( )
+
+ expect(screen.getByText('✅ Ingrédients à ajouter')).toBeInTheDocument()
+ expect(screen.getByText('400 g')).toBeInTheDocument()
+ expect(screen.getByText('260 g')).toBeInTheDocument()
+ expect(screen.getByText('100 g')).toBeInTheDocument()
+ expect(screen.getByText('10 g')).toBeInTheDocument()
+ })
+
+ it('should render details in collapsed state by default', () => {
+ render( )
+
+ const details = screen.getByText('Détails de la recette')
+ expect(details).toBeInTheDocument()
+
+ // Les détails sont dans un qui est fermé par défaut
+ const detailsElement = details.closest('details')
+ expect(detailsElement).not.toHaveAttribute('open')
+ })
+
+ it('should display all ingredient labels', () => {
+ render( )
+
+ expect(screen.getByText('Farine')).toBeInTheDocument()
+ expect(screen.getByText('Eau')).toBeInTheDocument()
+ expect(screen.getByText('Levain')).toBeInTheDocument()
+ expect(screen.getByText('Sel')).toBeInTheDocument()
+ })
+})
diff --git a/web/src/components/__tests__/ErrorMessage.test.jsx b/web/src/components/__tests__/ErrorMessage.test.jsx
new file mode 100644
index 0000000..4b29829
--- /dev/null
+++ b/web/src/components/__tests__/ErrorMessage.test.jsx
@@ -0,0 +1,36 @@
+// web/src/components/__tests__/ErrorMessage.test.jsx
+import { render, screen } from '@testing-library/react'
+import ErrorMessage from '../ErrorMessage'
+
+describe('ErrorMessage', () => {
+ it('should not render when error is null', () => {
+ render( )
+
+ const article = screen.queryByRole('article')
+ expect(article).not.toBeInTheDocument()
+ })
+
+ it('should not render when error is undefined', () => {
+ render( )
+
+ const article = screen.queryByRole('article')
+ expect(article).not.toBeInTheDocument()
+ })
+
+ it('should render error message when error is provided', () => {
+ const errorMessage = 'Une erreur est survenue'
+ render( )
+
+ expect(screen.getByText('❌ Erreur')).toBeInTheDocument()
+ expect(screen.getByText(errorMessage)).toBeInTheDocument()
+ })
+
+ it('should have correct styling for error', () => {
+ render( )
+
+ const article = screen.getByRole('article')
+ expect(article).toHaveStyle({
+ backgroundColor: 'var(--pico-del-color)'
+ })
+ })
+})
diff --git a/web/src/main.jsx b/web/src/main.jsx
index b9a1a6d..c43f9fd 100644
--- a/web/src/main.jsx
+++ b/web/src/main.jsx
@@ -1,10 +1,11 @@
-import { StrictMode } from 'react'
-import { createRoot } from 'react-dom/client'
-import './index.css'
+// web/src/main.jsx
+import React from 'react'
+import ReactDOM from 'react-dom/client'
import App from './App.jsx'
+import './styles/main.scss' // ✅ Import Pico + vos styles
-createRoot(document.getElementById('root')).render(
-
+ReactDOM.createRoot(document.getElementById('root')).render(
+
- ,
+ ,
)
diff --git a/web/src/pages/About.jsx b/web/src/pages/About.jsx
new file mode 100644
index 0000000..b0b87ab
--- /dev/null
+++ b/web/src/pages/About.jsx
@@ -0,0 +1,62 @@
+// web/src/pages/About.jsx
+
+function About() {
+ return (
+ <>
+
+
+
+
+
+
+ Tout seul, je ne suis pas capable de développer une
+ application, même aussi simple !
+ Aussi, j'utilise des modèles de langage proposés par les
+ assistants de Kagi . J'essaie
+ d'apprendre en faisant, mais le code est en grande partie
+ généré par la boîte noire. J'en porte l'entière
+ responsabilité, à défaut du mérite qui revient aux femmes
+ et aux hommes dont le travail a rendu cet outil possible.
+
+ Non, ça n'excuse rien.
+
+
+
+
+ J'avais un petit bout de code Python pour m'aider
+ à faire ces calculs. Je voulais simplement le mettre à
+ disposition.
+
+
+ Par ailleurs, j'avais besoin de réaliser un petit projet pour
+ évaluer la faisabilité d'utiliser certains outils pour un autre
+ projet, plus conséquent.
+
+ J'ai démarré ce projet.
+
+
+
+
+
+ FastAPI.
+ React et Vite.
+ Pico CSS.
+
+
+
+
+ >
+ );
+}
+
+export default About;
diff --git a/web/src/pages/Calculator.jsx b/web/src/pages/Calculator.jsx
new file mode 100644
index 0000000..e070327
--- /dev/null
+++ b/web/src/pages/Calculator.jsx
@@ -0,0 +1,67 @@
+// web/src/pages/Calculator.jsx
+import { useState } from "react";
+import BreadForm from "../components/BreadForm";
+import BreadResult from "../components/BreadResult";
+import ErrorMessage from "../components/ErrorMessage";
+import { calculateBread } from "../services/api";
+
+function Calculator() {
+ const [result, setResult] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ setLoading(true);
+ setError(null);
+
+ const formData = new FormData(e.target);
+
+ const params = {
+ total_flour: parseFloat(formData.get("total_flour")),
+ hydration: parseFloat(formData.get("hydration")),
+ sourdough_percentage: parseFloat(formData.get("sourdough_percentage")),
+ sourdough_ratio: {
+ flour_parts: parseFloat(formData.get("sourdough_flour_parts")),
+ water_parts: parseFloat(formData.get("sourdough_water_parts")),
+ },
+ salt: parseFloat(formData.get("salt")),
+ };
+
+ try {
+ const data = await calculateBread(params);
+ setResult(data);
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+ );
+}
+
+export default Calculator;
diff --git a/web/src/pages/Home.jsx b/web/src/pages/Home.jsx
new file mode 100644
index 0000000..12b0449
--- /dev/null
+++ b/web/src/pages/Home.jsx
@@ -0,0 +1,77 @@
+// web/src/pages/Home.jsx
+import { Link } from 'react-router-dom'
+
+function Home() {
+ return (
+ <>
+
+
+
+
+
+
+ Pourquoi utiliser cette calculette ?
+ 💦 Pour une hydratation précise .
+
+
+ Tenez-vous compte de la farine et de l'eau
+ contenues dans le levain que vous ajoutez à
+ votre pâton ?
+
+ Non ?
+
+ Désormais, la calculette fait les calculs pour vous.
+
+
+
+
+
+ Dans le formulaire :
+
+
+ Indiquez la quantité de farine totale désirée
+ pour votre pain.
+
+
+ Choisissez votre taux d'hydratation (eau
+ en % de la farine totale).
+
+
+
+ Définissez le ratio farine:eau de votre levain
+ . Un ratio de 1:1, signifie que votre
+ levain contient autant d'eau que de farine.
+
+
+ Ajoutez le sel (par exemple 1.6 % de
+ la farine, ou 16 g / kg).
+
+
+ 🎉 Obtenez les quantités exactes à utiliser pour
+ votre recette !
+
+
+
+
+ Commencer le calcul →
+
+
+
+
+
+
+ >
+ );
+}
+
+export default Home;
diff --git a/web/src/pages/NotFound.jsx b/web/src/pages/NotFound.jsx
new file mode 100644
index 0000000..2a21b21
--- /dev/null
+++ b/web/src/pages/NotFound.jsx
@@ -0,0 +1,24 @@
+// web/src/pages/NotFound.jsx
+import { Link } from 'react-router-dom'
+
+function NotFound() {
+ return (
+
+
+
+ 404 : page non trouvée
+
+
+ Désolé, la page que vous recherchez n'existe pas.
+
+
+
+ Retour à l'accueil
+
+
+
+
+ );
+}
+
+export default NotFound;
diff --git a/web/src/services/__tests__/api.test.js b/web/src/services/__tests__/api.test.js
new file mode 100644
index 0000000..44b5add
--- /dev/null
+++ b/web/src/services/__tests__/api.test.js
@@ -0,0 +1,108 @@
+// web/src/services/__tests__/api.test.js
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { calculateBread, healthCheck } from '../api'
+
+describe('API Service', () => {
+ beforeEach(() => {
+ // Mock global fetch
+ global.fetch = vi.fn()
+ })
+
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ describe('calculateBread', () => {
+ it('should make POST request with correct data', async () => {
+ const mockResponse = {
+ ingredients_to_add: {
+ flour: 400,
+ water: 260,
+ sourdough: 100,
+ salt: 10,
+ },
+ details: {
+ total_flour: 500,
+ total_water: 325,
+ flour_in_sourdough: 50,
+ water_in_sourdough: 50,
+ },
+ }
+
+ global.fetch.mockResolvedValueOnce({
+ ok: true,
+ json: async () => mockResponse,
+ })
+
+ const params = {
+ total_flour: 500,
+ hydration: 65,
+ sourdough_percentage: 20,
+ sourdough_ratio: {
+ flour_parts: 1,
+ water_parts: 1,
+ },
+ salt: 2,
+ }
+
+ const result = await calculateBread(params)
+
+ expect(global.fetch).toHaveBeenCalledWith(
+ 'http://localhost:8000/calculate',
+ expect.objectContaining({
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(params),
+ })
+ )
+
+ expect(result).toEqual(mockResponse)
+ })
+
+ it('should throw error when API returns error', async () => {
+ global.fetch.mockResolvedValueOnce({
+ ok: false,
+ status: 400,
+ json: async () => ({ detail: 'Invalid parameters' }),
+ })
+
+ await expect(
+ calculateBread({ total_flour: -1 })
+ ).rejects.toThrow('Invalid parameters')
+ })
+
+ it('should throw network error when fetch fails', async () => {
+ global.fetch.mockRejectedValueOnce(new Error('Failed to fetch'))
+
+ await expect(
+ calculateBread({ total_flour: 500 })
+ ).rejects.toThrow(/Impossible de contacter le serveur/)
+ })
+ })
+
+ describe('healthCheck', () => {
+ it('should make GET request to root endpoint', async () => {
+ const mockResponse = { status: 'ok' }
+
+ global.fetch.mockResolvedValueOnce({
+ ok: true,
+ json: async () => mockResponse,
+ })
+
+ const result = await healthCheck()
+
+ expect(global.fetch).toHaveBeenCalledWith(
+ 'http://localhost:8000/',
+ expect.objectContaining({
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ })
+ )
+
+ expect(result).toEqual(mockResponse)
+ })
+ })
+})
diff --git a/web/src/services/api.js b/web/src/services/api.js
new file mode 100644
index 0000000..07db16f
--- /dev/null
+++ b/web/src/services/api.js
@@ -0,0 +1,71 @@
+// web/src/services/api.js
+
+const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
+
+/**
+ * Generic API request handler
+ * @param {string} endpoint - API endpoint (e.g., '/calculate')
+ * @param {object} options - Fetch options
+ * @returns {Promise} - Response data
+ * @throws {Error} - API error with message
+ */
+async function apiRequest(endpoint, options = {}) {
+ const url = `${API_URL}${endpoint}`;
+
+ const config = {
+ headers: {
+ 'Content-Type': 'application/json',
+ ...options.headers,
+ },
+ ...options,
+ };
+
+ try {
+ const response = await fetch(url, config);
+
+ // Handle HTTP errors
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}));
+ throw new Error(
+ errorData.detail ||
+ errorData.message ||
+ `HTTP error! status: ${response.status}`
+ );
+ }
+
+ return await response.json();
+ } catch (error) {
+ // Re-throw with more context if it's a network error
+ if (error.message === 'Failed to fetch') {
+ throw new Error(
+ `Impossible de contacter le serveur. Vérifiez que l'API est démarrée sur ${API_URL}`
+ );
+ }
+ throw error;
+ }
+}
+
+/**
+ * Calculate bread proportions
+ * @param {object} params - Bread calculation parameters
+ * @param {number} params.total_flour - Total flour in grams
+ * @param {number} params.hydration - Hydration percentage
+ * @param {number} params.sourdough_percentage - Sourdough percentage
+ * @param {object} params.sourdough_ratio - Sourdough flour:water ratio
+ * @param {number} params.salt - Salt percentage
+ * @returns {Promise} - Calculation results
+ */
+export async function calculateBread(params) {
+ return apiRequest('/calculate', {
+ method: 'POST',
+ body: JSON.stringify(params),
+ });
+}
+
+/**
+ * Health check endpoint
+ * @returns {Promise} - API status
+ */
+export async function healthCheck() {
+ return apiRequest('/');
+}
diff --git a/web/src/styles/_mixins.scss b/web/src/styles/_mixins.scss
new file mode 100644
index 0000000..adef49d
--- /dev/null
+++ b/web/src/styles/_mixins.scss
@@ -0,0 +1,42 @@
+// web/src/styles/_mixins.scss
+
+@use "sass:map";
+@use "@picocss/pico/scss/pico" as pico;
+
+
+/// Mixin for media queries based on Pico's breakpoints
+/// @param {String} $size - breakpoint name (sm, md, lg, xl, xxl)
+/// @example
+/// @include breakpoint(md) {
+/// font-size: 1.5rem;
+/// }
+@mixin breakpoint($size) {
+ $breakpoint: map.get(pico.$breakpoints, $size, "breakpoint");
+
+ @if $breakpoint {
+ @media (min-width: $breakpoint) {
+ @content;
+ }
+ } @else {
+ @error "Breakpoint '#{$size}' not found. Available values: sm, md, lg, xl, xxl";
+ }
+}
+
+
+/// Mixin for media queries min-width based on Pico's breakpoints
+/// @param {String} $size - breakpoint name (sm, md, lg, xl, xxl)
+/// @example
+/// @include breakpoint-up(md) {
+/// font-size: 1.5rem;
+/// }
+@mixin breakpoint-up($size) {
+ $breakpoint: map.get(pico.$breakpoints, $size, "breakpoint");
+
+ @if $breakpoint {
+ @media (min-width: $breakpoint) {
+ @content;
+ }
+ } @else {
+ @error "Breakpoint '#{$size}' not found. Available values: sm, md, lg, xl, xxl";
+ }
+}
diff --git a/web/src/styles/main.scss b/web/src/styles/main.scss
new file mode 100644
index 0000000..f460a1a
--- /dev/null
+++ b/web/src/styles/main.scss
@@ -0,0 +1,25 @@
+// web/src/styles/main.scss
+
+// Import Pico CSS with default values, except for the color palette.
+@use "@picocss/pico/scss/pico" as * with (
+ $theme-color: "purple",
+ $semantic-root-element: "#root",
+ $enable-semantic-container: true
+);
+@use "mixins" as *;
+
+#root > footer {
+ text-align: center;
+}
+
+.error_message {
+ background-color: var(--pico-del-color);
+}
+
+.bread-form-result {
+ // Rules apply on large screens and bigger
+ @include breakpoint-up(lg) {
+ max-width: 50dvi;
+ margin: 0 auto;
+ }
+}
diff --git a/web/src/test/setup.js b/web/src/test/setup.js
new file mode 100644
index 0000000..4569fba
--- /dev/null
+++ b/web/src/test/setup.js
@@ -0,0 +1,3 @@
+// web/src/test/setup.js
+import '@testing-library/jest-dom'
+
diff --git a/web/vite.config.js b/web/vite.config.js
index 8b0f57b..b71fcd9 100644
--- a/web/vite.config.js
+++ b/web/vite.config.js
@@ -1,7 +1,13 @@
+// web/vite.config.js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
-// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
+ test: {
+ globals: true,
+ environment: 'jsdom',
+ setupFiles: './src/test/setup.js',
+ css: true,
+ },
})