Compare commits

...

5 Commits

Author SHA1 Message Date
iGor milhit 3061646718
web: display API documentation
Co-Authored-by: iGor milhit <igor@milhit.ch>
2026-01-15 16:32:39 +01:00
iGor milhit ab5c5f4fbc
api: support serving API behind a reverse proxy
Co-Authored-by: iGor milhit <igor@milhit.ch>
2026-01-15 09:54:29 +01:00
iGor milhit 22563c2113
web: improve management of `.env` files
- Ignores `.env.production`.
- Improvees `.env.example` file.

Co-Authored-by: iGor milhit <igor@milhit.ch>
2026-01-14 11:18:49 +01:00
iGor milhit bf3e29fb0e
dependencies: identify dev from prod dependencies
- Strutures the `pyproject.toml` in order to distinguish dev from prod
  dependencies.
- Lock the `uv.lock` file.
- Strutures the `web/package.json` to do the same.

Co-Authored-by: iGor milhit <igor@milhit.ch>
2026-01-14 10:52:05 +01:00
iGor milhit 9fcd4d87e3
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 <igor@milhit.ch>
2026-01-13 14:47:08 +01:00
32 changed files with 1844 additions and 342 deletions

View File

@ -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

View File

@ -8,7 +8,8 @@ app = FastAPI(
title="Dough Calculator API",
description="""Calculate bread ingredient proportions
based on baker's percentages""",
version="1.0.0"
version="1.0.0",
root_path="/api"
)
settings = Settings()

View File

@ -10,11 +10,7 @@ readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"fastapi[standard]>=0.128.0",
"httpx>=0.28.1",
"pydantic-settings>=2.12.0",
"pytest>=9.0.2",
"pytest-cov>=7.0.0",
"ruff>=0.14.10",
"uvicorn>=0.40.0",
]
@ -44,3 +40,11 @@ select = [
# isort
"I",
]
[dependency-groups]
dev = [
"httpx>=0.28.1",
"pytest>=9.0.2",
"pytest-cov>=7.0.0",
"ruff>=0.14.11",
]

View File

@ -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

58
uv.lock
View File

@ -138,23 +138,31 @@ version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "fastapi", extra = ["standard"] },
{ name = "httpx" },
{ name = "pydantic-settings" },
{ name = "uvicorn" },
]
[package.dev-dependencies]
dev = [
{ name = "httpx" },
{ name = "pytest" },
{ name = "pytest-cov" },
{ name = "ruff" },
{ name = "uvicorn" },
]
[package.metadata]
requires-dist = [
{ name = "fastapi", extras = ["standard"], specifier = ">=0.128.0" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "pydantic-settings", specifier = ">=2.12.0" },
{ name = "uvicorn", specifier = ">=0.40.0" },
]
[package.metadata.requires-dev]
dev = [
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "pytest", specifier = ">=9.0.2" },
{ name = "pytest-cov", specifier = ">=7.0.0" },
{ name = "ruff", specifier = ">=0.14.10" },
{ name = "uvicorn", specifier = ">=0.40.0" },
{ name = "ruff", specifier = ">=0.14.11" },
]
[[package]]
@ -744,28 +752,28 @@ wheels = [
[[package]]
name = "ruff"
version = "0.14.10"
version = "0.14.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/08/52232a877978dd8f9cf2aeddce3e611b40a63287dfca29b6b8da791f5e8d/ruff-0.14.10.tar.gz", hash = "sha256:9a2e830f075d1a42cd28420d7809ace390832a490ed0966fe373ba288e77aaf4", size = 5859763, upload-time = "2025-12-18T19:28:57.98Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d4/77/9a7fe084d268f8855d493e5031ea03fa0af8cc05887f638bf1c4e3363eb8/ruff-0.14.11.tar.gz", hash = "sha256:f6dc463bfa5c07a59b1ff2c3b9767373e541346ea105503b4c0369c520a66958", size = 5993417, upload-time = "2026-01-08T19:11:58.322Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/60/01/933704d69f3f05ee16ef11406b78881733c186fe14b6a46b05cfcaf6d3b2/ruff-0.14.10-py3-none-linux_armv6l.whl", hash = "sha256:7a3ce585f2ade3e1f29ec1b92df13e3da262178df8c8bdf876f48fa0e8316c49", size = 13527080, upload-time = "2025-12-18T19:29:25.642Z" },
{ url = "https://files.pythonhosted.org/packages/df/58/a0349197a7dfa603ffb7f5b0470391efa79ddc327c1e29c4851e85b09cc5/ruff-0.14.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:674f9be9372907f7257c51f1d4fc902cb7cf014b9980152b802794317941f08f", size = 13797320, upload-time = "2025-12-18T19:29:02.571Z" },
{ url = "https://files.pythonhosted.org/packages/7b/82/36be59f00a6082e38c23536df4e71cdbc6af8d7c707eade97fcad5c98235/ruff-0.14.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d85713d522348837ef9df8efca33ccb8bd6fcfc86a2cde3ccb4bc9d28a18003d", size = 12918434, upload-time = "2025-12-18T19:28:51.202Z" },
{ url = "https://files.pythonhosted.org/packages/a6/00/45c62a7f7e34da92a25804f813ebe05c88aa9e0c25e5cb5a7d23dd7450e3/ruff-0.14.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6987ebe0501ae4f4308d7d24e2d0fe3d7a98430f5adfd0f1fead050a740a3a77", size = 13371961, upload-time = "2025-12-18T19:29:04.991Z" },
{ url = "https://files.pythonhosted.org/packages/40/31/a5906d60f0405f7e57045a70f2d57084a93ca7425f22e1d66904769d1628/ruff-0.14.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:16a01dfb7b9e4eee556fbfd5392806b1b8550c9b4a9f6acd3dbe6812b193c70a", size = 13275629, upload-time = "2025-12-18T19:29:21.381Z" },
{ url = "https://files.pythonhosted.org/packages/3e/60/61c0087df21894cf9d928dc04bcd4fb10e8b2e8dca7b1a276ba2155b2002/ruff-0.14.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7165d31a925b7a294465fa81be8c12a0e9b60fb02bf177e79067c867e71f8b1f", size = 14029234, upload-time = "2025-12-18T19:29:00.132Z" },
{ url = "https://files.pythonhosted.org/packages/44/84/77d911bee3b92348b6e5dab5a0c898d87084ea03ac5dc708f46d88407def/ruff-0.14.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c561695675b972effb0c0a45db233f2c816ff3da8dcfbe7dfc7eed625f218935", size = 15449890, upload-time = "2025-12-18T19:28:53.573Z" },
{ url = "https://files.pythonhosted.org/packages/e9/36/480206eaefa24a7ec321582dda580443a8f0671fdbf6b1c80e9c3e93a16a/ruff-0.14.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4bb98fcbbc61725968893682fd4df8966a34611239c9fd07a1f6a07e7103d08e", size = 15123172, upload-time = "2025-12-18T19:29:23.453Z" },
{ url = "https://files.pythonhosted.org/packages/5c/38/68e414156015ba80cef5473d57919d27dfb62ec804b96180bafdeaf0e090/ruff-0.14.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f24b47993a9d8cb858429e97bdf8544c78029f09b520af615c1d261bf827001d", size = 14460260, upload-time = "2025-12-18T19:29:27.808Z" },
{ url = "https://files.pythonhosted.org/packages/b3/19/9e050c0dca8aba824d67cc0db69fb459c28d8cd3f6855b1405b3f29cc91d/ruff-0.14.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59aabd2e2c4fd614d2862e7939c34a532c04f1084476d6833dddef4afab87e9f", size = 14229978, upload-time = "2025-12-18T19:29:11.32Z" },
{ url = "https://files.pythonhosted.org/packages/51/eb/e8dd1dd6e05b9e695aa9dd420f4577debdd0f87a5ff2fedda33c09e9be8c/ruff-0.14.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:213db2b2e44be8625002dbea33bb9c60c66ea2c07c084a00d55732689d697a7f", size = 14338036, upload-time = "2025-12-18T19:29:09.184Z" },
{ url = "https://files.pythonhosted.org/packages/6a/12/f3e3a505db7c19303b70af370d137795fcfec136d670d5de5391e295c134/ruff-0.14.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b914c40ab64865a17a9a5b67911d14df72346a634527240039eb3bd650e5979d", size = 13264051, upload-time = "2025-12-18T19:29:13.431Z" },
{ url = "https://files.pythonhosted.org/packages/08/64/8c3a47eaccfef8ac20e0484e68e0772013eb85802f8a9f7603ca751eb166/ruff-0.14.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1484983559f026788e3a5c07c81ef7d1e97c1c78ed03041a18f75df104c45405", size = 13283998, upload-time = "2025-12-18T19:29:06.994Z" },
{ url = "https://files.pythonhosted.org/packages/12/84/534a5506f4074e5cc0529e5cd96cfc01bb480e460c7edf5af70d2bcae55e/ruff-0.14.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c70427132db492d25f982fffc8d6c7535cc2fd2c83fc8888f05caaa248521e60", size = 13601891, upload-time = "2025-12-18T19:28:55.811Z" },
{ url = "https://files.pythonhosted.org/packages/0d/1e/14c916087d8598917dbad9b2921d340f7884824ad6e9c55de948a93b106d/ruff-0.14.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5bcf45b681e9f1ee6445d317ce1fa9d6cba9a6049542d1c3d5b5958986be8830", size = 14336660, upload-time = "2025-12-18T19:29:16.531Z" },
{ url = "https://files.pythonhosted.org/packages/f2/1c/d7b67ab43f30013b47c12b42d1acd354c195351a3f7a1d67f59e54227ede/ruff-0.14.10-py3-none-win32.whl", hash = "sha256:104c49fc7ab73f3f3a758039adea978869a918f31b73280db175b43a2d9b51d6", size = 13196187, upload-time = "2025-12-18T19:29:19.006Z" },
{ url = "https://files.pythonhosted.org/packages/fb/9c/896c862e13886fae2af961bef3e6312db9ebc6adc2b156fe95e615dee8c1/ruff-0.14.10-py3-none-win_amd64.whl", hash = "sha256:466297bd73638c6bdf06485683e812db1c00c7ac96d4ddd0294a338c62fdc154", size = 14661283, upload-time = "2025-12-18T19:29:30.16Z" },
{ url = "https://files.pythonhosted.org/packages/74/31/b0e29d572670dca3674eeee78e418f20bdf97fa8aa9ea71380885e175ca0/ruff-0.14.10-py3-none-win_arm64.whl", hash = "sha256:e51d046cf6dda98a4633b8a8a771451107413b0f07183b2bef03f075599e44e6", size = 13729839, upload-time = "2025-12-18T19:28:48.636Z" },
{ url = "https://files.pythonhosted.org/packages/f0/a6/a4c40a5aaa7e331f245d2dc1ac8ece306681f52b636b40ef87c88b9f7afd/ruff-0.14.11-py3-none-linux_armv6l.whl", hash = "sha256:f6ff2d95cbd335841a7217bdfd9c1d2e44eac2c584197ab1385579d55ff8830e", size = 12951208, upload-time = "2026-01-08T19:12:09.218Z" },
{ url = "https://files.pythonhosted.org/packages/5c/5c/360a35cb7204b328b685d3129c08aca24765ff92b5a7efedbdd6c150d555/ruff-0.14.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6f6eb5c1c8033680f4172ea9c8d3706c156223010b8b97b05e82c59bdc774ee6", size = 13330075, upload-time = "2026-01-08T19:12:02.549Z" },
{ url = "https://files.pythonhosted.org/packages/1b/9e/0cc2f1be7a7d33cae541824cf3f95b4ff40d03557b575912b5b70273c9ec/ruff-0.14.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f2fc34cc896f90080fca01259f96c566f74069a04b25b6205d55379d12a6855e", size = 12257809, upload-time = "2026-01-08T19:12:00.366Z" },
{ url = "https://files.pythonhosted.org/packages/a7/e5/5faab97c15bb75228d9f74637e775d26ac703cc2b4898564c01ab3637c02/ruff-0.14.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:53386375001773ae812b43205d6064dae49ff0968774e6befe16a994fc233caa", size = 12678447, upload-time = "2026-01-08T19:12:13.899Z" },
{ url = "https://files.pythonhosted.org/packages/1b/33/e9767f60a2bef779fb5855cab0af76c488e0ce90f7bb7b8a45c8a2ba4178/ruff-0.14.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a697737dce1ca97a0a55b5ff0434ee7205943d4874d638fe3ae66166ff46edbe", size = 12758560, upload-time = "2026-01-08T19:11:42.55Z" },
{ url = "https://files.pythonhosted.org/packages/eb/84/4c6cf627a21462bb5102f7be2a320b084228ff26e105510cd2255ea868e5/ruff-0.14.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6845ca1da8ab81ab1dce755a32ad13f1db72e7fba27c486d5d90d65e04d17b8f", size = 13599296, upload-time = "2026-01-08T19:11:30.371Z" },
{ url = "https://files.pythonhosted.org/packages/88/e1/92b5ed7ea66d849f6157e695dc23d5d6d982bd6aa8d077895652c38a7cae/ruff-0.14.11-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:e36ce2fd31b54065ec6f76cb08d60159e1b32bdf08507862e32f47e6dde8bcbf", size = 15048981, upload-time = "2026-01-08T19:12:04.742Z" },
{ url = "https://files.pythonhosted.org/packages/61/df/c1bd30992615ac17c2fb64b8a7376ca22c04a70555b5d05b8f717163cf9f/ruff-0.14.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:590bcc0e2097ecf74e62a5c10a6b71f008ad82eb97b0a0079e85defe19fe74d9", size = 14633183, upload-time = "2026-01-08T19:11:40.069Z" },
{ url = "https://files.pythonhosted.org/packages/04/e9/fe552902f25013dd28a5428a42347d9ad20c4b534834a325a28305747d64/ruff-0.14.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53fe71125fc158210d57fe4da26e622c9c294022988d08d9347ec1cf782adafe", size = 14050453, upload-time = "2026-01-08T19:11:37.555Z" },
{ url = "https://files.pythonhosted.org/packages/ae/93/f36d89fa021543187f98991609ce6e47e24f35f008dfe1af01379d248a41/ruff-0.14.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a35c9da08562f1598ded8470fcfef2afb5cf881996e6c0a502ceb61f4bc9c8a3", size = 13757889, upload-time = "2026-01-08T19:12:07.094Z" },
{ url = "https://files.pythonhosted.org/packages/b7/9f/c7fb6ecf554f28709a6a1f2a7f74750d400979e8cd47ed29feeaa1bd4db8/ruff-0.14.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0f3727189a52179393ecf92ec7057c2210203e6af2676f08d92140d3e1ee72c1", size = 13955832, upload-time = "2026-01-08T19:11:55.064Z" },
{ url = "https://files.pythonhosted.org/packages/db/a0/153315310f250f76900a98278cf878c64dfb6d044e184491dd3289796734/ruff-0.14.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:eb09f849bd37147a789b85995ff734a6c4a095bed5fd1608c4f56afc3634cde2", size = 12586522, upload-time = "2026-01-08T19:11:35.356Z" },
{ url = "https://files.pythonhosted.org/packages/2f/2b/a73a2b6e6d2df1d74bf2b78098be1572191e54bec0e59e29382d13c3adc5/ruff-0.14.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c61782543c1231bf71041461c1f28c64b961d457d0f238ac388e2ab173d7ecb7", size = 12724637, upload-time = "2026-01-08T19:11:47.796Z" },
{ url = "https://files.pythonhosted.org/packages/f0/41/09100590320394401cd3c48fc718a8ba71c7ddb1ffd07e0ad6576b3a3df2/ruff-0.14.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:82ff352ea68fb6766140381748e1f67f83c39860b6446966cff48a315c3e2491", size = 13145837, upload-time = "2026-01-08T19:11:32.87Z" },
{ url = "https://files.pythonhosted.org/packages/3b/d8/e035db859d1d3edf909381eb8ff3e89a672d6572e9454093538fe6f164b0/ruff-0.14.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:728e56879df4ca5b62a9dde2dd0eb0edda2a55160c0ea28c4025f18c03f86984", size = 13850469, upload-time = "2026-01-08T19:12:11.694Z" },
{ url = "https://files.pythonhosted.org/packages/4e/02/bb3ff8b6e6d02ce9e3740f4c17dfbbfb55f34c789c139e9cd91985f356c7/ruff-0.14.11-py3-none-win32.whl", hash = "sha256:337c5dd11f16ee52ae217757d9b82a26400be7efac883e9e852646f1557ed841", size = 12851094, upload-time = "2026-01-08T19:11:45.163Z" },
{ url = "https://files.pythonhosted.org/packages/58/f1/90ddc533918d3a2ad628bc3044cdfc094949e6d4b929220c3f0eb8a1c998/ruff-0.14.11-py3-none-win_amd64.whl", hash = "sha256:f981cea63d08456b2c070e64b79cb62f951aa1305282974d4d5216e6e0178ae6", size = 14001379, upload-time = "2026-01-08T19:11:52.591Z" },
{ url = "https://files.pythonhosted.org/packages/c4/1c/1dbe51782c0e1e9cfce1d1004752672d2d4629ea46945d19d731ad772b3b/ruff-0.14.11-py3-none-win_arm64.whl", hash = "sha256:649fb6c9edd7f751db276ef42df1f3df41c38d67d199570ae2a7bd6cbc3590f0", size = 12938644, upload-time = "2026-01-08T19:11:50.027Z" },
]
[[package]]

View File

@ -0,0 +1,2 @@
# web/.env.development
VITE_API_URL=http://localhost:8000

5
web/.env.example 100644
View File

@ -0,0 +1,5 @@
# API URL (required)
# Development
VITE_API_URL=http://localhost:8000
# Production
VITE_API_URL=https://domaine.org/api

9
web/.gitignore vendored
View File

@ -24,3 +24,12 @@ dist-ssr
*.sw?
.vite
.env*
.env.local
.env*.local
!.env.example
!.env.template
!env.development
coverage

794
web/package-lock.json generated
View File

@ -8,21 +8,27 @@
"name": "web",
"version": "0.0.0",
"dependencies": {
"@picocss/pico": "^2.1.1",
"react": "^19.2.0",
"react-dom": "^19.2.0"
"react-dom": "^19.2.0",
"react-router-dom": "^7.12.0"
},
"devDependencies": {
"@eslint/js": "^9.39.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",
"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,329 @@
"@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==",
"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 +1982,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 +2123,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 +2252,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 +2393,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 +2450,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 +2557,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 +2607,19 @@
"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==",
"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 +2734,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 +3091,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 +3111,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 +3254,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 +3299,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 +3366,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 +3391,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 +3630,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 +3678,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 +3730,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 +3773,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 +4040,58 @@
"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==",
"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==",
"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 +4168,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 +4219,12 @@
"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==",
"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 +4255,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 +4404,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 +4590,7 @@
"integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/expect": "4.0.16",
"@vitest/mocker": "4.0.16",

View File

@ -8,24 +8,32 @@
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview",
"test": "vitest"
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage"
},
"dependencies": {
"@picocss/pico": "^2.1.1",
"react": "^19.2.0",
"react-dom": "^19.2.0"
"react-dom": "^19.2.0",
"react-router-dom": "^7.12.0"
},
"devDependencies": {
"@eslint/js": "^9.39.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",
"sass": "^1.97.2",
"vite": "^7.2.4",
"vitest": "^4.0.16"
}

View File

@ -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;
}

View File

@ -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: <Layout />,
children: [
{
index: true,
element: <Home />,
},
{
path: 'about',
element: <About />,
},
{
path: 'calculator',
element: <Calculator />,
},
{
path: '*',
element: <NotFound />,
},
],
},
])
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 (
<div className="container">
<h1>Bread Dough Calculator</h1>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="total_flour">Total Flour (g):</label>
<input
type="number"
id="total_flour"
name="total_flour"
value={formData.total_flour}
onChange={handleChange}
min="1"
step="1"
required
/>
</div>
<div className="form-group">
<label htmlFor="hydration">Hydration (%):</label>
<input
type="number"
id="hydration"
name="hydration"
value={formData.hydration}
onChange={handleChange}
min="50"
max="100"
step="0.1"
required
/>
</div>
<div className="form-group">
<label htmlFor="sourdough_percentage">Sourdough (% of flour):</label>
<input
type="number"
id="sourdough_percentage"
name="sourdough_percentage"
value={formData.sourdough_percentage}
onChange={handleChange}
min="0"
max="50"
step="0.1"
required
/>
</div>
<fieldset className="ratio-fieldset">
<legend>Sourdough Ratio (Flour:Water)</legend>
<div className="form-group">
<label htmlFor="sourdough_flour_parts">Flour parts:</label>
<input
type="number"
id="sourdough_flour_parts"
name="sourdough_flour_parts"
value={formData.sourdough_flour_parts}
onChange={handleChange}
min="0.1"
step="0.1"
required
/>
</div>
<div className="form-group">
<label htmlFor="sourdough_water_parts">Water parts:</label>
<input
type="number"
id="sourdough_water_parts"
name="sourdough_water_parts"
value={formData.sourdough_water_parts}
onChange={handleChange}
min="0.1"
step="0.1"
required
/>
</div>
</fieldset>
<div className="form-group">
<label htmlFor="salt">Salt (% of flour):</label>
<input
type="number"
id="salt"
name="salt"
value={formData.salt}
onChange={handleChange}
min="0"
max="5"
step="0.1"
required
/>
</div>
<button type="submit" disabled={loading}>
{loading ? "Calculating..." : "Calculate"}
</button>
</form>
{error && (
<div className="error">
<h2>Error</h2>
<p>{error}</p>
</div>
)}
{result && (
<div className="results">
<h2>Ingredients to Add</h2>
<div className="result-grid">
<div className="result-item">
<span className="label">Flour:</span>
<span className="value">{result.ingredients_to_add.flour}g</span>
</div>
<div className="result-item">
<span className="label">Water:</span>
<span className="value">{result.ingredients_to_add.water}g</span>
</div>
<div className="result-item">
<span className="label">Sourdough:</span>
<span className="value">
{result.ingredients_to_add.sourdough}g
</span>
</div>
<div className="result-item">
<span className="label">Salt:</span>
<span className="value">{result.ingredients_to_add.salt}g</span>
</div>
</div>
<h3>Recipe Details</h3>
<div className="result-grid">
<div className="result-item">
<span className="label">Total flour:</span>
<span className="value">{result.details.total_flour}g</span>
</div>
<div className="result-item">
<span className="label">Total water:</span>
<span className="value">{result.details.total_water}g</span>
</div>
<div className="result-item">
<span className="label">Flour in sourdough:</span>
<span className="value">
{result.details.flour_in_sourdough}g
</span>
</div>
<div className="result-item">
<span className="label">Water in sourdough:</span>
<span className="value">
{result.details.water_in_sourdough}g
</span>
</div>
</div>
</div>
)}
</div>
);
return <RouterProvider router={router} />
}
export default App;
export default App

View File

@ -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(<App />)
expect(screen.getByLabelText(/farine/i)).toBeInTheDocument()
expect(screen.getByRole('button', { name: /calculer/i })).toBeInTheDocument()
})
})

View File

@ -0,0 +1,139 @@
// web/src/components/BreadForm.jsx
function BreadForm({ onSubmit, loading }) {
return (
<form onSubmit={onSubmit} data-testid="bread-form">
<fieldset>
<legend>Les ingrédients de base</legend>
<label htmlFor="total_flour">
Farine totale (en grammes)
<input
type="number"
id="total_flour"
name="total_flour"
defaultValue="500"
min="1"
step="0.1"
required
/>
<small>
Indique la quantité totale de farine que tu veux dans ton pâton,
en grammes. 500 (g) est la valeur par défaut.
</small>
</label>
<label htmlFor="hydration">
Hydratation (en % de la farine)
<input
type="number"
id="hydration"
name="hydration"
defaultValue="60"
min="50"
max="100"
step="1"
required
/>
<small>
Indique le pourcentage d'eau par rapport à
la farine. La valeur par défaut est de 60&nbsp;%, ce qui revient
600&nbsp;g pour 1&nbsp;kg de farine, ou 300&thinsp;g pour 500&nbsp;g.
</small>
</label>
<label htmlFor="salt">
Sel (en % de la farine)
<input
type="number"
id="salt"
name="salt"
defaultValue="1.6"
min="0"
max="2"
step="0.1"
required
/>
<small>
Indique la quantité de sel en pourcent de la farine. La valeur par
défaut est 1.6&nbsp;%, ce qui revient à 16&nbsp;g de sel pour
1&nbsp;kg de farine, ou 8&nbsp;g pour 500&nbsp;g.
</small>
</label>
</fieldset>
<fieldset>
<legend>Le levain</legend>
<label htmlFor="sourdough_percentage">
Levain (en % de la farine)
<input
type="number"
id="sourdough_percentage"
name="sourdough_percentage"
defaultValue="20"
min="10"
max="100"
step="1"
required
/>
<small>
Indique la quantité de levain en pourcent de la farine. La valeur
par défaut est de 20&nbsp;%, ce qui revient à 200&nbsp;g pour
1&nbsp;kg de farine, ou 100&nbsp;g pour 500&nbsp;g.
</small>
</label>
<p>
Pour calculer les quantités à ajouter au pâton pour respecter
le taux d'hydratation voulu, il faut connaître les proportions de
farine et d'eau du levain. C'est le rôle des champs suivant&thinsp;:
</p>
<label htmlFor="sourdough_flour_parts">
Ratio farine
<input
type="number"
id="sourdough_flour_parts"
name="sourdough_flour_parts"
defaultValue="1"
min="0.1"
step="0.1"
required
/>
<small>
Indique une valeur par exemple de 1 ou de 100 qui servira de base
à la comparaison avec la proportion d'eau (champ suivant). La
valeur par défaut est de 1.
</small>
</label>
<label htmlFor="sourdough_water_parts">
Ratio eau
<input
type="number"
id="sourdough_water_parts"
name="sourdough_water_parts"
defaultValue="1"
min="0.1"
step="0.1"
required
/>
<small>
Indique la proportion d'eau du levain, par rapport à la quantité
de farine. Un ratio de <code>1:1</code> signifie qu'il y a autant
d'eau que de farine dans le levain. C'est la même chose qu'un ratio
de <code>100:100</code>. C'est le cas où lors d'un rafraîchi, tu
ajoutes autant de farine que d'eau.
</small>
</label>
</fieldset>
<button type="submit" disabled={loading} aria-busy={loading}>
{loading ? "Calcul en cours..." : "Calculer"}
</button>
</form>
);
}
export default BreadForm;

View File

@ -0,0 +1,63 @@
// web/src/components/BreadResult.jsx
function BreadResult({ result }) {
if (!result) {
return null;
}
return (
<>
<article>
<header>
<strong> Ingrédients à ajouter</strong>
</header>
<table>
<tbody>
<tr>
<th scope="row">Farine</th>
<td>{result.ingredients_to_add.flour} g</td>
</tr>
<tr>
<th scope="row">Eau</th>
<td>{result.ingredients_to_add.water} g</td>
</tr>
<tr>
<th scope="row">Levain</th>
<td>{result.ingredients_to_add.sourdough} g</td>
</tr>
<tr>
<th scope="row">Sel</th>
<td>{result.ingredients_to_add.salt} g</td>
</tr>
</tbody>
</table>
<details>
<summary>Détails de la recette</summary>
<table>
<tbody>
<tr>
<th scope="row">Farine totale</th>
<td>{result.details.total_flour} g</td>
</tr>
<tr>
<th scope="row">Eau totale</th>
<td>{result.details.total_water} g</td>
</tr>
<tr>
<th scope="row">Farine dans le levain</th>
<td>{result.details.flour_in_sourdough} g</td>
</tr>
<tr>
<th scope="row">Eau dans le levain</th>
<td>{result.details.water_in_sourdough} g</td>
</tr>
</tbody>
</table>
</details>
</article>
</>
);
}
export default BreadResult;

View File

@ -0,0 +1,18 @@
// web/src/components/ErrorMessage.jsx
function ErrorMessage({ error }) {
if (!error) {
return null;
}
return (
<article style={{ backgroundColor: 'var(--pico-del-color)' }}>
<header>
<strong> Erreur</strong>
</header>
<p>{error}</p>
</article>
);
}
export default ErrorMessage;

View File

@ -0,0 +1,27 @@
// web/src/components/Layout.jsx
import { Outlet } from 'react-router-dom'
import Navigation from './Navigation'
function Layout() {
return (
<>
<Navigation />
<Outlet />
<footer className="container">
<hr />
<small>
Fait avec pour les amateurs et le amatrices de pain au levain
<a href="https://git.milhit.ch/igor/dough-calc"
target="_blank" rel="noopener noreferrer"
title="Code source sur ma forge git"
>
Source
</a>
</small>
</footer>
</>
);
}
export default Layout;

View File

@ -0,0 +1,40 @@
// web/src/components/Navigation.jsx
import { Link } from 'react-router-dom'
function Navigation() {
return (
<nav className="container">
<ul>
<li>
<Link to="/">
<strong>Calculette à pâton</strong>
</Link>
</li>
</ul>
<ul>
<li>
<details className='dropdown'>
<summary>Menu</summary>
<ul dir='rtl'>
<li>
<Link to="/">Accueil</Link>
</li>
<li>
<Link to="/about">À propos</Link>
</li>
<li>
<Link to="/calculator">Calculette</Link>
</li>
</ul>
</details>
</li>
</ul>
</nav>
);
}
export default Navigation;

View File

@ -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(<BreadForm onSubmit={vi.fn()} loading={false} />)
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(<BreadForm onSubmit={vi.fn()} loading={false} />)
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(<BreadForm onSubmit={handleSubmit} loading={false} />)
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(<BreadForm onSubmit={vi.fn()} loading={true} />)
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(<BreadForm onSubmit={vi.fn()} loading={false} />)
const flourInput = screen.getByLabelText(/farine totale/i)
await user.clear(flourInput)
await user.type(flourInput, '1000')
expect(flourInput).toHaveValue(1000)
})
})

View File

@ -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(<BreadResult result={null} />)
const article = screen.queryByRole('article')
expect(article).not.toBeInTheDocument()
})
it('should render ingredients to add', () => {
render(<BreadResult result={mockResult} />)
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(<BreadResult result={mockResult} />)
const details = screen.getByText('Détails de la recette')
expect(details).toBeInTheDocument()
// Les détails sont dans un <details> qui est fermé par défaut
const detailsElement = details.closest('details')
expect(detailsElement).not.toHaveAttribute('open')
})
it('should display all ingredient labels', () => {
render(<BreadResult result={mockResult} />)
expect(screen.getByText('Farine')).toBeInTheDocument()
expect(screen.getByText('Eau')).toBeInTheDocument()
expect(screen.getByText('Levain')).toBeInTheDocument()
expect(screen.getByText('Sel')).toBeInTheDocument()
})
})

View File

@ -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(<ErrorMessage error={null} />)
const article = screen.queryByRole('article')
expect(article).not.toBeInTheDocument()
})
it('should not render when error is undefined', () => {
render(<ErrorMessage error={undefined} />)
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(<ErrorMessage error={errorMessage} />)
expect(screen.getByText('❌ Erreur')).toBeInTheDocument()
expect(screen.getByText(errorMessage)).toBeInTheDocument()
})
it('should have correct styling for error', () => {
render(<ErrorMessage error="Test error" />)
const article = screen.getByRole('article')
expect(article).toHaveStyle({
backgroundColor: 'var(--pico-del-color)'
})
})
})

View File

@ -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(
<StrictMode>
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</StrictMode>,
</React.StrictMode>,
)

View File

@ -0,0 +1,72 @@
// web/src/pages/About.jsx
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
function About() {
return (
<>
<main className="container">
<header className="container">
<h1>À propos</h1>
</header>
<article>
<header>
<h2> Avertissement&thinsp;!</h2>
</header>
<p>
<strong>
Tout seul, je ne suis pas capable de développer une
application, même aussi simple&thinsp;!
</strong> Aussi, j'utilise des modèles de langage proposés par les
assistants de <a href="https://kagi.com">Kagi</a>. 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.
</p>
<p>Non, ça n'excuse rien.</p>
</article>
<article>
<header>
<h2>Histoire</h2>
</header>
<p>
J'avais un petit bout de code Python pour m'aider
à faire ces calculs. Je voulais simplement le mettre à
disposition.
</p>
<p>
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.
</p>
<p>J'ai démarré ce projet.</p>
</article>
<article>
<header>
<h2>
Outils
</h2>
</header>
<ul>
<li>
FastAPI, qui devrait répondre sur les adresses
suivantes&thinsp;:
<ul>
<li>
<a href={`${API_URL}/docs`}>
{API_URL}/docs
</a>(Swagger UI).
</li>
</ul>
</li>
<li>React et Vite.</li>
<li>Pico CSS.</li>
</ul>
</article>
</main>
</>
);
}
export default About;

View File

@ -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 (
<main className="container">
<header>
<hgroup>
<h1>Calculatrice de pain</h1>
<p>Calculez les proportions exactes pour votre recette</p>
</hgroup>
</header>
<section className="bread-form-result">
<BreadForm onSubmit={handleSubmit} loading={loading} />
<BreadResult result={result} />
</section>
<section>
<ErrorMessage error={error} />
</section>
</main>
);
}
export default Calculator;

View File

@ -0,0 +1,77 @@
// web/src/pages/Home.jsx
import { Link } from 'react-router-dom'
function Home() {
return (
<>
<header className="container">
<hgroup>
<h1>Une calculette pour votre pâton</h1>
<p>
Calculez les proportions des ingrédients pour votre
pain au levain.
</p>
</hgroup>
</header>
<main className="container">
<article>
<header>
<hgroup>
<h2>Pourquoi utiliser cette calculette&thinsp;?</h2></hgroup>
<h3>💦 Pour une hydratation <em>précise</em>.</h3>
</header>
<p>
Tenez-vous compte de la farine et de l'eau
contenues dans le levain que vous ajoutez à
votre pâton&thinsp;?
</p>
<p>Non&thinsp;?</p>
<p>
Désormais, la calculette fait les calculs pour vous.
</p>
</article>
<article>
<header>
<h2>Comment ça marche ?</h2>
</header>
<p>Dans le <Link to="/calculator">formulaire</Link>&thinsp;:</p>
<ol>
<li>
<strong>Indiquez la quantité de farine totale</strong> désirée
pour votre pain.
</li>
<li>
<strong>Choisissez votre taux d'hydratation</strong> (eau
en % de la farine totale).
</li>
<li>
<strong>
Définissez le ratio <code>farine:eau</code> de votre levain
</strong>. Un ratio de <code>1:1</code>, signifie que votre
levain contient autant d'eau que de farine.
</li>
<li>
<strong>Ajoutez le sel</strong> (par exemple 1.6&thinsp;% de
la farine, ou 16 g / kg).
</li>
<li>
<strong>🎉 Obtenez les quantités exactes</strong> à utiliser pour
votre recette&thinsp;!
</li>
</ol>
<footer>
<Link to="/calculator" role="button">
Commencer le calcul
</Link>
</footer>
</article>
</main>
</>
);
}
export default Home;

View File

@ -0,0 +1,24 @@
// web/src/pages/NotFound.jsx
import { Link } from 'react-router-dom'
function NotFound() {
return (
<main className="container">
<article>
<header>
<h1>404&thinsp;: page non trouvée</h1>
</header>
<p>
Désolé, la page que vous recherchez n'existe pas.
</p>
<footer>
<Link to="/" role="button">
Retour à l'accueil
</Link>
</footer>
</article>
</main>
);
}
export default NotFound;

View File

@ -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)
})
})
})

View File

@ -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<object>} - 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<object>} - Calculation results
*/
export async function calculateBread(params) {
return apiRequest('/calculate', {
method: 'POST',
body: JSON.stringify(params),
});
}
/**
* Health check endpoint
* @returns {Promise<object>} - API status
*/
export async function healthCheck() {
return apiRequest('/');
}

View File

@ -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";
}
}

View File

@ -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;
}
}

View File

@ -0,0 +1,3 @@
// web/src/test/setup.js
import '@testing-library/jest-dom'

View File

@ -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,
},
})