diff --git a/.env.example b/.env.example index 73efed4..390b1ad 100644 --- a/.env.example +++ b/.env.example @@ -64,11 +64,15 @@ ELEVENLABS_API_KEY="" GOOGLE_TTS_KEY="" HANDTALK_API_KEY="" +# === Auth (JWT) === +# Generate a production secret with: python -c 'import secrets; print(secrets.token_urlsafe(48))' +AILINE_JWT_SECRET="" + # === Dev Mode (enables X-Teacher-ID header bypass for local dev) === -AILINE_DEV_MODE="false" +AILINE_DEV_MODE="true" -# === Demo Mode === -AILINE_DEMO_MODE="0" +# === Demo Mode (enables /demo/* endpoints and demo login) === +AILINE_DEMO_MODE="1" # === Rate Limiting === AILINE_RATE_LIMIT_RPM="60" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f967296..5cae503 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -209,7 +209,7 @@ jobs: - name: Audit Python runtime deps working-directory: runtime - run: uv run pip-audit --desc || true + run: uv run pip-audit --desc - name: Install frontend deps working-directory: frontend @@ -217,14 +217,14 @@ jobs: - name: Audit npm deps working-directory: frontend - run: pnpm audit --audit-level=high || true + run: pnpm audit --audit-level=high # --------------------------------------------------------------------------- # Docker Compose build validation # --------------------------------------------------------------------------- docker-build: runs-on: ubuntu-latest - needs: [backend-lint, backend-typecheck, backend-test, agents-test, frontend-lint, frontend-typecheck, frontend-test] + needs: [backend-lint, backend-typecheck, backend-test, agents-test, frontend-lint, frontend-typecheck, frontend-test, security-scan] steps: - uses: actions/checkout@v4 diff --git a/agents/pyproject.toml b/agents/pyproject.toml index d888b73..bbd76d0 100644 --- a/agents/pyproject.toml +++ b/agents/pyproject.toml @@ -8,16 +8,16 @@ version = "0.1.0" description = "AiLine agent framework: Pydantic AI agents + LangGraph workflows" requires-python = ">=3.11" dependencies = [ - "pydantic-ai>=1.58.0,<2", + "pydantic-ai>=1.63.0,<2", "langgraph==1.0.8", "pydantic>=2.10,<3", - "pydantic-settings>=2.7,<3", + "pydantic-settings>=2.13,<3", "structlog>=24,<26", "deepagents==0.4.1", "langchain-anthropic>=1.3.2,<2", "langchain-core>=1.2.10,<2", - "anthropic>=0.79.0,<1", - "fastapi>=0.128,<1", + "anthropic>=0.84.0,<1", + "fastapi>=0.133.0,<1", "sse-starlette>=3.2,<4", "httpx>=0.28,<1", "tenacity>=9,<10", @@ -33,11 +33,11 @@ all = ["ailine-agents[anthropic,openai,gemini]"] [dependency-groups] dev = [ - "pytest>=8,<9", - "pytest-asyncio>=0.24,<1", + "pytest>=9,<10", + "pytest-asyncio>=1.3,<2", "pytest-cov>=6,<7", - "ruff>=0.9,<1", - "mypy>=1.14,<2", + "ruff>=0.15,<1", + "mypy>=1.19,<2", "aiosqlite>=0.20,<1", "ailine-runtime", ] @@ -46,14 +46,14 @@ dev = [ packages = ["ailine_agents"] [tool.ruff] -target-version = "py311" +target-version = "py313" line-length = 120 [tool.ruff.lint] select = ["E", "F", "I", "N", "W", "UP", "B", "SIM", "RUF"] [tool.mypy] -python_version = "3.11" +python_version = "3.13" strict = false warn_return_any = true plugins = ["pydantic.mypy"] diff --git a/agents/uv.lock b/agents/uv.lock index 0a81635..823c7ed 100644 --- a/agents/uv.lock +++ b/agents/uv.lock @@ -64,20 +64,20 @@ dev = [ [package.metadata] requires-dist = [ { name = "ailine-agents", extras = ["anthropic", "openai", "gemini"], marker = "extra == 'all'" }, - { name = "anthropic", specifier = ">=0.79.0,<1" }, + { name = "anthropic", specifier = ">=0.84.0,<1" }, { name = "deepagents", specifier = "==0.4.1" }, - { name = "fastapi", specifier = ">=0.128,<1" }, + { name = "fastapi", specifier = ">=0.133.0,<1" }, { name = "httpx", specifier = ">=0.28,<1" }, { name = "langchain-anthropic", specifier = ">=1.3.2,<2" }, { name = "langchain-core", specifier = ">=1.2.10,<2" }, { name = "langgraph", specifier = "==1.0.8" }, { name = "numpy", specifier = ">=1.26,<3" }, { name = "pydantic", specifier = ">=2.10,<3" }, - { name = "pydantic-ai", specifier = ">=1.58.0,<2" }, + { name = "pydantic-ai", specifier = ">=1.63.0,<2" }, { name = "pydantic-ai", extras = ["anthropic"], marker = "extra == 'anthropic'" }, { name = "pydantic-ai", extras = ["google"], marker = "extra == 'gemini'" }, { name = "pydantic-ai", extras = ["openai"], marker = "extra == 'openai'" }, - { name = "pydantic-settings", specifier = ">=2.7,<3" }, + { name = "pydantic-settings", specifier = ">=2.13,<3" }, { name = "python-dotenv", specifier = ">=1.2.1" }, { name = "sse-starlette", specifier = ">=3.2,<4" }, { name = "structlog", specifier = ">=24,<26" }, @@ -89,11 +89,11 @@ provides-extras = ["anthropic", "openai", "gemini", "all"] dev = [ { name = "ailine-runtime", editable = "../runtime" }, { name = "aiosqlite", specifier = ">=0.20,<1" }, - { name = "mypy", specifier = ">=1.14,<2" }, - { name = "pytest", specifier = ">=8,<9" }, - { name = "pytest-asyncio", specifier = ">=0.24,<1" }, + { name = "mypy", specifier = ">=1.19,<2" }, + { name = "pytest", specifier = ">=9,<10" }, + { name = "pytest-asyncio", specifier = ">=1.3,<2" }, { name = "pytest-cov", specifier = ">=6,<7" }, - { name = "ruff", specifier = ">=0.9,<1" }, + { name = "ruff", specifier = ">=0.15,<1" }, ] [[package]] @@ -109,6 +109,7 @@ dependencies = [ { name = "langchain-anthropic" }, { name = "langchain-core" }, { name = "langgraph" }, + { name = "langgraph-checkpoint" }, { name = "numpy" }, { name = "pydantic" }, { name = "pydantic-ai" }, @@ -128,43 +129,44 @@ requires-dist = [ { name = "ailine-runtime", extras = ["db", "embeddings-gemini", "embeddings-openai", "media", "redis", "otel"], marker = "extra == 'all'" }, { name = "aiosqlite", marker = "extra == 'sqlite'", specifier = ">=0.20,<1" }, { name = "alembic", marker = "extra == 'db'", specifier = ">=1.18,<2" }, - { name = "anthropic", specifier = ">=0.79.0,<1" }, + { name = "anthropic", specifier = ">=0.84.0,<1" }, { name = "arq", marker = "extra == 'redis'", specifier = ">=0.26,<1" }, { name = "asyncmy", marker = "extra == 'mysql'", specifier = ">=0.2.9,<1" }, { name = "asyncpg", marker = "extra == 'db'", specifier = ">=0.30,<1" }, { name = "chromadb", marker = "extra == 'vectorstore-chroma'", specifier = ">=0.6,<1" }, { name = "deepagents", specifier = "==0.4.1" }, - { name = "fastapi", specifier = "==0.129.0" }, + { name = "fastapi", specifier = ">=0.133.0,<1" }, { name = "google-genai", marker = "extra == 'embeddings-gemini'", specifier = ">=1.0,<2" }, { name = "httpx", specifier = ">=0.28,<1" }, { name = "langchain-anthropic", specifier = ">=1.3.2,<2" }, { name = "langchain-core", specifier = ">=1.2.10,<2" }, { name = "langgraph", specifier = "==1.0.8" }, + { name = "langgraph-checkpoint", specifier = ">=3.0.0" }, { name = "mediapipe", marker = "extra == 'media'", specifier = ">=0.10,<1" }, { name = "numpy", specifier = ">=1.26,<3" }, - { name = "openai", marker = "extra == 'embeddings-openai'", specifier = ">=2.11,<3" }, - { name = "openai", marker = "extra == 'media'", specifier = ">=2.11,<3" }, - { name = "opentelemetry-api", marker = "extra == 'otel'", specifier = ">=1.29,<2" }, - { name = "opentelemetry-exporter-otlp-proto-grpc", marker = "extra == 'otel'", specifier = ">=1.29,<2" }, - { name = "opentelemetry-instrumentation-fastapi", marker = "extra == 'otel'", specifier = ">=0.50b0,<1" }, - { name = "opentelemetry-instrumentation-sqlalchemy", marker = "extra == 'otel'", specifier = ">=0.50b0,<1" }, - { name = "opentelemetry-sdk", marker = "extra == 'otel'", specifier = ">=1.29,<2" }, + { name = "openai", marker = "extra == 'embeddings-openai'", specifier = ">=2.20,<3" }, + { name = "openai", marker = "extra == 'media'", specifier = ">=2.20,<3" }, + { name = "opentelemetry-api", marker = "extra == 'otel'", specifier = ">=1.39,<2" }, + { name = "opentelemetry-exporter-otlp-proto-grpc", marker = "extra == 'otel'", specifier = ">=1.39,<2" }, + { name = "opentelemetry-instrumentation-fastapi", marker = "extra == 'otel'", specifier = ">=0.51b0,<1" }, + { name = "opentelemetry-instrumentation-sqlalchemy", marker = "extra == 'otel'", specifier = ">=0.51b0,<1" }, + { name = "opentelemetry-sdk", marker = "extra == 'otel'", specifier = ">=1.39,<2" }, { name = "pgvector", marker = "extra == 'db'", specifier = ">=0.3.6,<1" }, { name = "pydantic", specifier = ">=2.10,<3" }, - { name = "pydantic-ai", specifier = ">=1.58.0,<2" }, - { name = "pydantic-settings", specifier = ">=2.7,<3" }, - { name = "pyjwt", extras = ["crypto"], specifier = ">=2.9,<3" }, + { name = "pydantic-ai", specifier = ">=1.63.0,<2" }, + { name = "pydantic-settings", specifier = ">=2.13,<3" }, + { name = "pyjwt", extras = ["crypto"], specifier = ">=2.10.1,<3" }, { name = "pytesseract", marker = "extra == 'media'", specifier = ">=0.3,<1" }, { name = "qdrant-client", marker = "extra == 'vectorstore-qdrant'", specifier = ">=1.12,<2" }, { name = "sentence-transformers", marker = "extra == 'embeddings-local'", specifier = ">=3.4,<4" }, - { name = "sqlalchemy", extras = ["asyncio"], marker = "extra == 'db'", specifier = ">=2.0.36,<3" }, + { name = "sqlalchemy", extras = ["asyncio"], marker = "extra == 'db'", specifier = ">=2.0.47,<3" }, { name = "sse-starlette", specifier = "==3.2.0" }, { name = "structlog", specifier = ">=24,<26" }, { name = "tenacity", specifier = ">=9,<10" }, { name = "tiktoken", specifier = ">=0.12.0" }, { name = "uuid-utils", specifier = ">=0.14.0" }, { name = "uuid-utils", marker = "extra == 'db'", specifier = ">=0.10,<1" }, - { name = "uvicorn", extras = ["standard"], specifier = "==0.40.0" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.41.0,<1" }, ] provides-extras = ["db", "sqlite", "mysql", "embeddings-gemini", "embeddings-openai", "embeddings-local", "vectorstore-chroma", "vectorstore-qdrant", "media", "redis", "otel", "all"] @@ -172,12 +174,12 @@ provides-extras = ["db", "sqlite", "mysql", "embeddings-gemini", "embeddings-ope dev = [ { name = "aiosqlite", specifier = ">=0.20,<1" }, { name = "httpx", specifier = ">=0.28,<1" }, - { name = "mypy", specifier = ">=1.14,<2" }, - { name = "pytest", specifier = ">=8,<9" }, - { name = "pytest-asyncio", specifier = ">=0.24,<1" }, + { name = "mypy", specifier = ">=1.19,<2" }, + { name = "pytest", specifier = ">=9,<10" }, + { name = "pytest-asyncio", specifier = ">=1.3,<2" }, { name = "pytest-cov", specifier = ">=6,<7" }, - { name = "ruff", specifier = ">=0.9,<1" }, - { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.36,<3" }, + { name = "ruff", specifier = ">=0.15,<1" }, + { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.47,<3" }, { name = "uuid-utils", specifier = ">=0.10,<1" }, ] @@ -334,7 +336,7 @@ wheels = [ [[package]] name = "anthropic" -version = "0.79.0" +version = "0.84.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -346,9 +348,9 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/15/b1/91aea3f8fd180d01d133d931a167a78a3737b3fd39ccef2ae8d6619c24fd/anthropic-0.79.0.tar.gz", hash = "sha256:8707aafb3b1176ed6c13e2b1c9fb3efddce90d17aee5d8b83a86c70dcdcca871", size = 509825, upload-time = "2026-02-07T18:06:18.388Z" } +sdist = { url = "https://files.pythonhosted.org/packages/04/ea/0869d6df9ef83dcf393aeefc12dd81677d091c6ffc86f783e51cf44062f2/anthropic-0.84.0.tar.gz", hash = "sha256:72f5f90e5aebe62dca316cb013629cfa24996b0f5a4593b8c3d712bc03c43c37", size = 539457, upload-time = "2026-02-25T05:22:38.54Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/95/b2/cc0b8e874a18d7da50b0fda8c99e4ac123f23bf47b471827c5f6f3e4a767/anthropic-0.79.0-py3-none-any.whl", hash = "sha256:04cbd473b6bbda4ca2e41dd670fe2f829a911530f01697d0a1e37321eb75f3cf", size = 405918, upload-time = "2026-02-07T18:06:20.246Z" }, + { url = "https://files.pythonhosted.org/packages/64/ca/218fa25002a332c0aa149ba18ffc0543175998b1f65de63f6d106689a345/anthropic-0.84.0-py3-none-any.whl", hash = "sha256:861c4c50f91ca45f942e091d83b60530ad6d4f98733bfe648065364da05d29e7", size = 455156, upload-time = "2026-02-25T05:22:40.468Z" }, ] [[package]] @@ -642,7 +644,7 @@ wheels = [ [[package]] name = "cohere" -version = "5.20.5" +version = "5.20.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fastavro" }, @@ -654,9 +656,9 @@ dependencies = [ { name = "types-requests" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cf/e9/a977a2f7093912e5bb7065589c913ad6887a5bdb3474886347fca53ed283/cohere-5.20.5.tar.gz", hash = "sha256:9db82263cf3c54a35b9bf44faf39e4b7fc3fc51a32a2634fc3680b99de069f31", size = 184819, upload-time = "2026-02-11T17:47:59.403Z" } +sdist = { url = "https://files.pythonhosted.org/packages/44/0b/96e2b55a0114ed9d69b3154565f54b764e7530735426290b000f467f4c0f/cohere-5.20.7.tar.gz", hash = "sha256:997ed85fabb3a1e4a4c036fdb520382e7bfa670db48eb59a026803b6f7061dbb", size = 184986, upload-time = "2026-02-25T01:22:18.673Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/83/70eccefd0608582bf7af6e99c69a56ee14048de741ba338824011f3bcf83/cohere-5.20.5-py3-none-any.whl", hash = "sha256:25b2ceae8ea52ed7f4a5f76da854f75536c348767d25ae5f9608eddb6945ee64", size = 323215, upload-time = "2026-02-11T17:47:57.803Z" }, + { url = "https://files.pythonhosted.org/packages/9d/86/dc991a75e3b9c2007b90dbfaf7f36fdb2457c216f799e26ce0474faf0c1f/cohere-5.20.7-py3-none-any.whl", hash = "sha256:043fef2a12c30c07e9b2c1f0b869fd66ffd911f58d1492f87e901c4190a65914", size = 323389, upload-time = "2026-02-25T01:22:16.902Z" }, ] [[package]] @@ -983,7 +985,7 @@ lua = [ [[package]] name = "fastapi" -version = "0.129.0" +version = "0.133.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -992,9 +994,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/48/47/75f6bea02e797abff1bca968d5997793898032d9923c1935ae2efdece642/fastapi-0.129.0.tar.gz", hash = "sha256:61315cebd2e65df5f97ec298c888f9de30430dd0612d59d6480beafbc10655af", size = 375450, upload-time = "2026-02-12T13:54:52.541Z" } +sdist = { url = "https://files.pythonhosted.org/packages/22/6f/0eafed8349eea1fa462238b54a624c8b408cd1ba2795c8e64aa6c34f8ab7/fastapi-0.133.1.tar.gz", hash = "sha256:ed152a45912f102592976fde6cbce7dae1a8a1053da94202e51dd35d184fadd6", size = 378741, upload-time = "2026-02-25T18:18:17.398Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/dd/d0ee25348ac58245ee9f90b6f3cbb666bf01f69be7e0911f9851bddbda16/fastapi-0.129.0-py3-none-any.whl", hash = "sha256:b4946880e48f462692b31c083be0432275cbfb6e2274566b1be91479cc1a84ec", size = 102950, upload-time = "2026-02-12T13:54:54.528Z" }, + { url = "https://files.pythonhosted.org/packages/d2/c9/a175a7779f3599dfa4adfc97a6ce0e157237b3d7941538604aadaf97bfb6/fastapi-0.133.1-py3-none-any.whl", hash = "sha256:658f34ba334605b1617a65adf2ea6461901bdb9af3a3080d63ff791ecf7dc2e2", size = 109029, upload-time = "2026-02-25T18:18:18.578Z" }, ] [[package]] @@ -1264,30 +1266,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/ab/09169d5a4612a5f92490806649ac8d41e3ec9129c636754575b3553f4ea4/googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", size = 297515, upload-time = "2025-11-06T18:29:13.14Z" }, ] -[[package]] -name = "griffe" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "griffecli" }, - { name = "griffelib" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/94/ee21d41e7eb4f823b94603b9d40f86d3c7fde80eacc2c3c71845476dddaa/griffe-2.0.0-py3-none-any.whl", hash = "sha256:5418081135a391c3e6e757a7f3f156f1a1a746cc7b4023868ff7d5e2f9a980aa", size = 5214, upload-time = "2026-02-09T19:09:44.105Z" }, -] - -[[package]] -name = "griffecli" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama" }, - { name = "griffelib" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/ed/d93f7a447bbf7a935d8868e9617cbe1cadf9ee9ee6bd275d3040fbf93d60/griffecli-2.0.0-py3-none-any.whl", hash = "sha256:9f7cd9ee9b21d55e91689358978d2385ae65c22f307a63fb3269acf3f21e643d", size = 9345, upload-time = "2026-02-09T19:09:42.554Z" }, -] - [[package]] name = "griffelib" version = "2.0.0" @@ -1477,26 +1455,22 @@ wheels = [ [[package]] name = "huggingface-hub" -version = "0.36.2" +version = "1.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, { name = "fsspec" }, - { name = "hf-xet", marker = "platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "httpx" }, { name = "packaging" }, { name = "pyyaml" }, - { name = "requests" }, { name = "tqdm" }, + { name = "typer" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7c/b7/8cb61d2eece5fb05a83271da168186721c450eb74e3c31f7ef3169fa475b/huggingface_hub-0.36.2.tar.gz", hash = "sha256:1934304d2fb224f8afa3b87007d58501acfda9215b334eed53072dd5e815ff7a", size = 649782, upload-time = "2026-02-06T09:24:13.098Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/76/b5efb3033d8499b17f9386beaf60f64c461798e1ee16d10bc9c0077beba5/huggingface_hub-1.5.0.tar.gz", hash = "sha256:f281838db29265880fb543de7a23b0f81d3504675de82044307ea3c6c62f799d", size = 695872, upload-time = "2026-02-26T15:35:32.745Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/af/48ac8483240de756d2438c380746e7130d1c6f75802ef22f3c6d49982787/huggingface_hub-0.36.2-py3-none-any.whl", hash = "sha256:48f0c8eac16145dfce371e9d2d7772854a4f591bcb56c9cf548accf531d54270", size = 566395, upload-time = "2026-02-06T09:24:11.133Z" }, -] - -[package.optional-dependencies] -inference = [ - { name = "aiohttp" }, + { url = "https://files.pythonhosted.org/packages/ec/74/2bc951622e2dbba1af9a460d93c51d15e458becd486e62c29cc0ccb08178/huggingface_hub-1.5.0-py3-none-any.whl", hash = "sha256:c9c0b3ab95a777fc91666111f3b3ede71c0cdced3614c553a64e98920585c4ee", size = 596261, upload-time = "2026-02-26T15:35:31.1Z" }, ] [[package]] @@ -2953,32 +2927,32 @@ email = [ [[package]] name = "pydantic-ai" -version = "1.58.0" +version = "1.63.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic-ai-slim", extra = ["ag-ui", "anthropic", "bedrock", "cli", "cohere", "evals", "fastmcp", "google", "groq", "huggingface", "logfire", "mcp", "mistral", "openai", "retries", "temporal", "ui", "vertexai", "xai"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c6/fb/1f50cfcc1e241fe57b1ba226fda3a03bc86f8a62c4b08a1d256b404add5e/pydantic_ai-1.58.0.tar.gz", hash = "sha256:4166ffdf27a034cacaffc9a9171a7aee75473578c4f4130ef5f650010ec6ec2d", size = 11817, upload-time = "2026-02-11T01:46:01.329Z" } +sdist = { url = "https://files.pythonhosted.org/packages/15/13/f0a11d43e3e5b2705dd7ee687d4b0fa9b02a7cd23ea4170b92c0a79eb1d3/pydantic_ai-1.63.0.tar.gz", hash = "sha256:269665fbc947d1d4238296a697c12a60d8b1b2c82536f2af4be801f73e165a92", size = 12130, upload-time = "2026-02-23T17:56:34.489Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/f2/62bad61bd2d899f5459a265f315f541fd1f25c0532ce9303a5e867ed345a/pydantic_ai-1.58.0-py3-none-any.whl", hash = "sha256:4bf6635e7eb3279cba92789a619fbd4caa83035b4670a78ffe150d088bdc0810", size = 7220, upload-time = "2026-02-11T01:45:52.621Z" }, + { url = "https://files.pythonhosted.org/packages/a6/4b/7cf9f5b2f8971a176be6ef218ffe6a30a5461bc2bfe914356881808ce159/pydantic_ai-1.63.0-py3-none-any.whl", hash = "sha256:586f63f391aa24e8b06bd0aeafbb1058de1d4f3bfe34c5f13d4f29a2d870afa5", size = 7229, upload-time = "2026-02-23T17:56:26.921Z" }, ] [[package]] name = "pydantic-ai-slim" -version = "1.58.0" +version = "1.63.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "genai-prices" }, - { name = "griffe" }, + { name = "griffelib" }, { name = "httpx" }, { name = "opentelemetry-api" }, { name = "pydantic" }, { name = "pydantic-graph" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/14/5e/964df8db1e7e47fce0fed14bbe4445aae4e8ec72b8e113935d3476bf2021/pydantic_ai_slim-1.58.0.tar.gz", hash = "sha256:8f13086aaa046c759ddc36c14593f95c15fb09df1c3998e690df3c79732931dc", size = 415339, upload-time = "2026-02-11T01:46:03.286Z" } +sdist = { url = "https://files.pythonhosted.org/packages/da/6d/2b5c0c60b42e6af49830f6a09b5d38fecdb1f20d9659152691eba95613b4/pydantic_ai_slim-1.63.0.tar.gz", hash = "sha256:9377afecdfe4bc17f5c9ed72c758e460703ac5876931aa2f18ace8ac0e69312a", size = 426862, upload-time = "2026-02-23T17:56:36.215Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/89/4a/b29b7d2dad4ce6af053206474457c16a978aa974182bf6925e1b282704eb/pydantic_ai_slim-1.58.0-py3-none-any.whl", hash = "sha256:077e2d3ac95f8121a8988808674400be0c3e5f88e7e3cd30b317fe50096b2935", size = 542309, upload-time = "2026-02-11T01:45:55.65Z" }, + { url = "https://files.pythonhosted.org/packages/f2/ca/c4e39eec1cff5a294b64313a8a959b38d326819e0f0a41f48e61ce019a22/pydantic_ai_slim-1.63.0-py3-none-any.whl", hash = "sha256:ed393b0f871b748171f65bec5191c3025b5abb8a4fc616afee17eb9dc2dfa15d", size = 554190, upload-time = "2026-02-23T17:56:29.533Z" }, ] [package.optional-dependencies] @@ -3014,7 +2988,7 @@ groq = [ { name = "groq" }, ] huggingface = [ - { name = "huggingface-hub", extra = ["inference"] }, + { name = "huggingface-hub" }, ] logfire = [ { name = "logfire", extra = ["httpx"] }, @@ -3145,7 +3119,7 @@ wheels = [ [[package]] name = "pydantic-evals" -version = "1.58.0" +version = "1.63.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -3155,14 +3129,14 @@ dependencies = [ { name = "pyyaml" }, { name = "rich" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bc/d8/3a4007c945611880be928772186633f9006219e67a2f9976bff95bbc7a33/pydantic_evals-1.58.0.tar.gz", hash = "sha256:4f0d63c550c3fc625499546b6c2e02a2da78fede7f0fa423febd7985e01d9768", size = 54093, upload-time = "2026-02-11T01:46:04.303Z" } +sdist = { url = "https://files.pythonhosted.org/packages/99/43/21b6ddf65b56f7401c344f98e4e6258a02d2868c8a52a8b79c0e0e701029/pydantic_evals-1.63.0.tar.gz", hash = "sha256:eed56a7192e07c8be8cf16e53bb2ef652b4f7f7b8527650ac45fde865a4ecf9d", size = 56365, upload-time = "2026-02-23T17:56:37.71Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/29/c63666d9dd8ffd9870272f740298b59b6748041a6f67316385d3d85a22a9/pydantic_evals-1.58.0-py3-none-any.whl", hash = "sha256:a53f5048a0a59eb46216cdb298785368ad099d95c7561f5122f7af6356f8db91", size = 65156, upload-time = "2026-02-11T01:45:57.382Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f2/7174ad6abca2457e35a1b902ca4fa78aa8ee72e4ec2e9cd5dc8904014ec9/pydantic_evals-1.63.0-py3-none-any.whl", hash = "sha256:2e92a3af579a5670b2babf2044081d0ef99ab5a9ef141972616d71fd7e5bfd0e", size = 67279, upload-time = "2026-02-23T17:56:31.008Z" }, ] [[package]] name = "pydantic-graph" -version = "1.58.0" +version = "1.63.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -3170,23 +3144,23 @@ dependencies = [ { name = "pydantic" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/fa/b6b30889615beafb6d00738cf48d1227ce74223878a4f35fd9bccd664fa8/pydantic_graph-1.58.0.tar.gz", hash = "sha256:5a697ac5e27aa0ed2936175e4e6fca473c309e65856a45840468dbca4641ea5b", size = 58484, upload-time = "2026-02-11T01:46:05.627Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7a/c8/aa3cb56552562b799f31e9de291c8bd88306308cfc9647d220dfff2bea18/pydantic_graph-1.63.0.tar.gz", hash = "sha256:5fd98bb22fa6181f0357a6ffad38a3214af12868bd46492d6456c5db434466b4", size = 58528, upload-time = "2026-02-23T17:56:39.118Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/07/971a2c73415e895b869eb49bed73562aced8086a736574acc8c181e247cd/pydantic_graph-1.58.0-py3-none-any.whl", hash = "sha256:706441a4122e1b893f442d74e7e5d4f44f3d0eb69ff4d26882afc323e570f54e", size = 72346, upload-time = "2026-02-11T01:45:58.545Z" }, + { url = "https://files.pythonhosted.org/packages/a4/1c/8dcae24c824dd2690fbe7375083b369b10ed1ad773e2b9d1122bb6c0fcdc/pydantic_graph-1.63.0-py3-none-any.whl", hash = "sha256:d9b7a387116f358d470c042b07aa08125cadfcfa8c08ef01769746a489aef0d5", size = 72353, upload-time = "2026-02-23T17:56:32.304Z" }, ] [[package]] name = "pydantic-settings" -version = "2.12.0" +version = "2.13.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" } +sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, + { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, ] [[package]] @@ -3245,7 +3219,7 @@ wheels = [ [[package]] name = "pytest" -version = "8.4.2" +version = "9.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -3254,21 +3228,22 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] [[package]] name = "pytest-asyncio" -version = "0.26.0" +version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8e/c4/453c52c659521066969523e87d85d54139bbd17b78f09532fb8eb8cdb58e/pytest_asyncio-0.26.0.tar.gz", hash = "sha256:c4df2a697648241ff39e7f0e4a73050b03f123f760673956cf0d72a4990e312f", size = 54156, upload-time = "2025-03-25T06:22:28.883Z" } +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/7f/338843f449ace853647ace35870874f69a764d251872ed1b4de9f234822c/pytest_asyncio-0.26.0-py3-none-any.whl", hash = "sha256:7b51ed894f4fbea1340262bdae5135797ebbe21d8638978e35d31c6d19f72fb0", size = 19694, upload-time = "2025-03-25T06:22:27.807Z" }, + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, ] [[package]] @@ -4111,15 +4086,15 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.40.0" +version = "0.41.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" } +sdist = { url = "https://files.pythonhosted.org/packages/32/ce/eeb58ae4ac36fe09e3842eb02e0eb676bf2c53ae062b98f1b2531673efdd/uvicorn-0.41.0.tar.gz", hash = "sha256:09d11cf7008da33113824ee5a1c6422d89fbc2ff476540d69a34c87fab8b571a", size = 82633, upload-time = "2026-02-16T23:07:24.1Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" }, + { url = "https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187", size = 68783, upload-time = "2026-02-16T23:07:22.357Z" }, ] [package.optional-dependencies] diff --git a/control_docs/CHANGELOG.md b/control_docs/CHANGELOG.md index 415c829..eb795ad 100644 --- a/control_docs/CHANGELOG.md +++ b/control_docs/CHANGELOG.md @@ -1,6 +1,53 @@ # Changelog All notable changes documented here. Format: [Keep a Changelog](https://keepachangelog.com/). +## [0.14.0] - 2026-02-20 (Sprint 27 — Critical Fixes, Demo Auth, Visual Polish) + +### Added +- POST /auth/demo-login endpoint — proper JWT auth flow for demo profiles, accepts both long keys (teacher-ms-johnson) and short aliases (teacher), 7 new tests (F-247) +- Demo users seeded with hashed password (demo123) so email login also works (F-248) +- demoLogin() async helper in frontend api.ts — calls demo-login, stores JWT in auth store (F-247) +- Pipeline SVG connectors with animated dash-flow on active edges + arrow heads (F-234) +- Node glow effects on pipeline (active=warning glow, completed=success, failed=error) (F-234) +- Motor accessibility: pill-shaped buttons (28px radius), 3px focus halos with 8px tap zone indicators, bottom action bar padding (F-235) +- btn-press micro-interaction (scale 0.97 + inset shadow on active), slide-in-right animation for SSE notifications (F-236) + +### Fixed +- **P0:** VALID_DEMO_PROFILES expanded to include both short (login page) and long (landing page) key formats (F-243) +- **P0:** EvidencePanel aria-labelledby — added missing id attribute on toggle button (F-244) +- **P0:** PreferencesPanel stale-closure focus restore — replaced cleanup pattern with direct branch (F-245) +- **HIGH:** JWT iss/aud claims now minted when AILINE_JWT_ISSUER/AUDIENCE env vars configured (F-246) +- Login rate limit raised from 5/min to 20/min for demo-friendly Docker testing (F-249) + +### Changed +- Docker frontend memory 512M → 2G with NODE_OPTIONS=--max-old-space-size=1536 (F-250) +- Frontend healthcheck: wget || curl fallback +- Landing + login pages upgraded to JWT-first demo login (X-Teacher-ID fallback preserved) +- /auth/demo-login excluded from tenant middleware +- Total: 2,413 backend + 1,411 frontend = **3,824 tests passing**, 0 lint/type errors + +## [0.13.0] - 2026-02-19 (Comprehensive Review & Polish) + +### Added +- A11y Status Badge — floating "Make the Invisible Visible" indicator showing active persona + features count, expandable detail panel with ARIA compliance (F-223, 11 tests) +- Persona Explainer Banner — "Why this adaptation?" context banner below topbar with persona-specific hints, aria-live polite (F-224, 8 tests) +- GET /capabilities endpoint — platform feature discovery (LLM, TTS, image gen, vector search, braille, skills, demo mode), excluded from auth/rate-limit (F-225, 3 tests) +- Enhanced focus states — WCAG outline+offset indicators with forced-colors mode for Windows High Contrast (F-226) +- Theme color preview chips — 3 color circles (bg, primary, text) next to each theme in preferences panel (F-227) +- Config validation at startup — Settings.validate_environment() called before Container.build() with structured warnings (F-228) +- 16 new test files covering login phases, auth store, sign language selector, wizard steps, settings, API, demo data (F-229, 133 tests) + +### Fixed +- useTranslations mock: stable function references per namespace (OOM fix in vitest) +- test_tenant_context: HS256-signed JWTs with dev secret for proper verification +- exports/page.test.tsx: removed importActual causing OOM +- JWT middleware: dev-secret fallback with proper HS256 verification +- Demo mode: _require_demo_mode guard fix + +### Changed +- Expert reviews by GPT-5.2 (backend architecture) and GPT-5.2 (frontend UX) — Sprint 27 plan created with 13 features across 4 phases +- Total: 2,406 backend + 1,397 frontend = **~3,803 tests passing**, 0 lint/type errors + ## [0.12.0] - 2026-02-19 (Sprint 26 — "Make the Invisible Visible") ### Added diff --git a/control_docs/FEATURES.md b/control_docs/FEATURES.md index 4329b2f..1381bc9 100644 --- a/control_docs/FEATURES.md +++ b/control_docs/FEATURES.md @@ -84,6 +84,89 @@ F-156 to F-162 (7 features): Sign Language Interpreter Skill, Multi-Language Ada - [F-221] Braille Download + Copy — Download .brf file + copy to clipboard buttons added to BraillePreview component - [F-222] Inclusive Classroom Mode — "One Lesson, Four Adapted Plans" 2x2 teacher cockpit grid (Lucas/ASD, Sofia/ADHD, Pedro/Dyslexia, Ana/Hearing) with accent colors and accommodation badges +### Improvements Branch — Comprehensive Review & Polish (Feb 19, 2026) +- [F-223] A11y Status Badge — Floating "Make the Invisible Visible" indicator showing active persona + features count, expandable detail panel, ARIA-compliant (11 tests) +- [F-224] Persona Explainer Banner — "Why this adaptation?" context banner below topbar, shows persona icon/name/hints, aria-live polite (8 tests) +- [F-225] Capabilities Endpoint — GET /capabilities returns platform feature availability (LLM, TTS, image gen, vector search, braille, skills, demo mode), excluded from auth/rate-limit (3 tests) +- [F-226] Enhanced Focus States — WCAG-compliant outline+offset focus indicators, forced-colors mode support for Windows High Contrast +- [F-227] Theme Color Preview Chips — 3 color circles (bg, primary, text) next to each theme in preferences panel +- [F-228] Config Validation at Startup — Settings.validate_environment() called before Container.build(), fail-fast with structured warnings +- [F-229] Comprehensive Test Expansion — 16 new test files, 133 new tests covering login phases, auth store, sign language selector, wizard steps, settings, API, demo data + +### Sprint 27 — Production-Grade Polish (Feb 20, 2026) +- [F-230] PostgresUserRepository — SessionFactoryUserRepository with DI wiring for persistent user storage +- [F-231] JWT Hardening — RS256/HS256 algorithm selection, jti claim + Redis blacklist, POST /auth/logout, configurable TTL +- [F-232] Health Diagnostics Split — public /health/diagnostics vs private /internal/diagnostics with auth +- [F-233] Before/After Accessibility Compare Slider — draggable theme comparison, keyboard accessible +- [F-234] Pipeline Edge Animations — SVG stroke-dashoffset flow + node glow states +- [F-235] Motor Accessibility Mode — MotorStickyToolbar with 56px targets, pill shapes, focus halos +- [F-236] Micro-interactions — btn-press scale(0.97), slide-in animation, dash-flow keyframe +- [F-237] Run Resource Model — GET /runs (list + filter + pagination), GET /runs/{id} (detail) +- [F-238] RFC 7807 Error Model — already implemented (error_handler.py, application/problem+json) +- [F-239] TenantContext Explicit Dependencies — all routers use Depends(require_authenticated) +- [F-240] Config Deduplication — AiLineConfig deprecated with DeprecationWarning, Settings is canonical +- [F-241] Cache Skill Registry — _get_skills_info() cached once, used by diagnostics + capabilities +- [F-242] Demo Storyboard — 2 tracks (Teacher/Accessibility), 5 steps each, full i18n +- [F-243] Demo Profile Key Mismatch — VALID_DEMO_PROFILES includes both short and long keys +- [F-244] EvidencePanel aria-labelledby Fix — missing id on toggle button +- [F-245] PreferencesPanel Focus Restore — stale closure fix +- [F-246] JWT iss/aud Claims — mint when env vars configured +- [F-247] /auth/demo-login Endpoint — proper JWT flow with short/long key aliases +- [F-248] Demo Users Seeded — hashed password (demo123), email login works +- [F-249] Login Rate Limit — raised to 20/min for demo-friendly Docker testing +- [F-250] Docker Frontend Memory — 512M to 2G, NODE_OPTIONS=--max-old-space-size=1536 + +### Sprint 28 — Security Containment + Docs Sync (Feb 21, 2026) +- [F-251] Demo Login Privilege Escalation Fix — _validate_role() enforcement + admin profiles removed from DEMO_PROFILES +- [F-252] TraceStore Tenant Integrity — append_node/update_run no longer auto-create RunTrace +- [F-253] Dev Mode Default OFF — AILINE_DEV_MODE defaults to false in Docker Compose +- [F-254] JWT Dev Secret Module — Shared jwt_dev_secret.py replaces hardcoded fallback secrets +- [F-255] Demo Login Rate Limiting — Per-IP rate limit (20/min) on /auth/demo-login +- [F-256] Diagnostics Admin-Only — /internal/diagnostics restricted to admin role +- [F-257] Rate Limiter Docs Alignment — Docstrings corrected to 20 attempts/minute +- [F-258] Redis Access Encapsulation — EventBus.get_redis_client() protocol method replaces private _redis +- [F-259] TraceStore Server-Side Filtering — list_recent() accepts status parameter +- [F-260] Seed Import Unification — All imports use demo_profiles module directly +- [F-261] Role Validation 422 — _validate_role() raises HTTP 422 for invalid roles +- [F-262] Body Immutability — plans_stream uses Pydantic model_copy instead of mutation +- [F-263] SessionStorage JWT Cleanup — Removed insecure sessionStorage fallback +- [F-264] Docker Port Hardening — DB/Redis ports bound to 127.0.0.1 +- [F-265] SYSTEM_DESIGN.md Version Sync — Tailwind, motion, next-intl, pydantic-ai versions corrected +- [F-266] FEATURES.md Sprint 27 — Added Sprint 27 section (F-230 to F-250) +- [F-267] SECURITY.md Roles Update — Corrected to 5 roles (super_admin through parent) +- [F-268] RUN_DEPLOY.md Port Defaults — Fixed port defaults (8011/3011/5411/6311) +- [F-269] TEST.md Counts Update — Updated to ~3,889 tests, fixed pytest command +- [F-270] Frontend CLAUDE.md Sync — Tailwind and motion versions corrected +- [F-271] I18N Diacritics Fix — 7 pt-BR + 1 es diacritics corrected + +## Planned — Sprint 29: Design System, SVG Illustrations, Core Views & Emotional UX ("Soft Clay") +F-272→F-310 (39 stories, 6 phases, 3 weeks). HIGHEST PRIORITY. Design: "Soft Clay 2.5D Organic" via Gemini-3.1-Pro (6 consultations). Benchmarks: Khan Academy, Duolingo, Seesaw, Google Classroom. Phase 1: design system (tokens, tailwind-variants, lucide-react, Inter+Jakarta Sans, Azure/Sage/Amber). Phase 2: SVG illustration system (38+ Gemini-generated SVGs — empty states, onboarding, landing, personas, a11y icons). Phase 3: 3 CRITICAL missing views (Learning Analytics Dashboard, Student Learning View, Parent Progress Report + Recharts a11y infra). Phase 4: page redesigns (landing, dashboard, tablet, dark theme, onboarding). Phase 5: emotional safety (neutral errors, visual timer, Quiet Mode, ADHD support, Dyslexia rows). Phase 6: engagement (drag-and-drop, PWA, Learning Rhythm gamification). Stretch: Constellation map, push notifications, multimodal responses, Transition Passport. + +## Planned — Sprint 30: Backend Production, Observability & Compliance +F-311→F-330 (20 stories, 6 phases, 2-3 weeks). Architecture: GPT-5.3-codex (3 consultations). GDPR/LGPD verified. Phase 1 (Day 1): GEMINI_API_KEY fix (config.py AliasChoices), PII log redaction, Google paid-service guard. Phase 2: audit log + PII encryption. Phase 3: OTEL Redis, trace-ID correlation, LLM baggage. Phase 4: distributed idempotency, SmartRouter failover, SSE backpressure, graceful shutdown, K8s probes. Phase 5: split app.py/auth.py/models.py + remove AiLineConfig. Phase 6: Docker CI + Playwright CI. + +## Planned — Sprint 31: Architecture Evolution & Agent Maturity +F-331→F-345 (15 stories, 4 phases, 2-3 weeks). Phase 1: service layer (Commands/Queries), domain events, arq worker. Phase 2: API versioning (/v1/), cursor pagination. Phase 3: prompt registry, per-tenant cost tracking, content safety guardrails, semantic AI caching. Phase 4: persistent stores, embedding fix, InMemoryStore base, PgVectorStore cleanup, global state docs. + +## Planned — Sprint 32A: Privacy Compliance (LGPD/GDPR) +F-346→F-355 (10 stories, 2 weeks). Data tier classification, portability export (LGPD Art.18), deletion/anonymization pipeline, minor consent (LGPD Art.14), retention engine, incident response (ANPD 3d/GDPR 72h), provider DPA register, Redis eviction safety, Postgres RLS, notification service. + +## Planned — Sprint 33+: Advanced Features +F-356→F-375 (20 stories, 4-6 weeks). Gamification: Learning Rhythm, Skill Nodes, Constellation. Educational intelligence: spaced repetition, IEP tracking, AI Social Stories (ASD), predictive accommodations, cognitive simplification. Platform: feature flags, CQRS, A/B testing, personalization engine. Infra: Meilisearch, API SDK generation, admin dashboard, batch operations, webhooks, Git LFS, AT testing. + +## Planned — Sprint 34: Emotional Safety & Executive Function (Empowerment Pillar 1) +F-376→F-393 (18 stories, 4 phases, 3 weeks). SAMHSA 6 Trauma-Informed Principles + 6-subsystem Executive Function Support. Architecture: Gemini-3.1-Pro (3 consultation rounds). Phase 1 (P0): transition warnings ASD (aria-live="assertive"), task decomposition "Stepping Stones" SVG, safe feedback (amber not red, no "wrong" language), global undo + auto-save. Phase 2 (P0): working memory "Where was I?", initiation support "Just Start", emotional check-in (energy/pleasantness grid), frustration detection + de-escalation, breathing exercise, parking lot (distracting thoughts). Phase 3 (P1): productive struggle indicator, dignity-preserving celebrations (per-persona: TEA=text-only), self-assessment emoji scale. Phase 4 (P2): asset-based parent reports (no deficit language), growth portfolio, store integration, i18n 40+ keys, test suite. New: 12 DB tables, 15 API endpoints, 7 Zustand stores, CSS token changes (--color-feedback-safe). + +## Planned — Sprint 35: AI Educational Intelligence Tools (Empowerment Pillar 2) +F-394→F-411 (18 stories, 3 phases, 3 weeks). 3 AI-powered tools closing accommodation→empowerment gap. Architecture: Gemini-3.1-Pro (3 rounds). Phase 1 (P0): SocialStoryAgent (Carol Gray 10.2 methodology, deterministic 4:1 ratio validation, 10 scenario templates, multi-format export: text/audio/PDF/cards, StoryCardViewer). Phase 2 (P0-P1): readability analysis (Flesch-Kincaid/Gunning Fog deterministic), 4-level SimplificationAgent (Original→Simplified→Highly Simplified→Symbol-Supported), ComplexitySlider UI, plan_workflow/tutor_workflow integration. Phase 3 (P1): telemetry ingestion (7 signal types, partitioned, Redis buffer), BehavioralAnalyzer (rolling windows, 0-1 normalized), AccommodationPredictor (heuristic rules, dignity framing, NEVER auto-activate), ZPD adaptive difficulty (Vygotsky, frustration ceiling), teacher insights dashboard. New: 2 Pydantic AI agents, 3 skills, 1 LangGraph workflow, 5 DB tables, 14 API endpoints. + +## Planned — Sprint 36: IEP/MTSS, SM-2 Spaced Repetition, Mastery & Personalization (Empowerment Pillar 3) +F-412→F-447 (36 stories, 5 phases, 4-6 weeks). Educational data intelligence backbone. Architecture: GPT-5.3-codex (2 rounds) + GPT-5.2 thinkdeep. Phase 1 (P0): IEP document lifecycle (7 states), SMART goals (curriculum-linked), 6 accommodation categories, progress monitoring (4 evidence types), services tracking, team/notifications, domain entities + repository. Phase 2 (P0): MTSS 3-tier system, evidence-based intervention catalog (4 evidence levels), student interventions + fidelity tracking, screening + meetings, domain entities. Phase 3 (P0): consent management (IDEA + LGPD Art. 14, 6 consent types), immutable audit ledger (SHA-256 hashes), data retention policies (IEP 7yr, MTSS 5yr, reviews 3yr). Phase 4 (P1): review items (7 modalities), student review profiles (processing speed matrix: 7 needs × 7 modalities), SM-2 inclusive adaptation (calibration period, streak bonus, PSF), fatigue detection (3 rules), due reviews, review sessions, health dashboard, tutor integration (review_check_node). Phase 5 (P1-P2): mastery map (5 levels, ZPD bounds), evidence collection (5 sources), skill gap analysis, learning profiles, AI recommendations engine (heuristic-first, teacher approval), auto-generation from mastery, data retention enforcement, privacy dashboard. New: 24 DB tables, 38 API endpoints, 6 domain entity files, 6 repository ports + adapters, migration 0006. + ## Backlog -- [F-035] Sign Language Post-MVP Path — SPOTER transformer + VLibrasBD NMT dataset (ADR-047) -- [F-178] Teacher Skill Sets — Per-teacher skill configurations with presets +- [F-035] Sign Language Post-MVP — SPOTER transformer + VLibrasBD NMT +- [F-178] Teacher Skill Sets — per-teacher presets +- Braille Grade 2 via liblouis +- Symbol-Supported Text (PCS pictograms) +- Synchronized word-level TTS highlighting +- Haptic/Vibrational feedback (Web Haptics API) diff --git a/control_docs/RUN_DEPLOY.md b/control_docs/RUN_DEPLOY.md index 6ca9ce6..3ef618d 100644 --- a/control_docs/RUN_DEPLOY.md +++ b/control_docs/RUN_DEPLOY.md @@ -16,13 +16,19 @@ GOOGLE_API_KEY=... # for Gemini embeddings OPENAI_API_KEY=... # fallback LLM ELEVENLABS_API_KEY=... # TTS # Docker port overrides (if collisions): -API_HOST_PORT=8000 -FRONTEND_HOST_PORT=3000 +API_HOST_PORT=8011 # default host port for API +FRONTEND_HOST_PORT=3011 # default host port for frontend +DB_HOST_PORT=5411 # default host port for Postgres +REDIS_HOST_PORT=6311 # default host port for Redis +# Frontend container env (set in docker-compose.yml): +# API_INTERNAL_URL=http://api:8000 +# HOSTNAME=0.0.0.0 +# NODE_OPTIONS=--max-old-space-size=1536 ``` ## Docker Compose (recommended) ```bash -# Full stack: api (8000), frontend (3000), db (5432 internal), redis (6379 internal) +# Full stack: api (8011), frontend (3011), db (5411), redis (6311) — host ports docker compose up -d --build docker compose ps # health @@ -34,10 +40,10 @@ docker compose down -v # stop + delete data **Services:** | Service | Image | Port (host) | Network | |---------|-------|-------------|---------| -| db | pgvector/pgvector:0.8.0-pg16 | none (internal) | backend | -| redis | redis:7.4-alpine | none (internal) | backend | -| api | runtime/Dockerfile | 8000 | backend + frontend | -| frontend | frontend/Dockerfile | 3000 | frontend | +| db | pgvector/pgvector:0.8.0-pg16 | 127.0.0.1:5411 | backend | +| redis | redis:7.4-alpine | 127.0.0.1:6311 | backend | +| api | runtime/Dockerfile | 8011 | backend + frontend | +| frontend | frontend/Dockerfile | 3011 | frontend | **DB init:** `infra/db/init-pgvector.sql` auto-creates the `vector` extension via docker-entrypoint-initdb. diff --git a/control_docs/SECURITY.md b/control_docs/SECURITY.md index 5dc774a..8ba79f1 100644 --- a/control_docs/SECURITY.md +++ b/control_docs/SECURITY.md @@ -40,11 +40,11 @@ ## Access Control - JWT RS256/ES256 verification with JWKS support, algorithm pinning, iss/aud/exp/nbf validation (F-100) - 57 JWT security tests: forged, expired, wrong aud/kid, replay, tenant impersonation (F-101) -- Centralized authorization policy (authz.py): `require_authenticated`, `require_tenant_access`, `can_observe` (ADR-060) -- **All API endpoints require authentication** (media, sign-language, plans, tutors, materials, observability, traces, RAG diagnostics) +- Centralized authorization policy (authz.py, ADR-060): `require_authenticated`, `require_tenant_access`, `require_role`, `require_admin`, `require_teacher_or_admin`, `require_any_authenticated_role`, `can_access_student_data`, `check_student_access`, `can_observe` +- **All business/data endpoints require authentication** (media, sign-language, plans, tutors, materials, observability, traces, RAG diagnostics). Public exceptions: `GET /capabilities`, `GET /health`, `POST /auth/login`, `POST /auth/register`, `POST /auth/demo-login` - WebSocket `/ws/libras-caption` requires JWT token via `?token=` query parameter - CORS `X-Teacher-ID` header only allowed in dev mode (removed from production CORS) -- Roles: admin, educator, student +- Roles (5): super_admin, school_admin, teacher, student, parent (super_admin bypasses all role checks) - Principle of least privilege on all service accounts ## Prompt Injection Defenses (F-102) diff --git a/control_docs/SYSTEM_DESIGN.md b/control_docs/SYSTEM_DESIGN.md index 5c88c64..91402bf 100644 --- a/control_docs/SYSTEM_DESIGN.md +++ b/control_docs/SYSTEM_DESIGN.md @@ -18,7 +18,7 @@ Hexagonal (Ports-and-Adapters). Domain core has zero framework imports. User request -> FastAPI SSE endpoint -> LangGraph StateGraph: 1. Parallel fan-out: [RAG_Search | Profile_Analyzer] (simultaneous via static edges) -2. Fan-in -> PlannerAgent (Pydantic AI 1.58.0, StudyPlanDraft output) (ADR-059) +2. Fan-in -> PlannerAgent (Pydantic AI >=1.58.0, StudyPlanDraft output) (ADR-059) 3. QualityGateAgent (deterministic validator, score 0-100) 4. Conditional: score < 80 -> Refine loop (max 2 iters) | else -> ExecutorAgent 5. ExecutorAgent (Pydantic AI + LangGraph ToolNode) with tool bridge (ToolDef -> @agent.tool) (ADR-048/059) @@ -41,7 +41,7 @@ Student message -> SSE (fetch-event-source) -> LangGraph tutor StateGraph: 8 international sign languages: ASL (US), BSL (UK), LGP (Portugal), DGS (Germany), LSF (France), LSE (Spain), Libras (Brazil), ISL (Ireland). Webcam -> MediaPipe JS (Hands+Pose) -> TF.js MLP classifier -> Gloss labels (limited to 3-4 navigation gestures for MVP) --> Dedicated WebSocket /ws/accessibility/libras?lang={code} -> LLM gloss->sentence (per-language prompt) -> Response +-> Dedicated WebSocket /sign-language/ws/libras-caption?lang={code} -> LLM gloss->sentence (per-language prompt) -> Response Text -> VLibras widget (text->Libras 3D avatar, government CDN) Discovery API: GET /sign-language/languages, /languages/{code}, /languages/{code}/gestures, /for-locale/{locale} @@ -52,7 +52,7 @@ Upload -> parse (PDF/DOCX/TXT) -> chunk (512 tokens, 64 overlap) -> embed (gemin ### Demo System -8 pre-built demo profiles (teacher, 4 students with ASD/ADHD/Dyslexia/Hearing, parent, school_admin, super_admin) with seed data. Demo login bypasses auth for hackathon evaluation. Each profile has pre-populated courses, plans, progress records, and tutor sessions. +6 pre-built demo profiles (teacher, 4 students with ASD/ADHD/Dyslexia/Hearing, parent) with seed data. Demo login bypasses auth for hackathon evaluation. Each profile has pre-populated courses, plans, progress records, and tutor sessions. ### RBAC & Authentication @@ -95,7 +95,7 @@ Format: `provider:model-name` (anthropic, google-gla, openai, openrouter). 3. SkillPromptComposer: priority sort -> header -> soft-cap -> proportional truncate -> drop lowest 4. SkillCrafter agent: multi-turn conversational skill creation by teachers -> CraftedSkillOutput -Agents: Planner, Executor, QualityGate, Tutor, SkillCrafter (5 total, Pydantic AI 1.58.0) +Agents: Planner, Executor, QualityGate, Tutor, SkillCrafter (5 total, Pydantic AI >=1.58.0) ### "Make the Invisible Visible" (Sprint 26) @@ -106,6 +106,21 @@ Frontend components that expose the AI pipeline to users: - **TTS Audio Player**: HTMLAudioElement-based player calling POST /media/tts/synthesize with speed/language/voice controls. - **Inclusive Classroom Mode**: 2x2 teacher cockpit grid showing 4 students (Lucas/Sofia/Pedro/Ana) with disability-specific accent colors and accommodation badges. +### Capabilities Discovery (GET /capabilities) + +Public endpoint returning platform feature availability based on wired adapters and configuration: +- `version`, `personas` (9), `sign_languages` (8) +- `llm` (available + provider), `tts` (ElevenLabs), `image_generation` (Gemini Imagen) +- `vector_search` (pgvector), `braille` (grades + languages), `skills` (count + categories) +- `demo_mode` (boolean) +Excluded from auth middleware, rate limiting, and tenant context. Enables progressive enhancement on frontend. + +### Accessibility Status System + +- **A11yStatusBadge**: Floating indicator (bottom-left) showing active persona icon + name, feature count badge for non-default settings, expandable detail panel with all active features (theme, font, motion, focus, bionic) +- **PersonaExplainer**: Banner below topbar when non-standard persona active, shows icon + theme name + adaptation hints with aria-live="polite" +- **Focus States**: WCAG outline (3px solid primary, offset 3px) + forced-colors mode (Highlight color, no box-shadow) + ## ADR Log 60 ADRs (ADR-001 through ADR-060). Key decisions: 001 Hexagonal | 002-003 SQLAlchemy+pgvector | 006 SSE+WS | 012 multi-tenancy | 013 parallel LangGraph | 017/028-029/049 SmartRouter | 024/038 typed SSE | 042 recursion_limit=25 | 048 direct Anthropic API (no SDK) | 050 tiered quality gate | 051 FakeLLM CI | 053 composite FK | 054 SSE replay (Redis ZSET) | 055 RunContext terminal guarantee | 056 pool sizing | 057 Web Worker sign lang | 058 ThemeContext | 059 ailine_agents (Pydantic AI) | 060 structural tenant isolation + centralized authz @@ -115,14 +130,15 @@ Frontend components that expose the AI pipeline to users: **Backend (Python 3.13, uv-managed):** FastAPI 0.129.0, SQLAlchemy 2.0.46, Alembic 1.18.4, asyncpg 0.31.0, Pydantic 2.12.5, -LangGraph 1.0.8, Pydantic AI 1.58.0, ailine-agents 0.1.0, DeepAgents 0.4.1, +LangGraph 1.0.8, Pydantic AI >=1.58.0, ailine-agents 0.1.0, DeepAgents 0.4.1, Anthropic SDK 0.79.0, OpenAI SDK 2.20.0, google-genai 1.63.0, -pgvector-python 0.4.2, structlog 25.4.0, sse-starlette 3.2.0, faster-whisper 1.2.1, -uuid-utils 0.14.0, langgraph-checkpoint-postgres 3.0.4, aiosqlite 0.21.0, pypdf 5.x +pgvector-python 0.4.2, structlog 25.5.0, sse-starlette 3.2.0, +uuid-utils 0.14.0, aiosqlite 0.22.1 +**Note:** Embedding dimensions inconsistency — config default 3072 vs migration hardcode 1536 (MRL). **Frontend (Node 24, pnpm):** -Next.js 16.1.6, React 19.2.4, Tailwind 4.1.18, shadcn/ui 3.8.4, next-intl 4.8.2, -Zustand 5.0.11, motion 12.34.0, Recharts 3.7.0, DOMPurify 3.3.1, +Next.js 16.1.6, React 19.2.4, Tailwind 4.2.0, next-intl 4.8.3, +Zustand 5.0.11, motion 12.34.2, Recharts 3.7.0, DOMPurify 3.3.1, @mediapipe/tasks-vision 0.10.32, @tensorflow/tfjs 4.22.0, @microsoft/fetch-event-source 2.0.1 **Infrastructure:** PostgreSQL 16, pgvector 0.8.1, Redis 7.x, Docker Compose v2 diff --git a/control_docs/TEST.md b/control_docs/TEST.md index e7152d6..2a70f7b 100644 --- a/control_docs/TEST.md +++ b/control_docs/TEST.md @@ -8,9 +8,9 @@ - **Security:** trivy (container), pip-audit (Python), pnpm audit (Node) ## Coverage Targets -- Pre-MVP (current): 2,432 backend tests (2,155 runtime + 277 agents), all passing -- Frontend: 1,116 tests, all passing -- Total: 3,548 tests, all lint/typecheck gates green (ruff, mypy, ESLint, tsc) +- Pre-MVP (current): ~2,445 backend tests (runtime + agents), all passing +- Frontend: ~1,438 tests, all passing +- Total: ~3,883 tests, all lint/typecheck gates green (ruff, mypy, ESLint, tsc) - E2E: 3 Playwright golden path specs + 8 visual regression + axe-core a11y audit - Agent eval: 15 golden test sets (Planner/QualityGate/Tutor) + rubric scoring - Post-MVP: >= 90% on touched code (hard gate) @@ -38,7 +38,7 @@ cd frontend && pnpm test && pnpm test:coverage # Frontend test cd frontend && pnpm exec playwright test # E2E + a11y cd runtime && uv run ruff check . && uv run mypy . # Lint + typecheck cd frontend && pnpm lint && pnpm exec tsc --noEmit # Frontend lint -docker compose up -d --build && docker compose exec api uv run pytest -v # Docker (source of truth) +docker compose up -d --build && docker compose exec api python -m pytest -v # Docker (source of truth) ``` ## Test Matrix diff --git a/control_docs/TODO.md b/control_docs/TODO.md index e3ea35e..c7cdf74 100644 --- a/control_docs/TODO.md +++ b/control_docs/TODO.md @@ -4,40 +4,315 @@ Status key: `[x]` done | `[~]` in-progress | `[ ]` planned --- -## Completed Sprints (S0-25) +## Completed Sprints (S0-28) -All 26 sprints COMPLETED. 224 features (F-001 through F-224). ~3,600+ tests (2,348 runtime + 277 agents + 1,236 frontend). 0 lint/type errors. Docker 4 services healthy. See FEATURES.md for per-sprint detail. +All 29 sprints COMPLETED. 271 features (F-001 through F-271). 3,889 tests (2,451 runtime + 1,438 frontend). 0 lint/type errors. Docker 4 services healthy. See FEATURES.md for per-sprint detail. --- -## Sprint 26 — "Make the Invisible Visible" — DONE +## Sprint 29 — Design System, SVG Illustrations, Core Views & Emotional UX ("Soft Clay") — PLANNED -### Backend Wiring (F-175/F-176/F-165/F-177) -- [x] Skills API wired to app factory with SessionFactorySkillRepository (F-176) -- [x] TTS router wired with ElevenLabs/FakeTTS fallback (F-165) -- [x] Skills workflow nodes integrated in plan + tutor workflows (F-177) -- [x] Settings: elevenlabs_api_key field, diagnostics endpoint updated +39 stories (F-272 → F-310). HIGHEST PRIORITY. 3 weeks. +Design: "Soft Clay 2.5D Organic" (Gemini-3.1-Pro, 6 consultations). Benchmarks: Khan Academy, Duolingo, Seesaw. +Sources: 2 agent rounds (7 agents), competitive analysis (8 platforms), purpose analysis (10 gaps). -### Frontend — High-Impact Visual Features -- [x] Agent Pipeline Visualization — 6-node real-time graph with "Opus 4.6 Core" badge (F-217) -- [x] Adaptation Diff View — split-pane standard vs adapted with profile switching (F-218) -- [x] Evidence Panel — 6-section trust accordion (model, quality, standards, RAG, a11y, time) (F-219) -- [x] TTS Audio Player — play/pause, speed, language, voice selector (F-220) -- [x] Braille Download + Copy — download .brf, copy to clipboard (F-221) -- [x] Inclusive Classroom Mode — 4-student cockpit grid with accent colors (F-222) +### Phase 1 — Design System Foundation (P0) +- [ ] F-272: Modularize globals.css → token files (<400 LOC each) +- [ ] F-273: tailwind-variants + UI primitives (Button, Input, Dialog, Card, Badge, ActionButton w/ loading states) +- [ ] F-274: lucide-react icons (zero inline SVGs) +- [ ] F-275: Inter + Plus Jakarta Sans typography (base 18px) +- [ ] F-276: Azure #1E4ED8 / Sage #10B981 / Amber #F59E0B palette (WCAG AAA verified) +- [ ] F-277: useMotionConfig hook (centralized persona animation control) +- [ ] F-278: "Soft Clay" aesthetic (solid surfaces, 20px radius, ambient shadows, pill CTAs) -### Quality -- [x] 15 ruff lint errors fixed -- [x] 2 TypeScript errors fixed (FeatureItem icon type, ReactNode cast) -- [x] ai_receipt SSE event type mismatch fixed -- [x] evidence-panel null-safety fixes -- [x] Docker frontend memory increased to 2G +### Phase 2 — SVG Illustration System (P0) +- [ ] F-279: IllustrationBase component + ClayFilterProvider architecture +- [ ] F-280: 6 empty state illustrations (Gemini-generated, CSS custom properties, accessible) +- [ ] F-281: 5 onboarding step illustrations (Gemini-generated) +- [ ] F-282: Landing hero + 4 "How It Works" illustrations (Gemini-generated) +- [ ] F-283: 6 persona avatars + 8 a11y profile icons + 8 subject icons +- [ ] F-284: 5 error/status illustrations (404, 500, Offline, Loading, Success) + +### Phase 3 — Core Missing Views (P0-CRITICAL) +- [ ] F-285: Learning Analytics Dashboard (teacher mastery view, BarChart+AreaChart+sparklines) +- [ ] F-286: Student Learning View (/(app)/learn/[planId], mastery map, per-persona adaptations) +- [ ] F-287: Parent Progress Report (/(app)/parent-report, "Stepping Stones", conversation starters) +- [ ] F-288: Recharts a11y infra (PatternDefs, AccessibleTooltip, 56px motor hit-areas) + +### Phase 4 — Page Redesigns & Responsive (P1) +- [ ] F-289: Landing page "Soft Clay" redesign (hero illustration, ScrollReveal, scroll-snap testimonials) +- [ ] F-290: Teacher dashboard 12-col grid (bento stats, analytics integration) +- [ ] F-291: Tablet breakpoint md:768px (off-canvas sidebar, Master-Detail) +- [ ] F-292: Standard dark theme (prefers-color-scheme + toggle) +- [ ] F-293: Multi-step teacher onboarding (5 steps with SVG illustrations) + +### Phase 5 — Emotional Safety & A11y Innovation (P0-P1) +- [ ] F-294: Neutral error states (trauma-informed: no red X, encouraging microcopy) +- [ ] F-295: Visual Pie-Chart Timer (ADHD time blindness, not numeric countdown) +- [ ] F-296: "Quiet Mode" toggle (grayscale, no animations, no social — sensory processing) +- [ ] F-297: Empty states with SVG illustrations + encouraging microcopy + CTA +- [ ] F-298: ADHD Reading Ruler + Executive Function Support (chunking, Pacing Bar, checklists) +- [ ] F-299: Dyslexia alternating rows + font enhancements + +### Phase 6 — Engagement Foundation (P2) +- [ ] F-300: Drag-and-drop plan reorder (motion Reorder, keyboard a11y) +- [ ] F-301: ScrollReveal + micro-interactions kit +- [ ] F-302: PWA foundation (@serwist/next, offline read-only, InstallPrompt) +- [ ] F-303: "Learning Rhythm" forgiving gamification (no anxiety triggers) +- [ ] F-304: Celebration pattern evolution (subtle ring, no confetti) +- [ ] F-305: Persona explainer + improved a11y status + +### Stretch Goals (if time permits) +- [ ] F-306: "Constellation" curriculum map (SVG night-sky) +- [ ] F-307: Push notifications (Web Push API) +- [ ] F-308: Multimodal student responses (voice, drawing, photo) +- [ ] F-309: Classroom Constellation (cooperative social learning) +- [ ] F-310: Digital Transition Passport (Self-Advocacy Card export) + +--- + +## Sprint 30 — Backend Production, Observability & Compliance — PLANNED + +20 stories (F-311 → F-330). 2-3 weeks. +Architecture: GPT-5.3-codex (3 consultations). GDPR/LGPD verified against ANPD Resolucao 15/2024. + +### Phase 1 — Critical Fixes (P0, Day 1) +- [ ] F-311: GEMINI_API_KEY env var fix (config.py AliasChoices + 8 doc files) +- [ ] F-312: PII redaction in logs (structlog processor, zero student PII in output) +- [ ] F-313: Google paid-service guard (block free Gemini for student data per ToS) + +### Phase 2 — Compliance & Security (P0) +- [ ] F-314: Persistent audit log (FERPA/LGPD), migration 0005, admin query endpoint +- [ ] F-315: PII encryption at rest (Fernet/AES-256, key rotation) + +### Phase 3 — Observability Stack (P0-P1) +- [ ] F-316: OTEL Redis instrumentation (spans, no PII in attributes) +- [ ] F-317: Trace-ID log correlation (structlog + OTel span injection) +- [ ] F-318: LLM call baggage propagation (student_id, plan_id, agent_name across calls) + +### Phase 4 — Resilience & Reliability (P1) +- [ ] F-319: Redis-backed distributed IdempotencyGuard +- [ ] F-320: SmartRouter cross-tier fallback (primary → middle → cheap) +- [ ] F-321: SSE backpressure (bounded Queue, stream.gap notification) +- [ ] F-322: Lifecycle state + graceful shutdown (STARTING→READY→DRAINING→STOPPED) +- [ ] F-323: K8s-style health probes (live/ready/startup, migration check, LLM probe) + +### Phase 5 — Refactoring (P1) +- [ ] F-324: Split app.py (709 → ~4 modules <300 LOC each) +- [ ] F-325: Split auth.py (654 → auth_service + thin router) +- [ ] F-326: Split models.py (646 → per-domain: core/materials/pipeline/rbac/skills) +- [ ] F-327: Remove deprecated AiLineConfig (single config system) + +### Phase 6 — CI/CD (P2) +- [ ] F-328: Docker Compose integration test CI job (real Postgres + Redis) +- [ ] F-329: E2E Playwright CI job (8 specs, screenshots archived) +- [ ] F-330: Consolidate /skills vs /v1/skills (301 redirect) + +--- + +## Sprint 31 — Architecture Evolution & Agent Maturity — PLANNED + +15 stories (F-331 → F-345). 2-3 weeks. + +### Phase 1 — Application Layer (P1) +- [ ] F-331: Application Service Layer (Commands/Queries, 3 handlers) +- [ ] F-332: Domain Events Infrastructure (5 event types, 3 async handlers) +- [ ] F-333: Background Job Processing (arq worker Docker service) + +### Phase 2 — API Maturity (P1) +- [ ] F-334: API versioning (/v1/, X-API-Version header, 301 redirects) +- [ ] F-335: Cursor-based pagination (CursorPage[T], Link headers) +- [ ] F-336: Consolidate skills routers + +### Phase 3 — Agent Production Maturity (P1-P2) +- [ ] F-337: Prompt version registry (DB, activate/rollback, A/B split) +- [ ] F-338: Per-tenant cost tracking (usage_events, budget alerts) +- [ ] F-339: Content safety guardrails (age-appropriate, bias-free, PII scrub) +- [ ] F-340: Semantic AI caching (Redis, prompt-hash keys, invalidation) + +### Phase 4 — Data Layer & Cleanup (P2) +- [ ] F-341: PostgreSQL-backed persistent stores (4 stores, auto-cleanup) +- [ ] F-342: Embedding dimension fix (align to 1536, startup validation) +- [ ] F-343: Unified InMemoryStore[K,V] base (TTL, max_size, LRU) +- [ ] F-344: Expose session_factory on PgVectorStore +- [ ] F-345: Document/refactor 14 global mutable state instances + +--- + +## Sprint 32A — Privacy Compliance (LGPD/GDPR) — PLANNED + +10 stories (F-346 → F-355). 2 weeks. + +- [ ] F-346: Data tier classification + ORM tagging (CI enforcement) +- [ ] F-347: Data portability export API (LGPD Art. 18, ZIP archive) +- [ ] F-348: Deletion & anonymization pipeline (soft→hard delete, legal hold) +- [ ] F-349: Minor consent management (LGPD Art. 14, guardian verification) +- [ ] F-350: Retention policy engine (automated cleanup, execution logs) +- [ ] F-351: Incident response playbook (ANPD 3d / GDPR 72h templates) +- [ ] F-352: Provider DPA register + runtime guards +- [ ] F-353: Redis maxmemory eviction safety (separate logical DBs) +- [ ] F-354: Postgres RLS foundation (tenant_id policies) +- [ ] F-355: Notification service foundation (email, in-app SSE, Web Push) + +--- + +## Sprint 33+ — Advanced Features — PLANNED + +20 stories (F-356 → F-375). 4-6 weeks. + +### Gamification & Engagement +- [ ] F-356: "Learning Rhythm" backend (forgiving momentum, streak freeze) +- [ ] F-357: "Skill Nodes" achievement system (geometric badges, non-academic wins) +- [ ] F-358: "Constellation" curriculum map (frontend+backend, SVG night-sky) + +### Educational Intelligence +- [ ] F-359: Spaced repetition scheduler (SM-2 adapted for inclusive learning) +- [ ] F-360: IEP goal tracking schema (goals, accommodations, progress) +- [ ] F-361: AI Social Story Generator (ASD, scenario→5-step illustrated story) +- [ ] F-362: Predictive accommodation suggestions (usage patterns → teacher approval) +- [ ] F-363: Cognitive simplification engine (reading level slider, Flesch-Kincaid) + +### Platform Evolution +- [ ] F-364: Feature flags system (Redis, per-tenant) +- [ ] F-365: CQRS read models (materialized views, 10x dashboard) +- [ ] F-366: Agent A/B testing framework +- [ ] F-367: Personalization engine (mastery tracking, gap analysis, recommendations) + +### Infrastructure & Quality +- [ ] F-368: Search engine (Meilisearch for materials/plans) +- [ ] F-369: API SDK generation (OpenAPI → TypeScript client) +- [ ] F-370: Admin dashboard API (monitoring, user management) +- [ ] F-371: Batch operations API (bulk import, bulk generation) +- [ ] F-372: Webhook system (LMS: Canvas/Moodle, HMAC-SHA256) +- [ ] F-373: Git LFS for binary media (~15MB) +- [ ] F-374: Clean orphaned root files +- [ ] F-375: Real assistive technology testing (axe-core + NVDA/VoiceOver CI) + +--- + +## Sprint 34 — Emotional Safety & Executive Function — PLANNED + +18 stories (F-376 → F-393). 3 weeks. Empowerment Layer Pillar 1. +Framework: SAMHSA Trauma-Informed Care. Architecture: Gemini-3.1-Pro (3 consultations). +Sources: 3-round Gemini debate (emotional-safety-architect), purpose analysis (10 gaps). + +### Phase 1 — Emotional Safety Foundation (P0) +- [ ] F-376: Transition Warnings (ASD, 2-min, aria-live="assertive", VisualScheduleBar) +- [ ] F-377: Task Decomposition "Stepping Stones" (SVG path, 5-7 micro-tasks, aria-current) +- [ ] F-378: Safe Feedback — no red for learning (amber --color-feedback-safe, growth language) +- [ ] F-379: Global Undo + Auto-Save (Ctrl+Z, VersionHistoryPanel, session_drafts) + +### Phase 2 — Executive Function Support (P0) +- [ ] F-380: Working Memory "Where was I?" (ContextPanel, context-snapshot API) +- [ ] F-381: Initiation "Just Start" (JustStartButton, SentenceStarters, WatchMeFirst) +- [ ] F-382: Emotional Check-In (energy/pleasantness grid, mood widget, not face emojis) +- [ ] F-383: Frustration Detection + De-Escalation (rage clicks, idle, accuracy drops) +- [ ] F-384: Breathing Exercise (BoxBreathingVisualizer, 4-4-4-4 cycle) +- [ ] F-385: Parking Lot (distracting thoughts sidebar, persisted) + +### Phase 3 — Safe Failure & Growth Mindset (P1) +- [ ] F-386: Productive Struggle Indicator (3+ fails → normalize, never auto-reduce) +- [ ] F-387: Dignity-Preserving Celebrations (per-persona: TEA=text-only, ADHD=short) +- [ ] F-388: Self-Assessment (confidence emoji scale, end-session reflection) + +### Phase 4 — Reports & Portfolio (P2) +- [ ] F-389: Asset-Based Parent Reports (no deficit language, LLM constraints) +- [ ] F-390: Growth Portfolio (early vs recent work comparison) +- [ ] F-391: useEmotionalSafetyStore full integration (6+ stores consolidated) +- [ ] F-392: Executive Function i18n (40+ keys, EN/PT-BR/ES) +- [ ] F-393: Emotional Safety test suite (≥85% coverage) + +--- + +## Sprint 35 — AI Educational Intelligence Tools — PLANNED + +18 stories (F-394 → F-411). 3 weeks. Empowerment Layer Pillar 2. +Frameworks: Carol Gray Social Stories, Flesch-Kincaid, Vygotsky ZPD. Architecture: Gemini-3.1-Pro (3 consultations). + +### Phase 1 — Social Story Generator (P0) +- [ ] F-394: SocialStoryAgent + deterministic 4:1 ratio QualityGate (Carol Gray) +- [ ] F-395: Scenario Template Library (10 templates, DB-backed, locale-aware) +- [ ] F-396: Social Story multi-format export (text, audio, visual, PDF, cards) +- [ ] F-397: Social Story Student Viewer (swipeable cards, TTS per card) +- [ ] F-398: social_story_workflow LangGraph (skills→generate→validate→export→scorecard) +- [ ] F-399: social-story-generator skill (agentskills.io spec, AccessibilityPolicy update) + +### Phase 2 — Cognitive Simplification Engine (P0-P1) +- [ ] F-400: Readability Analysis Module (Flesch-Kincaid, Gunning Fog, deterministic) +- [ ] F-401: Multi-Level SimplificationAgent (4 levels, jargon tooltips, objectives preserved) +- [ ] F-402: Complexity Slider UI (1-4 levels, real-time, DiffHighlighter) +- [ ] F-403: cognitive-simplifier skill + plan_workflow/tutor_workflow integration + +### Phase 3 — Predictive Accommodations + ZPD (P1) +- [ ] F-404: Telemetry Ingestion Pipeline (7 signal types, partitioned, Redis buffer) +- [ ] F-405: BehavioralAnalyzer Service (rolling windows, normalized 0-1 signals) +- [ ] F-406: Accommodation Suggestion Engine (heuristic rules, dignity framing, NEVER auto-activate) +- [ ] F-407: ZPD Adaptive Difficulty (+1 after 3 correct, -1 after 2 wrong, frustration ceiling) +- [ ] F-408: Teacher Insights Dashboard (approve/reject suggestions, ZPD history graph) +- [ ] F-409: Student Accommodation Prompt (non-intrusive, dismissible, dignity-framed) +- [ ] F-410: accommodation-predictor skill (Vygotsky ZPD, FERPA/LGPD compliant) +- [ ] F-411: Privacy & Compliance (30d telemetry retention, consent, erasure endpoint) + +--- + +## Sprint 36 — IEP/MTSS, SM-2 Spaced Repetition, Mastery & Personalization — PLANNED + +36 stories (F-412 → F-447). 4-6 weeks. Empowerment Layer Pillar 3. +Architecture: GPT-5.3-codex (2 consultations) + GPT-5.2 thinkdeep. Compliance: IDEA, FERPA, LGPD Art. 14. + +### Phase 1 — IEP Document Management (P0) +- [ ] F-412: IEP Document CRUD + status lifecycle (7 states, audit trail) +- [ ] F-413: IEP SMART Goals (linked to curriculum standards, progress %) +- [ ] F-414: IEP Accommodations (6 categories, active/inactive, dates) +- [ ] F-415: IEP Progress Monitoring (datapoints, evidence types, timeline) +- [ ] F-416: IEP Services Tracking (8 service types, frequency, provider) +- [ ] F-417: IEP Team & Notifications (roles, channels, read tracking) +- [ ] F-418: IEP domain entities + PostgresIepRepository + +### Phase 2 — MTSS Tier Management (P0) +- [ ] F-419: MTSS Tier Placement (Tier 1/2/3, subject, dates, movement) +- [ ] F-420: MTSS Intervention Catalog (evidence-based, per-org, 4 evidence levels) +- [ ] F-421: MTSS Student Interventions + fidelity tracking +- [ ] F-422: MTSS Screening + Meetings (tools, decisions, next review) +- [ ] F-423: MTSS domain entities + PostgresMtssRepository + +### Phase 3 — Compliance & Audit (P0) +- [ ] F-424: Consent Management (6 types, IDEA + LGPD Art. 14, withdrawal) +- [ ] F-425: Immutable Audit Ledger (append-only, SHA-256 state hashes) +- [ ] F-426: Data Retention Policies (IEP 7yr, MTSS 5yr, reviews 3yr, legal hold) + +### Phase 4 — SM-2 Spaced Repetition (P1) +- [ ] F-427: Review Items CRUD (7 modalities, difficulty, curriculum-linked) +- [ ] F-428: Student Review Profiles (processing speed matrix, fatigue sensitivity) +- [ ] F-429: SM-2 Core Algorithm (EF update + processing speed factor + calibration) +- [ ] F-430: Processing Speed Matrix (7 needs × 7 modalities = 49 multipliers) +- [ ] F-431: Review Outcome Recording (quality 0-5, growth feedback, EF snapshots) +- [ ] F-432: Fatigue Detection (3 rules: declining quality, low average, overtime) +- [ ] F-433: Due Reviews + Review Sessions (priority ordering, session stats) +- [ ] F-434: Review Health Dashboard (aggregated stats, calibration status) +- [ ] F-435: Tutor Review Integration (review_check_node, SSE events) + +### Phase 5 — Mastery + Personalization (P1-P2) +- [ ] F-436: Mastery Map (5 levels, ZPD bounds, prerequisite graph) +- [ ] F-437: Mastery Evidence Collection (5 sources, normalized 0-1) +- [ ] F-438: Skill Gap Analysis (prerequisite traversal, ZPD-aware) +- [ ] F-439: Learning Profile Construction (auto-detected preferences, effectiveness) +- [ ] F-440: AI Recommendations Engine (5 types, heuristic-first, teacher approval) +- [ ] F-441: Recommendation Acceptance Flow (accept/dismiss, feedback loop) +- [ ] F-442: ZPD-Mastery Integration (bounds populate, difficulty matching) +- [ ] F-443: Parent IEP Progress Report (unified view, asset-based language) +- [ ] F-444: Teacher IEP Dashboard (caseload overview, Recharts visualizations) +- [ ] F-445: Review Auto-Generation from Mastery (proficient → review item) +- [ ] F-446: Data Retention Enforcement (arq job, anonymization, legal holds) +- [ ] F-447: Privacy Dashboard (consent coverage, retention compliance, audit) --- -## Post-Hackathon (unscheduled) -- [ ] Postgres RLS, Redis rate limiter, disable OpenAPI in prod, SW multi-locale caching -- [ ] Auth: migrate in-memory store to PostgreSQL-backed user persistence -- [ ] Teacher Skill Sets — per-teacher presets (F-178) -- [ ] Real assistive technology testing (axe-core E2E with NVDA/VoiceOver) +## Backlog (unscheduled) +- [ ] F-035: Sign Language Post-MVP — SPOTER transformer + VLibrasBD NMT +- [ ] F-178: Teacher Skill Sets — per-teacher presets - [ ] Braille Grade 2 via liblouis +- [ ] SW multi-locale caching for PWA +- [ ] Symbol-Supported Text (PCS pictograms for intellectual disabilities) +- [ ] Synchronized word-level TTS highlighting (Kurzweil-style) +- [ ] Haptic/Vibrational feedback (Web Haptics API) diff --git a/docker-compose.yml b/docker-compose.yml index ede11be..c64f916 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,7 +5,7 @@ name: ailine services: db: - image: pgvector/pgvector:0.8.0-pg16 + image: pgvector/pgvector:0.8.2-pg16 environment: POSTGRES_USER: ${POSTGRES_USER:-ailine} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-ailine_dev} @@ -14,7 +14,7 @@ services: - pgdata:/var/lib/postgresql/data - ./infra/db/init-pgvector.sql:/docker-entrypoint-initdb.d/01-pgvector.sql:ro ports: - - "${DB_HOST_PORT:-5411}:5432" + - "127.0.0.1:${DB_HOST_PORT:-5411}:5432" healthcheck: test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] interval: 5s @@ -39,7 +39,7 @@ services: REDIS_PASSWORD: ${REDIS_PASSWORD:-ailine_redis_dev} command: redis-server --maxmemory 128mb --maxmemory-policy allkeys-lru --requirepass ${REDIS_PASSWORD:-ailine_redis_dev} ports: - - "${REDIS_HOST_PORT:-6311}:6379" + - "127.0.0.1:${REDIS_HOST_PORT:-6311}:6379" healthcheck: test: ["CMD-SHELL", "REDISCLI_AUTH=$$REDIS_PASSWORD redis-cli ping"] interval: 5s @@ -72,6 +72,8 @@ services: AILINE_LLM_PROVIDER: ${AILINE_LLM_PROVIDER:-anthropic} AILINE_CORS_ORIGINS: "http://localhost:3011,http://127.0.0.1:3011,http://localhost:3000,http://127.0.0.1:3000,http://frontend:3000" AILINE_DEV_MODE: "${AILINE_DEV_MODE:-false}" + AILINE_DEMO_MODE: "${AILINE_DEMO_MODE:-1}" + AILINE_JWT_SECRET: "${AILINE_JWT_SECRET:-}" AILINE_LOCAL_STORE: /app/.local_store env_file: - path: .env @@ -111,11 +113,12 @@ services: environment: API_INTERNAL_URL: http://api:8000 HOSTNAME: "0.0.0.0" + NODE_OPTIONS: "--max-old-space-size=1536" depends_on: api: condition: service_healthy healthcheck: - test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:3000"] + test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:3000 || curl -sf http://127.0.0.1:3000 > /dev/null"] interval: 10s timeout: 5s retries: 3 @@ -131,7 +134,7 @@ services: deploy: resources: limits: - memory: 512M + memory: 2G volumes: pgdata: diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index c9683db..cbecbb6 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -3,9 +3,11 @@ ## Stack - Next.js 16.1.6 (Turbopack, React Compiler 1.0) - React 19.2.4 with auto-memoization -- Tailwind CSS 4.1.18 (CSS-first, @theme directive) -- next-intl 4.8.2 for i18n (EN, PT-BR, ES) -- motion 12.34.0 for animations (import from "motion/react") +- Tailwind CSS 4.2.1 (CSS-first, @theme directive) +- next-intl 4.8.3 for i18n (EN, PT-BR, ES) +- motion 12.34.3 for animations (import from "motion/react") +- TypeScript 5.9.3 +- tailwind-merge 3.5.0 - Zustand 5.0.11 for state management - Recharts 3.7.0 for charts - DOMPurify 3.3.1 for XSS sanitization @@ -37,7 +39,7 @@ pnpm dev # Dev server (http://localhost:3000) ## Key Conventions - Theme switching: `document.body.setAttribute('data-theme', id)` -- NO React re-render - `cookies()`, `headers()`, `params`, `searchParams` MUST be awaited -- SSE: `compress: false` in next.config.ts +- SSE: `compress: false` in next.config.ts; custom `src/lib/sse-fetch.ts` wrapper (no external dep) - Import motion from "motion/react" (NOT framer-motion) - React Compiler: `reactCompiler: true` + babel-plugin-react-compiler - WCAG AAA: all interactive elements get aria-labels, proper focus management diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index 7f94132..8e1640d 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -1,9 +1,10 @@ +import { defineConfig, globalIgnores } from '@eslint/config-helpers' import nextConfig from 'eslint-config-next' import nextCoreWebVitals from 'eslint-config-next/core-web-vitals' import nextTypescript from 'eslint-config-next/typescript' -const config = [ - { ignores: ['coverage/**'] }, +export default defineConfig([ + globalIgnores(['coverage/**', '.next/**', 'out/**']), ...nextConfig, ...nextCoreWebVitals, ...nextTypescript, @@ -20,6 +21,4 @@ const config = [ ], }, }, -] - -export default config +]) diff --git a/frontend/package.json b/frontend/package.json index 875d1b6..c380e38 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -2,7 +2,7 @@ "name": "ailine-frontend", "version": "0.1.0", "private": true, - "packageManager": "pnpm@10.7.1", + "packageManager": "pnpm@10.30.3", "scripts": { "dev": "next dev", "build": "next build", @@ -15,25 +15,24 @@ "e2e:ui": "playwright test --ui" }, "dependencies": { - "@microsoft/fetch-event-source": "2.0.1", "canvas-confetti": "^1.9.4", "clsx": "2.1.1", "dompurify": "3.3.1", "mermaid": "^11.12.2", - "motion": "12.34.0", + "motion": "12.34.3", "next": "16.1.6", - "next-intl": "4.8.2", + "next-intl": "4.8.3", "react": "19.2.4", "react-dom": "19.2.4", "recharts": "3.7.0", - "tailwind-merge": "3.0.2", + "tailwind-merge": "3.5.0", "zustand": "5.0.11" }, "devDependencies": { "@axe-core/playwright": "^4.11.1", - "@eslint/eslintrc": "^3.3.3", + "@eslint/config-helpers": "^0.2.0", "@playwright/test": "^1.58.2", - "@tailwindcss/postcss": "4.1.18", + "@tailwindcss/postcss": "4.2.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", @@ -47,8 +46,8 @@ "eslint-config-next": "16.1.6", "jsdom": "^28.0.0", "postcss": "^8.5.0", - "tailwindcss": "4.1.18", - "typescript": "^5.8.0", + "tailwindcss": "4.2.1", + "typescript": "^5.9.0", "vitest": "^4.0.18" } } diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index fc48017..3af4884 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -8,9 +8,6 @@ importers: .: dependencies: - '@microsoft/fetch-event-source': - specifier: 2.0.1 - version: 2.0.1 canvas-confetti: specifier: ^1.9.4 version: 1.9.4 @@ -24,14 +21,14 @@ importers: specifier: ^11.12.2 version: 11.12.2 motion: - specifier: 12.34.0 - version: 12.34.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: 12.34.3 + version: 12.34.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next: specifier: 16.1.6 version: 16.1.6(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next-intl: - specifier: 4.8.2 - version: 4.8.2(next@16.1.6(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(typescript@5.9.3) + specifier: 4.8.3 + version: 4.8.3(next@16.1.6(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(typescript@5.9.3) react: specifier: 19.2.4 version: 19.2.4 @@ -42,8 +39,8 @@ importers: specifier: 3.7.0 version: 3.7.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@17.0.2)(react@19.2.4)(redux@5.0.1) tailwind-merge: - specifier: 3.0.2 - version: 3.0.2 + specifier: 3.5.0 + version: 3.5.0 zustand: specifier: 5.0.11 version: 5.0.11(@types/react@19.2.14)(immer@11.1.4)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) @@ -51,15 +48,15 @@ importers: '@axe-core/playwright': specifier: ^4.11.1 version: 4.11.1(playwright-core@1.58.2) - '@eslint/eslintrc': - specifier: ^3.3.3 - version: 3.3.3 + '@eslint/config-helpers': + specifier: ^0.2.0 + version: 0.2.3 '@playwright/test': specifier: ^1.58.2 version: 1.58.2 '@tailwindcss/postcss': - specifier: 4.1.18 - version: 4.1.18 + specifier: 4.2.1 + version: 4.2.1 '@testing-library/jest-dom': specifier: ^6.9.1 version: 6.9.1 @@ -83,7 +80,7 @@ importers: version: 19.2.3(@types/react@19.2.14) '@vitest/coverage-v8': specifier: ^4.0.18 - version: 4.0.18(vitest@4.0.18(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.0.0)(lightningcss@1.30.2)) + version: 4.0.18(vitest@4.0.18(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.0.0)(lightningcss@1.31.1)) babel-plugin-react-compiler: specifier: ^1.0.0 version: 1.0.0 @@ -100,14 +97,14 @@ importers: specifier: ^8.5.0 version: 8.5.6 tailwindcss: - specifier: 4.1.18 - version: 4.1.18 + specifier: 4.2.1 + version: 4.2.1 typescript: - specifier: ^5.8.0 + specifier: ^5.9.0 version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.0.18(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.0.0)(lightningcss@1.30.2) + version: 4.0.18(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.0.0)(lightningcss@1.31.1) packages: @@ -441,6 +438,10 @@ packages: resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/config-helpers@0.2.3': + resolution: {integrity: sha512-u180qk2Um1le4yf0ruXH3PYFeEZeYC3p/4wCTKrr2U1CmGdzGi3KtY0nuPDH48UJxlKCC5RDzbcbh4X0XlqgHg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/config-helpers@0.4.2': resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -486,9 +487,6 @@ packages: '@formatjs/icu-skeleton-parser@2.1.1': resolution: {integrity: sha512-PSFABlcNefjI6yyk8f7nyX1DC7NHmq6WaCHZLySEXBrXuLOB2f935YsnzuPjlz+ibhb9yWTdPeVX1OVcj24w2Q==} - '@formatjs/intl-localematcher@0.5.10': - resolution: {integrity: sha512-af3qATX+m4Rnd9+wHcjJ4w2ijq+rAVP3CCinJQvFv1kgSu1W6jypUmvleJxcewdxmutM8dmIRZFxO/IQBZmP2Q==} - '@formatjs/intl-localematcher@0.8.1': resolution: {integrity: sha512-xwEuwQFdtSq1UKtQnyTZWC+eHdv7Uygoa+H2k/9uzBVQjDyp9r20LNDNKedWXll7FssT3GRHvqsdJGYSUWqYFA==} @@ -544,89 +542,105 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -670,9 +684,6 @@ packages: '@mermaid-js/parser@0.6.3': resolution: {integrity: sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==} - '@microsoft/fetch-event-source@2.0.1': - resolution: {integrity: sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==} - '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} @@ -699,24 +710,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@16.1.6': resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@16.1.6': resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@16.1.6': resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@16.1.6': resolution: {integrity: sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==} @@ -775,36 +790,42 @@ packages: engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + libc: [glibc] '@parcel/watcher-linux-arm-musl@2.5.6': resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + libc: [musl] '@parcel/watcher-linux-arm64-glibc@2.5.6': resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] '@parcel/watcher-linux-arm64-musl@2.5.6': resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [musl] '@parcel/watcher-linux-x64-glibc@2.5.6': resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [glibc] '@parcel/watcher-linux-x64-musl@2.5.6': resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [musl] '@parcel/watcher-win32-arm64@2.5.6': resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==} @@ -878,66 +899,79 @@ packages: resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.57.1': resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.57.1': resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.57.1': resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.57.1': resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.57.1': resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.57.1': resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.57.1': resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.57.1': resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.57.1': resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.57.1': resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.57.1': resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.57.1': resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.57.1': resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==} @@ -1004,24 +1038,28 @@ packages: engines: {node: '>=10'} cpu: [arm64] os: [linux] + libc: [glibc] '@swc/core-linux-arm64-musl@1.15.11': resolution: {integrity: sha512-PYftgsTaGnfDK4m6/dty9ryK1FbLk+LosDJ/RJR2nkXGc8rd+WenXIlvHjWULiBVnS1RsjHHOXmTS4nDhe0v0w==} engines: {node: '>=10'} cpu: [arm64] os: [linux] + libc: [musl] '@swc/core-linux-x64-gnu@1.15.11': resolution: {integrity: sha512-DKtnJKIHiZdARyTKiX7zdRjiDS1KihkQWatQiCHMv+zc2sfwb4Glrodx2VLOX4rsa92NLR0Sw8WLcPEMFY1szQ==} engines: {node: '>=10'} cpu: [x64] os: [linux] + libc: [glibc] '@swc/core-linux-x64-musl@1.15.11': resolution: {integrity: sha512-mUjjntHj4+8WBaiDe5UwRNHuEzLjIWBTSGTw0JT9+C9/Yyuh4KQqlcEQ3ro6GkHmBGXBFpGIj/o5VMyRWfVfWw==} engines: {node: '>=10'} cpu: [x64] os: [linux] + libc: [musl] '@swc/core-win32-arm64-msvc@1.15.11': resolution: {integrity: sha512-ZkNNG5zL49YpaFzfl6fskNOSxtcZ5uOYmWBkY4wVAvgbSAQzLRVBp+xArGWh2oXlY/WgL99zQSGTv7RI5E6nzA==} @@ -1059,65 +1097,69 @@ packages: '@swc/types@0.1.25': resolution: {integrity: sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==} - '@tailwindcss/node@4.1.18': - resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==} + '@tailwindcss/node@4.2.1': + resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==} - '@tailwindcss/oxide-android-arm64@4.1.18': - resolution: {integrity: sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-android-arm64@4.2.1': + resolution: {integrity: sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==} + engines: {node: '>= 20'} cpu: [arm64] os: [android] - '@tailwindcss/oxide-darwin-arm64@4.1.18': - resolution: {integrity: sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-darwin-arm64@4.2.1': + resolution: {integrity: sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==} + engines: {node: '>= 20'} cpu: [arm64] os: [darwin] - '@tailwindcss/oxide-darwin-x64@4.1.18': - resolution: {integrity: sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-darwin-x64@4.2.1': + resolution: {integrity: sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==} + engines: {node: '>= 20'} cpu: [x64] os: [darwin] - '@tailwindcss/oxide-freebsd-x64@4.1.18': - resolution: {integrity: sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-freebsd-x64@4.2.1': + resolution: {integrity: sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==} + engines: {node: '>= 20'} cpu: [x64] os: [freebsd] - '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18': - resolution: {integrity: sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1': + resolution: {integrity: sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==} + engines: {node: '>= 20'} cpu: [arm] os: [linux] - '@tailwindcss/oxide-linux-arm64-gnu@4.1.18': - resolution: {integrity: sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-linux-arm64-gnu@4.2.1': + resolution: {integrity: sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==} + engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [glibc] - '@tailwindcss/oxide-linux-arm64-musl@4.1.18': - resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-linux-arm64-musl@4.2.1': + resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==} + engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [musl] - '@tailwindcss/oxide-linux-x64-gnu@4.1.18': - resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-linux-x64-gnu@4.2.1': + resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==} + engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [glibc] - '@tailwindcss/oxide-linux-x64-musl@4.1.18': - resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-linux-x64-musl@4.2.1': + resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==} + engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [musl] - '@tailwindcss/oxide-wasm32-wasi@4.1.18': - resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} + '@tailwindcss/oxide-wasm32-wasi@4.2.1': + resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==} engines: {node: '>=14.0.0'} cpu: [wasm32] bundledDependencies: @@ -1128,24 +1170,24 @@ packages: - '@emnapi/wasi-threads' - tslib - '@tailwindcss/oxide-win32-arm64-msvc@4.1.18': - resolution: {integrity: sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-win32-arm64-msvc@4.2.1': + resolution: {integrity: sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==} + engines: {node: '>= 20'} cpu: [arm64] os: [win32] - '@tailwindcss/oxide-win32-x64-msvc@4.1.18': - resolution: {integrity: sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-win32-x64-msvc@4.2.1': + resolution: {integrity: sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==} + engines: {node: '>= 20'} cpu: [x64] os: [win32] - '@tailwindcss/oxide@4.1.18': - resolution: {integrity: sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==} - engines: {node: '>= 10'} + '@tailwindcss/oxide@4.2.1': + resolution: {integrity: sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==} + engines: {node: '>= 20'} - '@tailwindcss/postcss@4.1.18': - resolution: {integrity: sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==} + '@tailwindcss/postcss@4.2.1': + resolution: {integrity: sha512-OEwGIBnXnj7zJeonOh6ZG9woofIjGrd2BORfvE5p9USYKDCZoQmfqLcfNiRWoJlRWLdNPn2IgVZuWAOM4iTYMw==} '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} @@ -1411,41 +1453,49 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -2202,8 +2252,8 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} - framer-motion@12.34.0: - resolution: {integrity: sha512-+/H49owhzkzQyxtn7nZeF4kdH++I2FWrESQ184Zbcw5cEqNHYkE5yxWxcTLSj5lNx3NWdbIRy5FHqUvetD8FWg==} + framer-motion@12.34.3: + resolution: {integrity: sha512-v81ecyZKYO/DfpTwHivqkxSUBzvceOpoI+wLfgCgoUIKxlFKEXdg0oR9imxwXumT4SFy8vRk9xzJ5l3/Du/55Q==} peerDependencies: '@emotion/is-prop-valid': '*' react: ^18.0.0 || ^19.0.0 @@ -2341,8 +2391,8 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} - icu-minify@4.8.2: - resolution: {integrity: sha512-LHBQV+skKkjZSPd590pZ7ZAHftUgda3eFjeuNwA8/15L8T8loCNBktKQyTlkodAU86KovFXeg/9WntlAo5wA5A==} + icu-minify@4.8.3: + resolution: {integrity: sha512-65Av7FLosNk7bPbmQx5z5XG2Y3T2GFppcjiXh4z1idHeVgQxlDpAmkGoYI0eFzAvrOnjpWTL5FmPDhsdfRMPEA==} ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} @@ -2593,74 +2643,78 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} - lightningcss-android-arm64@1.30.2: - resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} + lightningcss-android-arm64@1.31.1: + resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [android] - lightningcss-darwin-arm64@1.30.2: - resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==} + lightningcss-darwin-arm64@1.31.1: + resolution: {integrity: sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] - lightningcss-darwin-x64@1.30.2: - resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==} + lightningcss-darwin-x64@1.31.1: + resolution: {integrity: sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] - lightningcss-freebsd-x64@1.30.2: - resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==} + lightningcss-freebsd-x64@1.31.1: + resolution: {integrity: sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] - lightningcss-linux-arm-gnueabihf@1.30.2: - resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==} + lightningcss-linux-arm-gnueabihf@1.31.1: + resolution: {integrity: sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] - lightningcss-linux-arm64-gnu@1.30.2: - resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==} + lightningcss-linux-arm64-gnu@1.31.1: + resolution: {integrity: sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] - lightningcss-linux-arm64-musl@1.30.2: - resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} + lightningcss-linux-arm64-musl@1.31.1: + resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] - lightningcss-linux-x64-gnu@1.30.2: - resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} + lightningcss-linux-x64-gnu@1.31.1: + resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] - lightningcss-linux-x64-musl@1.30.2: - resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} + lightningcss-linux-x64-musl@1.31.1: + resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] - lightningcss-win32-arm64-msvc@1.30.2: - resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} + lightningcss-win32-arm64-msvc@1.31.1: + resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [win32] - lightningcss-win32-x64-msvc@1.30.2: - resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==} + lightningcss-win32-x64-msvc@1.31.1: + resolution: {integrity: sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] - lightningcss@1.30.2: - resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} + lightningcss@1.31.1: + resolution: {integrity: sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==} engines: {node: '>= 12.0.0'} locate-path@6.0.0: @@ -2741,14 +2795,14 @@ packages: mlly@1.8.0: resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} - motion-dom@12.34.0: - resolution: {integrity: sha512-Lql3NuEcScRDxTAO6GgUsRHBZOWI/3fnMlkMcH5NftzcN37zJta+bpbMAV9px4Nj057TuvRooMK7QrzMCgtz6Q==} + motion-dom@12.34.3: + resolution: {integrity: sha512-sYgFe+pR9aIM7o4fhs2aXtOI+oqlUd33N9Yoxcgo1Fv7M20sRkHtCmzE/VRNIcq7uNJ+qio+Xubt1FXH3pQ+eQ==} motion-utils@12.29.2: resolution: {integrity: sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==} - motion@12.34.0: - resolution: {integrity: sha512-01Sfa/zgsD/di8zA/uFW5Eb7/SPXoGyUfy+uMRMW5Spa8j0z/UbfQewAYvPMYFCXRlyD6e5aLHh76TxeeJD+RA==} + motion@12.34.3: + resolution: {integrity: sha512-xZIkBGO7v/Uvm+EyaqYd+9IpXu0sZqLywVlGdCFrrMiaO9JI4Kx51mO9KlHSWwll+gZUVY5OJsWgYI5FywJ/tw==} peerDependencies: '@emotion/is-prop-valid': '*' react: ^18.0.0 || ^19.0.0 @@ -2781,11 +2835,11 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} - next-intl-swc-plugin-extractor@4.8.2: - resolution: {integrity: sha512-sHDs36L1VZmFHj3tPHsD+KZJtnsRudHlNvT0ieIe3iFVn5OpGLTxW3d/Zc/2LXSj5GpGuR6wQeikbhFjU9tMQQ==} + next-intl-swc-plugin-extractor@4.8.3: + resolution: {integrity: sha512-YcaT+R9z69XkGhpDarVFWUprrCMbxgIQYPUaXoE6LGVnLjGdo8hu3gL6bramDVjNKViYY8a/pXPy7Bna0mXORg==} - next-intl@4.8.2: - resolution: {integrity: sha512-GuuwyvyEI49/oehQbBXEoY8KSIYCzmfMLhmIwhMXTb+yeBmly1PnJcpgph3KczQ+HTJMXwXCmkizgtT8jBMf3A==} + next-intl@4.8.3: + resolution: {integrity: sha512-PvdBDWg+Leh7BR7GJUQbCDVVaBRn37GwDBWc9sv0rVQOJDQ5JU1rVzx9EEGuOGYo0DHAl70++9LQ7HxTawdL7w==} peerDependencies: next: ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0 @@ -3214,11 +3268,11 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} - tailwind-merge@3.0.2: - resolution: {integrity: sha512-l7z+OYZ7mu3DTqrL88RiKrKIqO3NcpEO8V/Od04bNpvk0kiIFndGEoqfuzvj4yuhRkHKjRkII2z+KS2HfPcSxw==} + tailwind-merge@3.5.0: + resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} - tailwindcss@4.1.18: - resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==} + tailwindcss@4.2.1: + resolution: {integrity: sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==} tapable@2.3.0: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} @@ -3335,8 +3389,8 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - use-intl@4.8.2: - resolution: {integrity: sha512-3VNXZgDnPFqhIYosQ9W1Hc6K5q+ZelMfawNbexdwL/dY7BTHbceLUBX5Eeex9lgogxTp0pf1SjHuhYNAjr9H3g==} + use-intl@4.8.3: + resolution: {integrity: sha512-nLxlC/RH+le6g3amA508Itnn/00mE+J22ui21QhOWo5V9hCEC43+WtnRAITbJW0ztVZphev5X9gvOf2/Dk9PLA==} peerDependencies: react: ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0 @@ -3823,6 +3877,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@eslint/config-helpers@0.2.3': {} + '@eslint/config-helpers@0.4.2': dependencies: '@eslint/core': 0.17.0 @@ -3878,10 +3934,6 @@ snapshots: '@formatjs/ecma402-abstract': 3.1.1 tslib: 2.8.1 - '@formatjs/intl-localematcher@0.5.10': - dependencies: - tslib: 2.8.1 - '@formatjs/intl-localematcher@0.8.1': dependencies: '@formatjs/fast-memoize': 3.1.0 @@ -4026,8 +4078,6 @@ snapshots: dependencies: langium: 3.3.1 - '@microsoft/fetch-event-source@2.0.1': {} - '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.8.1 @@ -4294,74 +4344,74 @@ snapshots: dependencies: '@swc/counter': 0.1.3 - '@tailwindcss/node@4.1.18': + '@tailwindcss/node@4.2.1': dependencies: '@jridgewell/remapping': 2.3.5 enhanced-resolve: 5.19.0 jiti: 2.6.1 - lightningcss: 1.30.2 + lightningcss: 1.31.1 magic-string: 0.30.21 source-map-js: 1.2.1 - tailwindcss: 4.1.18 + tailwindcss: 4.2.1 - '@tailwindcss/oxide-android-arm64@4.1.18': + '@tailwindcss/oxide-android-arm64@4.2.1': optional: true - '@tailwindcss/oxide-darwin-arm64@4.1.18': + '@tailwindcss/oxide-darwin-arm64@4.2.1': optional: true - '@tailwindcss/oxide-darwin-x64@4.1.18': + '@tailwindcss/oxide-darwin-x64@4.2.1': optional: true - '@tailwindcss/oxide-freebsd-x64@4.1.18': + '@tailwindcss/oxide-freebsd-x64@4.2.1': optional: true - '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18': + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1': optional: true - '@tailwindcss/oxide-linux-arm64-gnu@4.1.18': + '@tailwindcss/oxide-linux-arm64-gnu@4.2.1': optional: true - '@tailwindcss/oxide-linux-arm64-musl@4.1.18': + '@tailwindcss/oxide-linux-arm64-musl@4.2.1': optional: true - '@tailwindcss/oxide-linux-x64-gnu@4.1.18': + '@tailwindcss/oxide-linux-x64-gnu@4.2.1': optional: true - '@tailwindcss/oxide-linux-x64-musl@4.1.18': + '@tailwindcss/oxide-linux-x64-musl@4.2.1': optional: true - '@tailwindcss/oxide-wasm32-wasi@4.1.18': + '@tailwindcss/oxide-wasm32-wasi@4.2.1': optional: true - '@tailwindcss/oxide-win32-arm64-msvc@4.1.18': + '@tailwindcss/oxide-win32-arm64-msvc@4.2.1': optional: true - '@tailwindcss/oxide-win32-x64-msvc@4.1.18': + '@tailwindcss/oxide-win32-x64-msvc@4.2.1': optional: true - '@tailwindcss/oxide@4.1.18': + '@tailwindcss/oxide@4.2.1': optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.1.18 - '@tailwindcss/oxide-darwin-arm64': 4.1.18 - '@tailwindcss/oxide-darwin-x64': 4.1.18 - '@tailwindcss/oxide-freebsd-x64': 4.1.18 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.18 - '@tailwindcss/oxide-linux-arm64-gnu': 4.1.18 - '@tailwindcss/oxide-linux-arm64-musl': 4.1.18 - '@tailwindcss/oxide-linux-x64-gnu': 4.1.18 - '@tailwindcss/oxide-linux-x64-musl': 4.1.18 - '@tailwindcss/oxide-wasm32-wasi': 4.1.18 - '@tailwindcss/oxide-win32-arm64-msvc': 4.1.18 - '@tailwindcss/oxide-win32-x64-msvc': 4.1.18 - - '@tailwindcss/postcss@4.1.18': + '@tailwindcss/oxide-android-arm64': 4.2.1 + '@tailwindcss/oxide-darwin-arm64': 4.2.1 + '@tailwindcss/oxide-darwin-x64': 4.2.1 + '@tailwindcss/oxide-freebsd-x64': 4.2.1 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.1 + '@tailwindcss/oxide-linux-arm64-gnu': 4.2.1 + '@tailwindcss/oxide-linux-arm64-musl': 4.2.1 + '@tailwindcss/oxide-linux-x64-gnu': 4.2.1 + '@tailwindcss/oxide-linux-x64-musl': 4.2.1 + '@tailwindcss/oxide-wasm32-wasi': 4.2.1 + '@tailwindcss/oxide-win32-arm64-msvc': 4.2.1 + '@tailwindcss/oxide-win32-x64-msvc': 4.2.1 + + '@tailwindcss/postcss@4.2.1': dependencies: '@alloc/quick-lru': 5.2.0 - '@tailwindcss/node': 4.1.18 - '@tailwindcss/oxide': 4.1.18 + '@tailwindcss/node': 4.2.1 + '@tailwindcss/oxide': 4.2.1 postcss: 8.5.6 - tailwindcss: 4.1.18 + tailwindcss: 4.2.1 '@testing-library/dom@10.4.1': dependencies: @@ -4705,7 +4755,7 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true - '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.0.0)(lightningcss@1.30.2))': + '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.0.0)(lightningcss@1.31.1))': dependencies: '@bcoe/v8-coverage': 1.0.2 '@vitest/utils': 4.0.18 @@ -4717,7 +4767,7 @@ snapshots: obug: 2.1.1 std-env: 3.10.0 tinyrainbow: 3.0.3 - vitest: 4.0.18(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.0.0)(lightningcss@1.30.2) + vitest: 4.0.18(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.0.0)(lightningcss@1.31.1) '@vitest/expect@4.0.18': dependencies: @@ -4728,13 +4778,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.0.3 - '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2))': + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1))': dependencies: '@vitest/spy': 4.0.18 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2) + vite: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1) '@vitest/pretty-format@4.0.18': dependencies: @@ -5674,9 +5724,9 @@ snapshots: dependencies: is-callable: 1.2.7 - framer-motion@12.34.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + framer-motion@12.34.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: - motion-dom: 12.34.0 + motion-dom: 12.34.3 motion-utils: 12.29.2 tslib: 2.8.1 optionalDependencies: @@ -5811,7 +5861,7 @@ snapshots: dependencies: safer-buffer: 2.1.2 - icu-minify@4.8.2: + icu-minify@4.8.3: dependencies: '@formatjs/icu-messageformat-parser': 3.5.1 @@ -6079,54 +6129,54 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 - lightningcss-android-arm64@1.30.2: + lightningcss-android-arm64@1.31.1: optional: true - lightningcss-darwin-arm64@1.30.2: + lightningcss-darwin-arm64@1.31.1: optional: true - lightningcss-darwin-x64@1.30.2: + lightningcss-darwin-x64@1.31.1: optional: true - lightningcss-freebsd-x64@1.30.2: + lightningcss-freebsd-x64@1.31.1: optional: true - lightningcss-linux-arm-gnueabihf@1.30.2: + lightningcss-linux-arm-gnueabihf@1.31.1: optional: true - lightningcss-linux-arm64-gnu@1.30.2: + lightningcss-linux-arm64-gnu@1.31.1: optional: true - lightningcss-linux-arm64-musl@1.30.2: + lightningcss-linux-arm64-musl@1.31.1: optional: true - lightningcss-linux-x64-gnu@1.30.2: + lightningcss-linux-x64-gnu@1.31.1: optional: true - lightningcss-linux-x64-musl@1.30.2: + lightningcss-linux-x64-musl@1.31.1: optional: true - lightningcss-win32-arm64-msvc@1.30.2: + lightningcss-win32-arm64-msvc@1.31.1: optional: true - lightningcss-win32-x64-msvc@1.30.2: + lightningcss-win32-x64-msvc@1.31.1: optional: true - lightningcss@1.30.2: + lightningcss@1.31.1: dependencies: detect-libc: 2.1.2 optionalDependencies: - lightningcss-android-arm64: 1.30.2 - lightningcss-darwin-arm64: 1.30.2 - lightningcss-darwin-x64: 1.30.2 - lightningcss-freebsd-x64: 1.30.2 - lightningcss-linux-arm-gnueabihf: 1.30.2 - lightningcss-linux-arm64-gnu: 1.30.2 - lightningcss-linux-arm64-musl: 1.30.2 - lightningcss-linux-x64-gnu: 1.30.2 - lightningcss-linux-x64-musl: 1.30.2 - lightningcss-win32-arm64-msvc: 1.30.2 - lightningcss-win32-x64-msvc: 1.30.2 + lightningcss-android-arm64: 1.31.1 + lightningcss-darwin-arm64: 1.31.1 + lightningcss-darwin-x64: 1.31.1 + lightningcss-freebsd-x64: 1.31.1 + lightningcss-linux-arm-gnueabihf: 1.31.1 + lightningcss-linux-arm64-gnu: 1.31.1 + lightningcss-linux-arm64-musl: 1.31.1 + lightningcss-linux-x64-gnu: 1.31.1 + lightningcss-linux-x64-musl: 1.31.1 + lightningcss-win32-arm64-msvc: 1.31.1 + lightningcss-win32-x64-msvc: 1.31.1 locate-path@6.0.0: dependencies: @@ -6219,15 +6269,15 @@ snapshots: pkg-types: 1.3.1 ufo: 1.6.3 - motion-dom@12.34.0: + motion-dom@12.34.3: dependencies: motion-utils: 12.29.2 motion-utils@12.29.2: {} - motion@12.34.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + motion@12.34.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: - framer-motion: 12.34.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + framer-motion: 12.34.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) tslib: 2.8.1 optionalDependencies: react: 19.2.4 @@ -6243,20 +6293,20 @@ snapshots: negotiator@1.0.0: {} - next-intl-swc-plugin-extractor@4.8.2: {} + next-intl-swc-plugin-extractor@4.8.3: {} - next-intl@4.8.2(next@16.1.6(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(typescript@5.9.3): + next-intl@4.8.3(next@16.1.6(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(typescript@5.9.3): dependencies: - '@formatjs/intl-localematcher': 0.5.10 + '@formatjs/intl-localematcher': 0.8.1 '@parcel/watcher': 2.5.6 '@swc/core': 1.15.11 - icu-minify: 4.8.2 + icu-minify: 4.8.3 negotiator: 1.0.0 next: 16.1.6(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - next-intl-swc-plugin-extractor: 4.8.2 + next-intl-swc-plugin-extractor: 4.8.3 po-parser: 2.1.1 react: 19.2.4 - use-intl: 4.8.2(react@19.2.4) + use-intl: 4.8.3(react@19.2.4) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: @@ -6788,9 +6838,9 @@ snapshots: symbol-tree@3.2.4: {} - tailwind-merge@3.0.2: {} + tailwind-merge@3.5.0: {} - tailwindcss@4.1.18: {} + tailwindcss@4.2.1: {} tapable@2.3.0: {} @@ -6937,11 +6987,11 @@ snapshots: dependencies: punycode: 2.3.1 - use-intl@4.8.2(react@19.2.4): + use-intl@4.8.3(react@19.2.4): dependencies: '@formatjs/fast-memoize': 3.1.0 '@schummar/icu-type-parser': 1.21.5 - icu-minify: 4.8.2 + icu-minify: 4.8.3 intl-messageformat: 11.1.2 react: 19.2.4 @@ -6968,7 +7018,7 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2): + vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1): dependencies: esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.3) @@ -6980,12 +7030,12 @@ snapshots: '@types/node': 25.2.3 fsevents: 2.3.3 jiti: 2.6.1 - lightningcss: 1.30.2 + lightningcss: 1.31.1 - vitest@4.0.18(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.0.0)(lightningcss@1.30.2): + vitest@4.0.18(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.0.0)(lightningcss@1.31.1): dependencies: '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)) + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)) '@vitest/pretty-format': 4.0.18 '@vitest/runner': 4.0.18 '@vitest/snapshot': 4.0.18 @@ -7002,7 +7052,7 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2) + vite: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.2.3 diff --git a/frontend/src/__tests__/setup.ts b/frontend/src/__tests__/setup.ts index 2fd7f08..dcfc061 100644 --- a/frontend/src/__tests__/setup.ts +++ b/frontend/src/__tests__/setup.ts @@ -72,14 +72,22 @@ vi.mock('motion/react', () => ({ }), })) -// Mock next-intl -vi.mock('next-intl', () => ({ - useTranslations: (namespace?: string) => { - return (key: string) => (namespace ? `${namespace}.${key}` : key) - }, - useLocale: () => 'pt-BR', - NextIntlClientProvider: ({ children }: { children: React.ReactNode }) => children, -})) +// Mock next-intl — returns stable function references per namespace +// to avoid breaking referential equality in useCallback/useEffect chains. +vi.mock('next-intl', () => { + const translatorCache = new Map string>() + function getTranslator(ns: string): (key: string) => string { + if (!translatorCache.has(ns)) { + translatorCache.set(ns, (key: string) => (ns ? `${ns}.${key}` : key)) + } + return translatorCache.get(ns)! + } + return { + useTranslations: (namespace?: string) => getTranslator(namespace ?? ''), + useLocale: () => 'pt-BR', + NextIntlClientProvider: ({ children }: { children: React.ReactNode }) => children, + } +}) // Mock next-intl/server vi.mock('next-intl/server', () => ({ diff --git a/frontend/src/app/[locale]/(app)/exports/page.test.tsx b/frontend/src/app/[locale]/(app)/exports/page.test.tsx index c295e57..95c9584 100644 --- a/frontend/src/app/[locale]/(app)/exports/page.test.tsx +++ b/frontend/src/app/[locale]/(app)/exports/page.test.tsx @@ -3,27 +3,6 @@ import { render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import ExportsPage from './page' -vi.mock('motion/react', () => { - function stripMotionProps(rest: Record) { - const { initial: _i, animate: _a, transition: _t, layoutId: _l, ...safe } = rest - return safe - } - return { - motion: { - div: ({ children, ...rest }: Record) => { - return
{children as React.ReactNode}
- }, - li: ({ children, ...rest }: Record) => { - return
  • {children as React.ReactNode}
  • - }, - article: ({ children, ...rest }: Record) => { - return
    {children as React.ReactNode}
    - }, - }, - AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}, - } -}) - vi.mock('dompurify', () => ({ default: { sanitize: (html: string) => html, @@ -33,28 +12,26 @@ vi.mock('dompurify', () => ({ // Track what searchParams returns let mockPlanId: string | null = null -vi.mock('next/navigation', async () => { - const actual = await vi.importActual('next/navigation') - return { - ...actual as object, - usePathname: () => '/pt-BR/exports', - useRouter: () => ({ - push: vi.fn(), - replace: vi.fn(), - back: vi.fn(), - forward: vi.fn(), - refresh: vi.fn(), - prefetch: vi.fn(), - }), - useParams: () => ({ locale: 'pt-BR' }), - useSearchParams: () => ({ - get: (key: string) => { - if (key === 'planId') return mockPlanId - return null - }, - }), - } -}) +vi.mock('next/navigation', () => ({ + usePathname: () => '/pt-BR/exports', + useRouter: () => ({ + push: vi.fn(), + replace: vi.fn(), + back: vi.fn(), + forward: vi.fn(), + refresh: vi.fn(), + prefetch: vi.fn(), + }), + useParams: () => ({ locale: 'pt-BR' }), + useSearchParams: () => ({ + get: (key: string) => { + if (key === 'planId') return mockPlanId + return null + }, + }), + notFound: vi.fn(), + redirect: vi.fn(), +})) const MOCK_API_RESPONSE = { plan_title: 'Frações e Números Decimais', diff --git a/frontend/src/app/[locale]/(app)/layout.tsx b/frontend/src/app/[locale]/(app)/layout.tsx index 38d9925..0c6a016 100644 --- a/frontend/src/app/[locale]/(app)/layout.tsx +++ b/frontend/src/app/[locale]/(app)/layout.tsx @@ -2,6 +2,9 @@ import { getTranslations } from 'next-intl/server' import { Sidebar } from '@/components/layout/sidebar' import { TopBar } from '@/components/layout/topbar' import { MobileNav } from '@/components/layout/mobile-nav' +import { A11yStatusBadge } from '@/components/accessibility/a11y-status-badge' +import { PersonaExplainer } from '@/components/accessibility/persona-explainer' +import { MotorStickyToolbar } from '@/components/accessibility/motor-sticky-toolbar' interface AppLayoutProps { children: React.ReactNode @@ -32,6 +35,9 @@ export default async function AppLayout({ children }: AppLayoutProps) {
    + {/* "Why this adaptation?" context banner */} + +
    + {/* Motor accessibility sticky toolbar (F-235) */} + + {/* Mobile bottom navigation */} + + {/* Live accessibility status indicator */} + ) } diff --git a/frontend/src/app/[locale]/(app)/materials/page.test.tsx b/frontend/src/app/[locale]/(app)/materials/page.test.tsx new file mode 100644 index 0000000..0301e2d --- /dev/null +++ b/frontend/src/app/[locale]/(app)/materials/page.test.tsx @@ -0,0 +1,26 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import MaterialsPage from './page' + +vi.mock('./materials-content', () => ({ + MaterialsContent: () =>
    Materials
    , +})) + +describe('MaterialsPage', () => { + it('renders heading and description', async () => { + const Component = await MaterialsPage({ + params: Promise.resolve({ locale: 'en' }), + }) + render(Component) + expect(screen.getByText('materials.title')).toBeInTheDocument() + expect(screen.getByText('materials.upload_desc')).toBeInTheDocument() + }) + + it('renders MaterialsContent component', async () => { + const Component = await MaterialsPage({ + params: Promise.resolve({ locale: 'en' }), + }) + render(Component) + expect(screen.getByTestId('materials-content')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/app/[locale]/(app)/progress/page.test.tsx b/frontend/src/app/[locale]/(app)/progress/page.test.tsx new file mode 100644 index 0000000..aeb58bf --- /dev/null +++ b/frontend/src/app/[locale]/(app)/progress/page.test.tsx @@ -0,0 +1,25 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import ProgressPage from './page' + +vi.mock('@/components/progress/progress-dashboard', () => ({ + ProgressDashboard: () =>
    Dashboard
    , +})) + +describe('ProgressPage', () => { + it('renders heading', async () => { + const Component = await ProgressPage({ + params: Promise.resolve({ locale: 'en' }), + }) + render(Component) + expect(screen.getByText('progress.title')).toBeInTheDocument() + }) + + it('renders ProgressDashboard', async () => { + const Component = await ProgressPage({ + params: Promise.resolve({ locale: 'en' }), + }) + render(Component) + expect(screen.getByTestId('progress-dashboard')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/app/[locale]/(app)/settings/page.test.tsx b/frontend/src/app/[locale]/(app)/settings/page.test.tsx new file mode 100644 index 0000000..5093c92 --- /dev/null +++ b/frontend/src/app/[locale]/(app)/settings/page.test.tsx @@ -0,0 +1,26 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import SettingsPage from './page' + +vi.mock('./settings-content', () => ({ + SettingsContent: () =>
    Settings Content
    , +})) + +describe('SettingsPage', () => { + it('renders heading and description', async () => { + const Component = await SettingsPage({ + params: Promise.resolve({ locale: 'en' }), + }) + render(Component) + expect(screen.getByText('settings.title')).toBeInTheDocument() + expect(screen.getByText('settings.description')).toBeInTheDocument() + }) + + it('renders SettingsContent component', async () => { + const Component = await SettingsPage({ + params: Promise.resolve({ locale: 'en' }), + }) + render(Component) + expect(screen.getByTestId('settings-content')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/app/[locale]/(app)/settings/settings-content.test.tsx b/frontend/src/app/[locale]/(app)/settings/settings-content.test.tsx new file mode 100644 index 0000000..ac057b0 --- /dev/null +++ b/frontend/src/app/[locale]/(app)/settings/settings-content.test.tsx @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { SettingsContent } from './settings-content' + +describe('SettingsContent', () => { + it('renders AI model section', () => { + render() + expect(screen.getByText('settings.ai_model')).toBeInTheDocument() + expect(screen.getByText('settings.ai_model_desc')).toBeInTheDocument() + }) + + it('renders auto-routed model by default', () => { + render() + expect(screen.getByText('settings.auto_routed')).toBeInTheDocument() + }) + + it('renders language section with current locale', () => { + render() + expect(screen.getByText('settings.language')).toBeInTheDocument() + }) + + it('renders accessibility section with link to preferences', () => { + render() + expect(screen.getByText('settings.accessibility_settings')).toBeInTheDocument() + const link = screen.getByText('settings.open_preferences') + expect(link.closest('a')).toHaveAttribute('href', '/pt-BR/accessibility') + }) + + it('renders privacy section with PrivacyPanel', () => { + render() + expect(screen.getByText('settings.privacy')).toBeInTheDocument() + expect(screen.getByText('settings.privacy_desc')).toBeInTheDocument() + }) + + it('renders about section with version', () => { + render() + expect(screen.getByText('settings.about')).toBeInTheDocument() + expect(screen.getByText(/v0\.2\.0/)).toBeInTheDocument() + }) + + it('has proper section landmarks via aria-labelledby', () => { + render() + expect(document.getElementById('settings-ai-model')).toBeInTheDocument() + expect(document.getElementById('settings-language')).toBeInTheDocument() + expect(document.getElementById('settings-accessibility')).toBeInTheDocument() + expect(document.getElementById('settings-privacy')).toBeInTheDocument() + expect(document.getElementById('settings-about')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/app/[locale]/(app)/settings/settings-content.tsx b/frontend/src/app/[locale]/(app)/settings/settings-content.tsx index 70217c3..dc5a595 100644 --- a/frontend/src/app/[locale]/(app)/settings/settings-content.tsx +++ b/frontend/src/app/[locale]/(app)/settings/settings-content.tsx @@ -6,6 +6,7 @@ import { motion } from 'motion/react' import { cn } from '@/lib/cn' import { containerVariants, itemVariants } from '@/lib/motion-variants' import { PrivacyPanel } from '@/components/privacy/privacy-panel' +import { ThemeCompareSlider } from '@/components/accessibility/theme-compare-slider' const modelDisplay = process.env.NEXT_PUBLIC_DEFAULT_MODEL ?? 'Auto-routed (SmartRouter)' @@ -128,6 +129,10 @@ export function SettingsContent() { > {t('open_preferences')} + {/* Before/After theme comparison */} +
    + +
    diff --git a/frontend/src/app/[locale]/login/page.test.tsx b/frontend/src/app/[locale]/login/page.test.tsx new file mode 100644 index 0000000..0b9acbd --- /dev/null +++ b/frontend/src/app/[locale]/login/page.test.tsx @@ -0,0 +1,243 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import LoginPage from './page' +import { useAuthStore } from '@/stores/auth-store' +import { useAccessibilityStore } from '@/stores/accessibility-store' + +// Mock the child components to isolate page orchestration logic +vi.mock('@/components/auth/role-selection-phase', () => ({ + RoleSelectionPhase: ({ + onRoleSelect, + }: { + locale: string + noMotion: boolean + onRoleSelect: (role: string) => void + }) => ( +
    + + +
    + ), +})) + +vi.mock('@/components/auth/login-form-phase', () => ({ + LoginFormPhase: ({ + selectedRole, + onBack, + onDemoLogin, + onEmailChange, + onPasswordChange, + onSubmit, + email, + password, + isLoading, + error, + }: { + locale: string + noMotion: boolean + selectedRole: string + demoProfiles: unknown[] + email: string + password: string + isLoading: boolean + error: string | null + onBack: () => void + onDemoLogin: (profile: { key: string; route: string; accessibility?: string }) => void + onEmailChange: (v: string) => void + onPasswordChange: (v: string) => void + onSubmit: (e: React.FormEvent) => void + }) => ( +
    + {selectedRole} + + + + onEmailChange(e.target.value)} + /> + onPasswordChange(e.target.value)} + /> +
    + +
    + {isLoading && Loading} + {error && {error}} +
    + ), +})) + +vi.mock('@/lib/api', () => ({ + API_BASE: '/api', + setDemoProfile: vi.fn(), + demoLogin: vi.fn().mockResolvedValue(null), + getAuthHeaders: () => ({}), +})) + +vi.mock('@/hooks/use-theme', () => ({ + cssTheme: (id: string) => id, +})) + +vi.mock('@/components/auth/login-data', () => ({ + DEMO_PROFILES_BY_ROLE: { + teacher: [{ key: 'teacher', name: 'Teacher', route: '/dashboard', color: 'blue', avatar: 'T', description: '' }], + student: [], + parent: [], + // F-251: admin profiles removed + }, +})) + +const pushMock = vi.fn() +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: pushMock, replace: vi.fn(), back: vi.fn(), forward: vi.fn(), refresh: vi.fn(), prefetch: vi.fn() }), + useParams: () => ({ locale: 'en' }), + usePathname: () => '/en/login', + useSearchParams: () => new URLSearchParams(), + notFound: vi.fn(), + redirect: vi.fn(), +})) + +describe('LoginPage', () => { + const user = userEvent.setup() + + beforeEach(async () => { + pushMock.mockReset() + useAuthStore.setState({ user: null, token: null, isAuthenticated: false }) + useAccessibilityStore.setState({ theme: 'standard' }) + vi.mocked(await import('@/lib/api')).setDemoProfile.mockClear() + }) + + it('renders role selection phase initially', () => { + render() + expect(screen.getByTestId('role-selection')).toBeInTheDocument() + expect(screen.queryByTestId('login-form')).not.toBeInTheDocument() + }) + + it('renders title and subtitle', () => { + render() + expect(screen.getByText('login.title')).toBeInTheDocument() + expect(screen.getByText('login.subtitle')).toBeInTheDocument() + }) + + it('transitions to login form when role is selected', async () => { + render() + await user.click(screen.getByText('Select Teacher')) + expect(screen.getByTestId('login-form')).toBeInTheDocument() + expect(screen.getByTestId('selected-role')).toHaveTextContent('teacher') + }) + + it('transitions back to role selection when back is clicked', async () => { + render() + await user.click(screen.getByText('Select Teacher')) + expect(screen.getByTestId('login-form')).toBeInTheDocument() + + await user.click(screen.getByText('Back')) + expect(screen.getByTestId('role-selection')).toBeInTheDocument() + }) + + it('handles demo login by setting profile and navigating', async () => { + const { setDemoProfile } = await import('@/lib/api') + render() + await user.click(screen.getByText('Select Teacher')) + await user.click(screen.getByText('Demo Teacher')) + + expect(setDemoProfile).toHaveBeenCalledWith('teacher') + expect(pushMock).toHaveBeenCalledWith('/en/dashboard') + }) + + it('handles demo login with accessibility profile', async () => { + render() + await user.click(screen.getByText('Select Student')) + await user.click(screen.getByText('Demo Student ASD')) + + expect(pushMock).toHaveBeenCalledWith('/en/tutors') + // The accessibility theme should be set + const a11yState = useAccessibilityStore.getState() + expect(a11yState.theme).toBe('tea') + }) + + it('handles email login success', async () => { + const loginFn = vi.fn() + useAuthStore.setState({ login: loginFn } as never) + + // Mock successful fetch + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + access_token: 'jwt-123', + user: { + id: 'u1', + email: 'test@example.com', + display_name: 'Test', + role: 'teacher', + locale: 'en', + avatar_url: '', + accessibility_profile: 'standard', + is_active: true, + }, + }), + }) + + render() + await user.click(screen.getByText('Select Teacher')) + + // Type email and password + await user.type(screen.getByTestId('email'), 'test@example.com') + await user.type(screen.getByTestId('password'), 'password123') + + // Submit form + await user.click(screen.getByText('Submit')) + + expect(global.fetch).toHaveBeenCalledWith( + '/api/auth/login', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ email: 'test@example.com', password: 'password123', role: 'teacher' }), + }), + ) + expect(loginFn).toHaveBeenCalledWith('jwt-123', expect.objectContaining({ id: 'u1' })) + expect(pushMock).toHaveBeenCalledWith('/en/dashboard') + }) + + it('handles email login failure', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + json: () => Promise.resolve({ detail: 'Invalid credentials' }), + }) + + render() + await user.click(screen.getByText('Select Teacher')) + await user.type(screen.getByTestId('email'), 'bad@example.com') + await user.type(screen.getByTestId('password'), 'wrong') + await user.click(screen.getByText('Submit')) + + // Wait for error to appear + expect(await screen.findByTestId('error')).toHaveTextContent('Invalid credentials') + }) + + it('renders main landmark element', () => { + render() + expect(screen.getByRole('main')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/app/[locale]/login/page.tsx b/frontend/src/app/[locale]/login/page.tsx index e7ae010..8052037 100644 --- a/frontend/src/app/[locale]/login/page.tsx +++ b/frontend/src/app/[locale]/login/page.tsx @@ -6,7 +6,7 @@ import { motion, AnimatePresence, useReducedMotion } from 'motion/react' import { useTranslations } from 'next-intl' import { cn } from '@/lib/cn' import { useAuthStore, type UserRole } from '@/stores/auth-store' -import { API_BASE, setDemoProfile, getAuthHeaders } from '@/lib/api' +import { API_BASE, demoLogin, setDemoProfile, getAuthHeaders } from '@/lib/api' import { useAccessibilityStore } from '@/stores/accessibility-store' import { cssTheme } from '@/hooks/use-theme' import { DEMO_PROFILES_BY_ROLE, type DemoProfile } from '@/components/auth/login-data' @@ -46,7 +46,8 @@ export default function LoginPage() { }, []) const handleDemoLogin = useCallback( - (profile: DemoProfile) => { + async (profile: DemoProfile) => { + // Set demo profile first as fallback (X-Teacher-ID header in dev mode) setDemoProfile(profile.key) if (profile.accessibility) { @@ -58,6 +59,9 @@ export default function LoginPage() { } } + // Try proper JWT auth via demo-login endpoint + await demoLogin(profile.key) + router.push(`/${locale}${profile.route}`) }, [locale, router, setTheme], @@ -117,7 +121,7 @@ export default function LoginPage() { ) const demoProfiles = selectedRole - ? DEMO_PROFILES_BY_ROLE[selectedRole] + ? (DEMO_PROFILES_BY_ROLE[selectedRole] ?? []) : [] return ( diff --git a/frontend/src/components/accessibility/a11y-status-badge.test.tsx b/frontend/src/components/accessibility/a11y-status-badge.test.tsx new file mode 100644 index 0000000..2894f0a --- /dev/null +++ b/frontend/src/components/accessibility/a11y-status-badge.test.tsx @@ -0,0 +1,108 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { A11yStatusBadge } from './a11y-status-badge' +import { useAccessibilityStore } from '@/stores/accessibility-store' + +describe('A11yStatusBadge', () => { + beforeEach(() => { + useAccessibilityStore.setState({ + theme: 'standard', + fontSize: 'medium', + reducedMotion: false, + focusMode: false, + bionicReading: false, + }) + }) + + it('renders badge with default persona icon and name', () => { + render() + const badge = screen.getByRole('button', { name: /accessibility.status_badge_label/i }) + expect(badge).toBeInTheDocument() + }) + + it('shows persona name in the badge', () => { + render() + expect(screen.getByText('accessibility.themes.standard')).toBeInTheDocument() + }) + + it('does not show feature count when no features are active', () => { + render() + expect(screen.queryByText(/features_active/)).not.toBeInTheDocument() + }) + + it('shows feature count when features are active', () => { + useAccessibilityStore.setState({ + theme: 'tea', + reducedMotion: true, + focusMode: true, + }) + render() + // theme !== standard + reducedMotion + focusMode = 3 + expect(screen.getByText('3')).toBeInTheDocument() + }) + + it('expands on click to show feature details', () => { + render() + const badge = screen.getByRole('button', { name: /accessibility.status_badge_label/i }) + fireEvent.click(badge) + expect(screen.getByText('accessibility.font_size')).toBeInTheDocument() + expect(screen.getByText('accessibility.motion')).toBeInTheDocument() + expect(screen.getByText('accessibility.focusMode')).toBeInTheDocument() + expect(screen.getByText('accessibility.bionicReading')).toBeInTheDocument() + }) + + it('sets aria-expanded correctly', () => { + render() + const badge = screen.getByRole('button', { name: /accessibility.status_badge_label/i }) + expect(badge).toHaveAttribute('aria-expanded', 'false') + fireEvent.click(badge) + expect(badge).toHaveAttribute('aria-expanded', 'true') + }) + + it('shows active persona label when expanded', () => { + useAccessibilityStore.setState({ theme: 'dyslexia' }) + render() + fireEvent.click(screen.getByRole('button', { name: /accessibility.status_badge_label/i })) + expect(screen.getByText('accessibility.active_persona')).toBeInTheDocument() + }) + + it('shows feature values correctly for non-default state', () => { + useAccessibilityStore.setState({ + fontSize: 'large', + reducedMotion: true, + focusMode: true, + bionicReading: true, + }) + render() + fireEvent.click(screen.getByRole('button', { name: /accessibility.status_badge_label/i })) + expect(screen.getByText('accessibility.font_size_large')).toBeInTheDocument() + expect(screen.getByText('accessibility.motion_reduced')).toBeInTheDocument() + // Focus and bionic show "On" + const onLabels = screen.getAllByText('accessibility.badge_on') + expect(onLabels).toHaveLength(2) + }) + + it('closes on Escape key', () => { + render() + const badge = screen.getByRole('button', { name: /accessibility.status_badge_label/i }) + fireEvent.click(badge) + expect(badge).toHaveAttribute('aria-expanded', 'true') + fireEvent.keyDown(document, { key: 'Escape' }) + expect(badge).toHaveAttribute('aria-expanded', 'false') + }) + + it('closes on outside click', () => { + render() + const badge = screen.getByRole('button', { name: /accessibility.status_badge_label/i }) + fireEvent.click(badge) + expect(badge).toHaveAttribute('aria-expanded', 'true') + fireEvent.mouseDown(document.body) + expect(badge).toHaveAttribute('aria-expanded', 'false') + }) + + it('has status role on expanded panel', () => { + render() + fireEvent.click(screen.getByRole('button', { name: /accessibility.status_badge_label/i })) + expect(screen.getByRole('status')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/accessibility/a11y-status-badge.tsx b/frontend/src/components/accessibility/a11y-status-badge.tsx new file mode 100644 index 0000000..e7c9665 --- /dev/null +++ b/frontend/src/components/accessibility/a11y-status-badge.tsx @@ -0,0 +1,185 @@ +'use client' + +import { useState, useCallback, useRef, useEffect } from 'react' +import { useTranslations } from 'next-intl' +import { AnimatePresence, motion } from 'motion/react' +import { cn } from '@/lib/cn' +import { useAccessibilityStore } from '@/stores/accessibility-store' +import { PERSONAS } from '@/lib/accessibility-data' +import type { PersonaId } from '@/types/accessibility' + +/** + * Live Accessibility Status Badge — "Make the Invisible Visible" + * + * A compact floating indicator that shows the current accessibility + * configuration at a glance. Expands on click to reveal all active + * features. Designed to convert hidden a11y sophistication into + * something judges can see instantly. + * + * Position: bottom-left corner (avoids conflict with PersonaHUD on bottom-right). + */ +export function A11yStatusBadge() { + const t = useTranslations('accessibility') + const { + theme, fontSize, reducedMotion, focusMode, bionicReading, + } = useAccessibilityStore() + const [expanded, setExpanded] = useState(false) + const panelRef = useRef(null) + + const persona = PERSONAS[theme as PersonaId] ?? PERSONAS.standard + + // Count active features (non-default settings) + const activeFeatures: string[] = [] + if (theme !== 'standard') activeFeatures.push(t('badge_persona')) + if (fontSize !== 'medium') activeFeatures.push(t('badge_font')) + if (reducedMotion) activeFeatures.push(t('badge_motion')) + if (focusMode) activeFeatures.push(t('badge_focus')) + if (bionicReading) activeFeatures.push(t('badge_bionic')) + const activeCount = activeFeatures.length + + const toggle = useCallback(() => { + setExpanded((p) => !p) + }, []) + + // Close on outside click + useEffect(() => { + if (!expanded) return + function handleClick(e: MouseEvent) { + if (panelRef.current && !panelRef.current.contains(e.target as Node)) { + setExpanded(false) + } + } + function handleKey(e: KeyboardEvent) { + if (e.key === 'Escape') setExpanded(false) + } + document.addEventListener('mousedown', handleClick) + document.addEventListener('keydown', handleKey) + return () => { + document.removeEventListener('mousedown', handleClick) + document.removeEventListener('keydown', handleKey) + } + }, [expanded]) + + return ( +
    + {/* Expanded details panel */} + + {expanded && ( + + {/* Header */} +
    + +
    + + {t(`themes.${theme}`)} + + + {t('active_persona')} + +
    +
    + + {/* Feature indicators */} +
    + + + + +
    + + {/* Active count */} + {activeCount > 0 && ( +
    + + {t('features_active', { count: String(activeCount) })} + +
    + )} +
    + )} +
    + + {/* Badge pill trigger */} + +
    + ) +} + +function FeatureRow({ label, value, active }: { label: string; value: string; active: boolean }) { + return ( +
    + {label} + + {value} + +
    + ) +} diff --git a/frontend/src/components/accessibility/motor-sticky-toolbar.test.tsx b/frontend/src/components/accessibility/motor-sticky-toolbar.test.tsx new file mode 100644 index 0000000..92dc9a3 --- /dev/null +++ b/frontend/src/components/accessibility/motor-sticky-toolbar.test.tsx @@ -0,0 +1,129 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { MotorStickyToolbar } from './motor-sticky-toolbar' +import { useAccessibilityStore } from '@/stores/accessibility-store' + +describe('MotorStickyToolbar', () => { + beforeEach(() => { + useAccessibilityStore.setState({ + theme: 'standard', + fontSize: 'medium', + reducedMotion: false, + focusMode: false, + bionicReading: false, + lowDistraction: false, + }) + }) + + it('does not render when theme is not motor', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('renders toolbar when theme is motor', () => { + useAccessibilityStore.setState({ theme: 'motor' }) + render() + const toolbar = screen.getByRole('toolbar') + expect(toolbar).toBeInTheDocument() + }) + + it('has accessible label', () => { + useAccessibilityStore.setState({ theme: 'motor' }) + render() + expect(screen.getByRole('toolbar')).toHaveAttribute( + 'aria-label', + 'motor_toolbar.label' + ) + }) + + it('renders 4 action buttons', () => { + useAccessibilityStore.setState({ theme: 'motor' }) + render() + const buttons = screen.getAllByRole('button') + expect(buttons).toHaveLength(4) + }) + + it('scroll-to-top button scrolls main content', () => { + useAccessibilityStore.setState({ theme: 'motor' }) + + const mockScroll = vi.fn() + const mockFocus = vi.fn() + const mockMain = { + scrollTo: mockScroll, + focus: mockFocus, + } + vi.spyOn(document, 'getElementById').mockReturnValue(mockMain as unknown as HTMLElement) + + render() + const scrollBtn = screen.getByLabelText('motor_toolbar.scroll_top') + fireEvent.click(scrollBtn) + + expect(mockScroll).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' }) + expect(mockFocus).toHaveBeenCalledWith({ preventScroll: true }) + }) + + it('font increase button increases font size', () => { + useAccessibilityStore.setState({ theme: 'motor', fontSize: 'medium' }) + render() + + const increaseBtn = screen.getByLabelText('motor_toolbar.font_increase') + fireEvent.click(increaseBtn) + + expect(useAccessibilityStore.getState().fontSize).toBe('large') + }) + + it('font decrease button decreases font size', () => { + useAccessibilityStore.setState({ theme: 'motor', fontSize: 'large' }) + render() + + const decreaseBtn = screen.getByLabelText('motor_toolbar.font_decrease') + fireEvent.click(decreaseBtn) + + expect(useAccessibilityStore.getState().fontSize).toBe('medium') + }) + + it('font decrease is disabled at smallest size', () => { + useAccessibilityStore.setState({ theme: 'motor', fontSize: 'small' }) + render() + + const decreaseBtn = screen.getByLabelText('motor_toolbar.font_decrease') + expect(decreaseBtn).toBeDisabled() + }) + + it('font increase is disabled at largest size', () => { + useAccessibilityStore.setState({ theme: 'motor', fontSize: 'xlarge' }) + render() + + const increaseBtn = screen.getByLabelText('motor_toolbar.font_increase') + expect(increaseBtn).toBeDisabled() + }) + + it('focus mode toggle works', () => { + useAccessibilityStore.setState({ theme: 'motor', focusMode: false }) + render() + + const focusBtn = screen.getByLabelText('motor_toolbar.focus_on') + expect(focusBtn).toHaveAttribute('aria-pressed', 'false') + + fireEvent.click(focusBtn) + expect(useAccessibilityStore.getState().focusMode).toBe(true) + }) + + it('focus button shows active state when focus mode is on', () => { + useAccessibilityStore.setState({ theme: 'motor', focusMode: true }) + render() + + const focusBtn = screen.getByLabelText('motor_toolbar.focus_off') + expect(focusBtn).toHaveAttribute('aria-pressed', 'true') + }) + + it('disappears when theme changes away from motor', () => { + useAccessibilityStore.setState({ theme: 'motor' }) + const { rerender } = render() + expect(screen.getByRole('toolbar')).toBeInTheDocument() + + useAccessibilityStore.setState({ theme: 'standard' }) + rerender() + expect(screen.queryByRole('toolbar')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/accessibility/motor-sticky-toolbar.tsx b/frontend/src/components/accessibility/motor-sticky-toolbar.tsx new file mode 100644 index 0000000..b55933c --- /dev/null +++ b/frontend/src/components/accessibility/motor-sticky-toolbar.tsx @@ -0,0 +1,168 @@ +'use client' + +import { useCallback } from 'react' +import { useTranslations } from 'next-intl' +import { useAccessibilityStore } from '@/stores/accessibility-store' +import { cn } from '@/lib/cn' + +/** + * Sticky bottom toolbar for the motor accessibility persona (F-235). + * + * Renders only when `theme === 'motor'`. Provides large, pill-shaped + * action buttons for common tasks: scroll-to-top, font size +/-, + * focus mode toggle, and a "back to top" shortcut. + * + * Positioned above MobileNav (which is fixed bottom-0 on < md). + * The CSS in globals.css reserves `padding-bottom: 80px` on `main` + * for this toolbar when `[data-theme="motor"]` is active. + */ +export function MotorStickyToolbar() { + const t = useTranslations('motor_toolbar') + const theme = useAccessibilityStore((s) => s.theme) + const fontSize = useAccessibilityStore((s) => s.fontSize) + const setFontSize = useAccessibilityStore((s) => s.setFontSize) + const focusMode = useAccessibilityStore((s) => s.focusMode) + const toggleFocusMode = useAccessibilityStore((s) => s.toggleFocusMode) + + const scrollToTop = useCallback(() => { + const main = document.getElementById('main-content') + if (main) { + main.scrollTo({ top: 0, behavior: 'smooth' }) + main.focus({ preventScroll: true }) + } + }, []) + + const increaseFontSize = useCallback(() => { + const sizes = ['small', 'medium', 'large', 'xlarge'] + const idx = sizes.indexOf(fontSize) + if (idx < sizes.length - 1) { + setFontSize(sizes[idx + 1]) + } + }, [fontSize, setFontSize]) + + const decreaseFontSize = useCallback(() => { + const sizes = ['small', 'medium', 'large', 'xlarge'] + const idx = sizes.indexOf(fontSize) + if (idx > 0) { + setFontSize(sizes[idx - 1]) + } + }, [fontSize, setFontSize]) + + if (theme !== 'motor') return null + + return ( +
    + } + /> + } + disabled={fontSize === 'small'} + /> + } + disabled={fontSize === 'xlarge'} + /> + } + active={focusMode} + /> +
    + ) +} + +interface ToolbarButtonProps { + onClick: () => void + label: string + icon: React.ReactNode + disabled?: boolean + active?: boolean +} + +function ToolbarButton({ onClick, label, icon, disabled, active }: ToolbarButtonProps) { + return ( + + ) +} + +function ArrowUpIcon() { + return ( + + ) +} + +function FontDecreaseIcon() { + return ( + + ) +} + +function FontIncreaseIcon() { + return ( + + ) +} + +function FocusIcon() { + return ( + + ) +} diff --git a/frontend/src/components/accessibility/persona-explainer.test.tsx b/frontend/src/components/accessibility/persona-explainer.test.tsx new file mode 100644 index 0000000..62806bd --- /dev/null +++ b/frontend/src/components/accessibility/persona-explainer.test.tsx @@ -0,0 +1,59 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import { PersonaExplainer } from './persona-explainer' +import { useAccessibilityStore } from '@/stores/accessibility-store' + +describe('PersonaExplainer', () => { + beforeEach(() => { + useAccessibilityStore.setState({ theme: 'standard' }) + }) + + it('renders nothing when standard persona is active', () => { + const { container } = render() + expect(container.innerHTML).toBe('') + }) + + it('renders explainer banner when non-standard persona is active', () => { + useAccessibilityStore.setState({ theme: 'tea' }) + render() + expect(screen.getByRole('status')).toBeInTheDocument() + }) + + it('shows persona name and hint text', () => { + useAccessibilityStore.setState({ theme: 'dyslexia' }) + render() + expect(screen.getByText('accessibility.themes.dyslexia')).toBeInTheDocument() + expect(screen.getByText('accessibility.theme_hints.dyslexia')).toBeInTheDocument() + }) + + it('shows persona icon', () => { + useAccessibilityStore.setState({ theme: 'tdah' }) + render() + // TDAH icon is ⚡ + expect(screen.getByText('\u26A1')).toBeInTheDocument() + }) + + it('shows active persona badge', () => { + useAccessibilityStore.setState({ theme: 'motor' }) + render() + expect(screen.getByText('accessibility.active_persona')).toBeInTheDocument() + }) + + it('maps high_contrast to hyphenated theme key', () => { + useAccessibilityStore.setState({ theme: 'high_contrast' }) + render() + expect(screen.getByText('accessibility.themes.high-contrast')).toBeInTheDocument() + }) + + it('maps low_vision to hyphenated theme key', () => { + useAccessibilityStore.setState({ theme: 'low_vision' }) + render() + expect(screen.getByText('accessibility.themes.low-vision')).toBeInTheDocument() + }) + + it('has aria-live polite for screen reader announcements', () => { + useAccessibilityStore.setState({ theme: 'hearing' }) + render() + expect(screen.getByRole('status')).toHaveAttribute('aria-live', 'polite') + }) +}) diff --git a/frontend/src/components/accessibility/persona-explainer.tsx b/frontend/src/components/accessibility/persona-explainer.tsx new file mode 100644 index 0000000..3643d6f --- /dev/null +++ b/frontend/src/components/accessibility/persona-explainer.tsx @@ -0,0 +1,69 @@ +'use client' + +import { useTranslations } from 'next-intl' +import { AnimatePresence, motion } from 'motion/react' +import { cn } from '@/lib/cn' +import { useAccessibilityStore } from '@/stores/accessibility-store' +import { PERSONAS } from '@/lib/accessibility-data' +import type { PersonaId } from '@/types/accessibility' + +/** + * "Why this adaptation?" explainer banner. + * + * Shown when a non-standard persona is active. Explains in plain language + * what adaptations are being applied and why. Dismissible per session. + * + * This converts hidden accessibility sophistication into visible context + * that hackathon judges (and users) can understand at a glance. + */ +export function PersonaExplainer() { + const t = useTranslations('accessibility') + const tHints = useTranslations('accessibility.theme_hints') + const { theme } = useAccessibilityStore() + + if (theme === 'standard') return null + + const persona = PERSONAS[theme as PersonaId] + if (!persona) return null + + // Map to CSS theme key for translations + const themeKey = theme === 'high_contrast' ? 'high-contrast' + : theme === 'low_vision' ? 'low-vision' + : theme === 'screen_reader' ? 'screen-reader' + : theme + + return ( + + + + + {t(`themes.${themeKey}`)} + {' — '} + {tHints(theme)} + + + {t('active_persona')} + + + + ) +} diff --git a/frontend/src/components/accessibility/preferences-panel.tsx b/frontend/src/components/accessibility/preferences-panel.tsx index 54a98d4..df19250 100644 --- a/frontend/src/components/accessibility/preferences-panel.tsx +++ b/frontend/src/components/accessibility/preferences-panel.tsx @@ -23,6 +23,19 @@ const THEME_IDS = [ type ThemeId = (typeof THEME_IDS)[number] +/** Color preview swatches for each theme (bg, primary, text). */ +const THEME_COLORS: Record = { + standard: ['#FFFFFF', '#2563EB', '#1A1A2E'], + high_contrast: ['#000000', '#5EEAD4', '#FFFFFF'], + tea: ['#F0F7F4', '#2D8B6E', '#1B3A33'], + tdah: ['#FFFDF7', '#E07E34', '#2D2A24'], + dyslexia: ['#FBF5E6', '#3B7DD8', '#2C2416'], + low_vision: ['#FFFEF5', '#1A56DB', '#1A1400'], + hearing: ['#F8F9FE', '#4338CA', '#1E1E3A'], + motor: ['#FAFBFF', '#3B82F6', '#1C1C3A'], + screen_reader: ['#FFFFFF', '#1D4ED8', '#111827'], +} + interface PreferencesPanelProps { open: boolean onClose: () => void @@ -47,15 +60,14 @@ export function PreferencesPanel({ open, onClose }: PreferencesPanelProps) { } = useAccessibilityStore() const { startTransition } = useViewTransition() - // Save previously focused element and restore on unmount + // Save previously focused element on open; restore on close. + // Uses separate branches instead of cleanup to avoid stale closure. useEffect(() => { if (open) { previousFocusRef.current = document.activeElement as HTMLElement | null - } - return () => { - if (!open) { - previousFocusRef.current?.focus() - } + } else if (previousFocusRef.current) { + previousFocusRef.current.focus() + previousFocusRef.current = null } }, [open]) @@ -241,6 +253,19 @@ export function PreferencesPanel({ open, onClose }: PreferencesPanelProps) { )} + {/* Theme color preview chips */} +