diff --git a/.env.example b/.env.example index 3319492..0f3528b 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,11 @@ -# Local PostgreSQL settings only. These defaults are intentionally non-secret. +# Local development settings only. These defaults are intentionally non-secret. # Copy to .env to override them; never commit a real .env file. POSTGRES_DB=medcheck POSTGRES_USER=medcheck POSTGRES_PASSWORD=medcheck_local_only POSTGRES_PORT=127.0.0.1:5432 +# Export these values into the shell before running Django commands locally. +# Replace the development-only key outside local development. +DJANGO_SECRET_KEY=local-development-only-change-me +DJANGO_DEBUG=true diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 04a3bdb..5a81125 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -14,7 +14,7 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - + ref: ${{ github.event.pull_request.head.sha }} - uses: gitleaks/gitleaks-action@v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -68,7 +68,7 @@ jobs: version: 10.4.1 - uses: actions/setup-node@v4 with: - node-version: 22.14.0 + node-version: 22.23.3 cache: pnpm cache-dependency-path: web/pnpm-lock.yaml - name: Install locked dependencies @@ -81,6 +81,12 @@ jobs: run: pnpm typecheck - name: Run infrastructure tests with coverage run: pnpm test + - name: Build web application + run: pnpm build + - name: Install Playwright Chromium + run: pnpm exec playwright install --with-deps chromium + - name: Run landing-page browser tests + run: pnpm test:e2e - name: Upload web coverage if: always() uses: actions/upload-artifact@v4 @@ -88,3 +94,10 @@ jobs: name: web-coverage path: web/coverage if-no-files-found: ignore + - name: Upload Playwright diagnostics + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: web/playwright-report + if-no-files-found: ignore diff --git a/.node-version b/.node-version new file mode 100644 index 0000000..de889f8 --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +22.23.3 diff --git a/AGENTS.md b/AGENTS.md index e264877..236c1ea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,10 +2,12 @@ ## Current status -`infrastructure_plan.md` is the source of truth. The web tooling, Vite entrypoint, -Docker foundation, and minimal Django project scaffold are in place. Do not -introduce product pages, API handlers, domain models, authentication, or -business data while performing infrastructure work. +`infrastructure_plan.md` is the source of truth for architectural decisions. The +web tooling, MedCheck landing-page prototype, Playwright tests, Docker +foundation, and minimal Django project scaffold are in place. The landing page +only echoes a local search term; no medical search, production API, domain +model, authentication, or persistence workflow exists. Do not expand product +behavior while performing infrastructure work. ## Repository map @@ -17,10 +19,11 @@ business data while performing infrastructure work. - `backend/` — Django project scaffold, Python dependency/tool configuration, and infrastructure-only tests; no API or business application package exists yet. - `web/` — React/Vite entrypoint, dependency/tool configuration, and - infrastructure-only tests; no product pages or business UI exists yet. -- `.github/workflows/pr-checks.yml` — web-only pull-request quality checks. + the landing-page prototype with infrastructure and Playwright tests. +- `.github/workflows/pr-checks.yml` — secret, backend, web, build, and browser + pull-request quality checks. - `.github/workflows/release.yml` — not created; it requires a selected hosting provider. -- `docs/` — not created yet. +- `docs/specs/` — application and setup specifications. - `.agents/skills/` — project-specific skills and instructions. ## Required reading and skill selection @@ -52,7 +55,8 @@ docker build --file docker/backend.Dockerfile --tag medcheck-backend-tooling . docker compose config --quiet ./scripts/docker-smoke.sh (cd backend && uv run ruff format --check . && uv run ruff check . && uv run pyright && uv run pytest --cov=tests) -(cd web && pnpm format:check && pnpm lint && pnpm typecheck && pnpm test) +(cd backend && set -a && source ../.env.example && set +a && uv run python manage.py check) +(cd web && pnpm format:check && pnpm lint && pnpm typecheck && pnpm test && pnpm build && pnpm test:e2e) git diff --check ``` diff --git a/README.md b/README.md index 2f43f7e..259a96c 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,11 @@ ## Status -The repository has minimal Django and React/Vite application scaffolds, a local -PostgreSQL service, and pull-request checks. No production API, domain models, -or product UI exists yet. +The repository has a minimal Django scaffold, a React/Vite landing-page +prototype, a local PostgreSQL service, and pull-request checks. The landing page +accepts a local search term but does not query medical information. No +production API, domain models, authentication, or persistent product workflow +exists yet. ## Repository map @@ -16,33 +18,45 @@ or product UI exists yet. smoke test. - `backend/` — minimal Django project scaffold, Python/Django tooling, locked dependencies, and infrastructure probes; no API or business code exists yet. -- `web/` — React/Vite entrypoint, locked dependencies, infrastructure probes, and - the initial application shell; no product page or business UI exists yet. -- `.github/workflows/pr-checks.yml` — web-only infrastructure quality checks. +- `web/` — React/Vite landing-page prototype, locked dependencies, + infrastructure probes, and Playwright browser tests. +- `.github/workflows/pr-checks.yml` — secret scanning plus backend and web + quality checks, including the web build and Playwright tests. - `.github/dependabot.yml` — weekly dependency-update configuration. - `infrastructure_plan.md` — the approved infrastructure plan and source of truth for future configuration. - `.agents/skills/` — local agent skills. -- `docs/` and production application source directories — not created yet. +- `docs/specs/` — implementation specifications and scope boundaries. +- Production API and domain application packages — not created yet. ## Getting Started 1. Install [Docker Desktop](https://www.docker.com/products/docker-desktop/) - or Docker Engine with Compose v2, [Python 3.13](https://www.python.org/downloads/), - [uv](https://docs.astral.sh/uv/getting-started/installation/), and Node.js - 22 LTS with Corepack enabled. The Docker image supplies Python and uv only - for Docker work; host tools support editor and local quality-check workflows. -2. Optionally copy the non-secret local database defaults: + or Docker Engine with Compose v2, [uv](https://docs.astral.sh/uv/getting-started/installation/), + and Node.js 22.23.3 with Corepack. The version is recorded in + `.node-version`. Install [Python 3.13](https://www.python.org/downloads/) or + allow uv to install its managed Python 3.13 runtime. The Docker image supplies + Python and uv only for Docker work; host tools support editor and local + quality-check workflows. +2. Copy the non-secret local defaults and export them for Django commands: ```bash cp .env.example .env + set -a + source .env + set +a ``` + Docker Compose reads `.env` automatically. Django reads environment variables + from the shell, so source the file again in each new shell before running + Django commands. Never place a production secret in this file. + 3. Install the locked tooling dependencies: ```bash (cd backend && uv sync --all-groups --frozen) (cd web && corepack enable && pnpm install --frozen-lockfile) + (cd web && pnpm exec playwright install chromium) ``` 4. Build the development-tooling image: @@ -57,11 +71,12 @@ or product UI exists yet. docker compose up --detach postgres ``` -6. Run the available infrastructure checks and clean up the Docker foundation: +6. Run the available backend, web, and browser checks, then clean up the Docker + foundation: ```bash - (cd backend && uv run ruff format --check . && uv run ruff check . && uv run pyright && uv run pytest --cov=tests) - (cd web && pnpm format:check && pnpm lint && pnpm typecheck && pnpm test) + (cd backend && uv run python manage.py check && uv run ruff format --check . && uv run ruff check . && uv run pyright && uv run pytest --cov=tests) + (cd web && pnpm format:check && pnpm lint && pnpm typecheck && pnpm test && pnpm build && pnpm test:e2e) ./scripts/docker-smoke.sh docker compose down --volumes ``` @@ -70,8 +85,8 @@ The local database is exposed only on `127.0.0.1:5432` by default. Change `POSTGRES_PORT` in an untracked `.env` if that port is occupied. Reset local database data with `docker compose down --volumes`. -Playwright end-to-end tests are intentionally deferred until the first product -workflow exists. A production release workflow is also deferred until a hosting +Playwright currently verifies the landing-page layout and local search-form +behavior. A production release workflow remains deferred until a hosting provider is selected. ## Troubleshooting @@ -82,5 +97,11 @@ provider is selected. restart the Compose service. - **A stale database is causing unexpected results:** run `docker compose down --volumes` before starting again. -- **`uv` or `pnpm` is missing:** install the host prerequisite from the links - above, then rerun the locked install command. +- **Django reports a missing `DJANGO_SECRET_KEY`:** source the untracked `.env` + into the current shell as shown above. +- **Node reports an unsupported engine:** select Node 22.23.3 from + `.node-version`; Node 24 is outside the supported project range. Homebrew's + `node@22` is keg-only, so add `/opt/homebrew/opt/node@22/bin` to `PATH` when + using that installation. +- **`uv` or `pnpm` is missing:** install uv or run `corepack enable` under Node + 22, then rerun the locked install command. diff --git a/docs/specs/setup-alignment.md b/docs/specs/setup-alignment.md new file mode 100644 index 0000000..a362b87 --- /dev/null +++ b/docs/specs/setup-alignment.md @@ -0,0 +1,55 @@ +# Setup Alignment Specification + +## Objective + +Make the documented developer setup, pull-request checks, and current MedCheck +landing-page prototype agree with the repository. The prototype accepts a local +search term and displays a status message; it does not perform a medical search +or call a backend API. + +## Commands + +- Backend install: `cd backend && uv sync --all-groups --frozen` +- Backend checks: `cd backend && uv run ruff format --check . && uv run ruff check . && uv run pyright && uv run pytest --cov=tests` +- Web install: `cd web && corepack enable && pnpm install --frozen-lockfile` +- Web checks: `cd web && pnpm format:check && pnpm lint && pnpm typecheck && pnpm test && pnpm build && pnpm test:e2e` +- Docker smoke test: `./scripts/docker-smoke.sh` + +## Project Structure + +- `backend/` contains the minimal Django scaffold and backend tooling. +- `web/` contains the React/Vite landing-page prototype and its tests. +- `web/tests/e2e/` contains Playwright browser tests. +- `.github/workflows/` contains pull-request automation. +- `docs/specs/` contains implementation specifications. + +## Code Style + +Use the configured Ruff, Prettier, ESLint, and TypeScript rules. Keep UI state +local until a backend contract is specified. + +## Testing Strategy + +Run fast backend and web harness tests first, then build the web application and +run the landing-page workflow in Chromium with Playwright. CI must enforce the +same checks and retain Playwright diagnostics when browser tests fail. + +## Boundaries + +- Always use locked dependencies and safe local-only environment values. +- Ask before adding APIs, persistence, authentication, or real medical search. +- Never commit `.env`, real secrets, medical data, or generated build output. + +## Success Criteria + +- Node 22 and pnpm setup is reproducible and documented. +- Django's required local environment value is documented and checkable. +- README and AGENTS accurately describe the current files and commands. +- Pull requests run formatting, linting, type checks, tests, the web build, and + Playwright browser checks. +- Backend, web, browser, Docker, and repository hygiene checks pass locally. + +## Open Questions + +The real search behavior, medical-data sources, API contract, authentication, +and hosting provider remain intentionally unspecified. diff --git a/web/.prettierignore b/web/.prettierignore index 46cc696..fe0d9bf 100644 --- a/web/.prettierignore +++ b/web/.prettierignore @@ -2,3 +2,5 @@ coverage/ dist/ node_modules/ +playwright-report/ +test-results/ diff --git a/web/playwright.config.ts b/web/playwright.config.ts index 6431494..35b39ae 100644 --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -1,4 +1,13 @@ import { defineConfig } from "@playwright/test"; -// No web server is defined until the application entrypoint exists. -export default defineConfig({ testDir: "tests/e2e" }); +export default defineConfig({ + testDir: "tests/e2e", + use: { + baseURL: "http://127.0.0.1:5173", + }, + webServer: { + command: "./node_modules/.bin/vite --host 127.0.0.1", + url: "http://127.0.0.1:5173", + reuseExistingServer: true, + }, +}); diff --git a/web/src/App.tsx b/web/src/App.tsx index 9fde9d6..f286ea0 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,32 +1,99 @@ +import { FormEvent, useState } from "react"; + +type OpenPanel = "menu" | "account" | null; + export function App() { + const [query, setQuery] = useState(""); + const [submittedQuery, setSubmittedQuery] = useState(""); + const [openPanel, setOpenPanel] = useState(null); + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + const trimmedQuery = query.trim(); + + if (trimmedQuery) { + setSubmittedQuery(trimmedQuery); + } + } + return ( -
-
-

Personal health workspace

- +
- -
-

Medcheck

-

A clear starting point for your health records.

-

- The React and Vite foundation is ready. Your first workflow will have - a focused home here once the product details are defined. + + + + {openPanel === "menu" && ( +

+ )} + + {openPanel === "account" && ( +
+

Account

+ +
+ )} + + +
+

MedCheck

+

+ Your simple starting point for health information.

-
-
-

Next step

-

Define the first user workflow

-

- This shell is intentionally small: it proves the browser entrypoint, - styling pipeline, and accessible React mount without inventing - application behavior. +

+ + setQuery(event.target.value)} + placeholder="Search medications, symptoms, or health topics" + autoComplete="off" + /> + +
+ +

+ {submittedQuery ? `Searching for “${submittedQuery}”` : ""}

diff --git a/web/src/styles.css b/web/src/styles.css index 5ed634b..18000fd 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1,5 +1,5 @@ :root { - color: #1f2a27; + color: #1d332b; background: #f7f5f0; font-family: Inter, @@ -23,109 +23,257 @@ body { margin: 0; } -body::before { - position: fixed; - inset: 0; - z-index: -1; - background: radial-gradient(circle at 85% 15%, #dce9df 0, transparent 32rem); - content: ""; +button, +input { + font: inherit; } -.shell { - width: min(100% - 3rem, 72rem); - margin: 0 auto; - padding: 2rem 0 5rem; +.landing-page { + position: relative; + display: grid; + min-height: 100vh; + place-items: center; + padding: 1.5rem; } -.masthead { +.top-bar { + position: absolute; + top: 1.5rem; + right: 1.5rem; + left: 1.5rem; display: flex; align-items: center; justify-content: space-between; - gap: 1rem; - border-bottom: 1px solid #d9ddd5; - padding-bottom: 1.25rem; } -.eyebrow, -.kicker, -.card-label { - margin: 0; - color: #557267; +.popover { + position: absolute; + top: 3.5rem; + display: grid; + min-width: 9rem; + gap: 0.25rem; + padding: 0.75rem; + border: 1px solid #c7d2ca; + border-radius: 0.75rem; + background: #fffefa; + box-shadow: 0 0.75rem 2rem rgb(44 72 57 / 12%); + text-align: left; +} + +.menu-panel { + left: 0; +} + +.account-panel { + right: 0; +} + +.popover-title { + margin: 0 0 0.25rem; + color: #60716b; font-size: 0.75rem; font-weight: 700; - letter-spacing: 0.12em; + letter-spacing: 0.08em; text-transform: uppercase; } -.status { - display: inline-flex; - align-items: center; - gap: 0.5rem; +.popover button { + border: 0; + border-radius: 0.375rem; + background: transparent; + color: #1d332b; + cursor: pointer; + padding: 0.5rem; + text-align: left; +} + +.popover button:hover, +.popover button:focus-visible { + background: #edf3ee; + outline: 0; +} + +.icon-button { + display: grid; + width: 2.75rem; + height: 2.75rem; + place-items: center; + border: 1px solid transparent; + border-radius: 0.5rem; + background: transparent; color: #557267; - font-size: 0.875rem; + cursor: pointer; + transition: + border-color 160ms ease, + background-color 160ms ease, + color 160ms ease; } -.status span { - width: 0.5rem; - height: 0.5rem; - border-radius: 50%; - background: #4d8b69; +.icon-button:hover { + border-color: #c7d2ca; + background: #fffefa; + color: #2f6b4f; } -.intro { - max-width: 48rem; - padding: clamp(5rem, 15vw, 10rem) 0 4rem; +.icon-button:focus-visible { + outline: 3px solid #9bc4a7; + outline-offset: 2px; } -.intro h1 { - max-width: 13ch; - margin: 1rem 0 1.5rem; - color: #203c34; - font-size: clamp(2.75rem, 7vw, 5.5rem); - font-weight: 650; - letter-spacing: -0.06em; - line-height: 0.98; +.icon-button svg { + width: 1.5rem; + height: 1.5rem; + fill: currentColor; } -.summary { - max-width: 34rem; - margin: 0; - color: #60716b; - font-size: clamp(1rem, 2vw, 1.25rem); - line-height: 1.6; +.hamburger-icon { + display: grid; + width: 1.25rem; + gap: 0.25rem; } -.next-step { - max-width: 36rem; - border-top: 3px solid #92b49b; - padding: 1.5rem 0 0; +.hamburger-icon span { + display: block; + height: 0.125rem; + border-radius: 999px; + background: currentColor; } -.next-step h2 { - margin: 0.75rem 0; - color: #203c34; - font-size: 1.25rem; - letter-spacing: -0.02em; +.search-panel { + display: flex; + width: min(100%, 42rem); + align-items: center; + flex-direction: column; + text-align: center; } -.next-step p:last-child { +h1 { margin: 0; + color: #1d332b; + font-size: clamp(3rem, 9vw, 5.5rem); + font-weight: 650; + letter-spacing: -0.07em; + line-height: 1; +} + +.tagline { + margin: 1rem 0 2.25rem; color: #60716b; - line-height: 1.6; + font-size: clamp(1rem, 2vw, 1.125rem); + line-height: 1.5; +} + +.search-form { + display: flex; + width: 100%; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.5rem 0.5rem 1.25rem; + border: 1px solid #c7d2ca; + border-radius: 0.75rem; + background: #fffefa; + box-shadow: 0 0.75rem 2rem rgb(44 72 57 / 8%); + transition: + border-color 160ms ease, + box-shadow 160ms ease; +} + +.search-form:focus-within { + border-color: #6c9a7d; + box-shadow: 0 0 0 0.25rem rgb(108 154 125 / 18%); +} + +.search-form input { + min-width: 0; + flex: 1; + border: 0; + outline: 0; + background: transparent; + color: #1d332b; + font-size: 1rem; +} + +.search-form input::placeholder { + color: #84918a; +} + +.search-form button { + display: grid; + width: 2.75rem; + height: 2.75rem; + flex: 0 0 auto; + place-items: center; + border: 0; + border-radius: 0.5rem; + background: #2f6b4f; + color: #fffefa; + cursor: pointer; + transition: + background-color 160ms ease, + transform 160ms ease; +} + +.search-form button:hover { + background: #24563f; +} + +.search-form button:active { + transform: scale(0.96); } -@media (max-width: 600px) { - .shell { - width: min(100% - 2rem, 72rem); - padding-top: 1.25rem; +.search-form button:focus-visible { + outline: 3px solid #9bc4a7; + outline-offset: 2px; +} + +.search-form svg { + width: 1.25rem; + height: 1.25rem; + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.75; +} + +.search-status { + min-height: 1.5rem; + margin: 1rem 0 0; + color: #557267; + font-size: 0.875rem; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +@media (max-width: 480px) { + .landing-page { + padding: 1rem; + } + + .top-bar { + top: 1rem; + right: 1rem; + left: 1rem; + } + + .tagline { + margin-bottom: 1.75rem; } - .masthead { - align-items: flex-start; - flex-direction: column; + .search-form { + padding-left: 1rem; } - .intro { - padding-top: 5rem; + .search-form input { + font-size: 0.9375rem; } } diff --git a/web/tests/e2e/landing-page.spec.ts b/web/tests/e2e/landing-page.spec.ts new file mode 100644 index 0000000..52202ca --- /dev/null +++ b/web/tests/e2e/landing-page.spec.ts @@ -0,0 +1,69 @@ +import { expect, test } from "@playwright/test"; + +test("centers MedCheck above the search bar", async ({ page }) => { + await page.goto("/"); + + const heading = page.getByRole("heading", { name: "MedCheck" }); + const searchbox = page.getByRole("searchbox", { + name: "Search medications, symptoms, or health topics", + }); + const searchForm = page.getByRole("search"); + + await expect(heading).toBeVisible(); + await expect(searchbox).toBeVisible(); + await expect(page.getByText("MC", { exact: true })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Open menu" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Account" })).toBeVisible(); + + const [headingBox, searchFormBox] = await Promise.all([ + heading.boundingBox(), + searchForm.boundingBox(), + ]); + + expect(headingBox).not.toBeNull(); + expect(searchFormBox).not.toBeNull(); + + const viewportCenter = (page.viewportSize()?.width ?? 0) / 2; + expect( + Math.abs(headingBox!.x + headingBox!.width / 2 - viewportCenter), + ).toBeLessThan(2); + expect( + Math.abs(searchFormBox!.x + searchFormBox!.width / 2 - viewportCenter), + ).toBeLessThan(2); +}); + +test("shows the submitted search term", async ({ page }) => { + await page.goto("/"); + + const searchbox = page.getByRole("searchbox"); + await searchbox.fill("ibuprofen"); + await searchbox.press("Enter"); + + await expect( + page.getByRole("status").filter({ hasText: "Searching for “ibuprofen”" }), + ).toBeVisible(); +}); + +test("does not submit an empty search", async ({ page }) => { + await page.goto("/"); + + await page.getByRole("searchbox").press("Enter"); + + await expect(page.getByRole("status")).toHaveText(""); +}); + +test("opens the menu and account panels", async ({ page }) => { + await page.goto("/"); + + const menuButton = page.getByRole("button", { name: "Open menu" }); + const accountButton = page.getByRole("button", { name: "Account" }); + + await menuButton.click(); + await expect(page.getByText("Menu", { exact: true })).toBeVisible(); + await expect(menuButton).toHaveAttribute("aria-expanded", "true"); + + await accountButton.click(); + await expect(page.getByText("Account", { exact: true })).toBeVisible(); + await expect(page.getByText("Menu", { exact: true })).toHaveCount(0); + await expect(accountButton).toHaveAttribute("aria-expanded", "true"); +});