Bladeren bron

Plan Phases 8–11 in HANDOFF.md

Each of the remaining items from the original §10 "Known gaps" becomes
an independent phase with its own deliverable list, commit stub, and
size estimate. Explicitly called out in §14 "Upcoming phases":

- Phase 8 — Cascade audit integrity. Snapshot-audits three paths that
  currently lose rows to FK cascades: sprint_worker → sprint_worker_days,
  sprint_worker → task_assignments, sprint_week → sprint_worker_days
  (on weeks shrink). Pattern mirrors TaskController::delete from Phase 6.
- Phase 9 — Users management. GET /users + POST /users/{id} toggle
  is_admin, with self-demote and last-admin guardrails + audit.
- Phase 10 — Task list polish. Multi-select owner filter + column
  visibility toggle, both pure client-side with localStorage.
- Phase 11 — CSP hardening. Vendor Tailwind via a Node build stage,
  move the single inline onclick out of home.php, drop 'unsafe-inline'
  from the CSP.

Also: §9 marks Phase 7 shipped with its commit SHA, §10 shrinks to the
truly residual items (worker-reorder-reloads, OIDC library 8.4
deprecations, manual acceptance walkthrough), §12 updates the resume
prompt to start on Phase 8, §13 logs the latest commits, and §3 picks
up the Phase 7 additions (AuditController, AuditRepository, audit/,
ACCEPTANCE.md, tests/).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
achiappa 2 weken geleden
bovenliggende
commit
8537fc2d79
1 gewijzigde bestanden met toevoegingen van 314 en 79 verwijderingen
  1. 314 79
      HANDOFF.md

+ 314 - 79
HANDOFF.md

@@ -21,8 +21,8 @@ per-cell audit trail.
 - Runtime: Docker, single container, `php:8.3-apache` base.
 - Language: PHP 8.3, strict types, PSR-12.
 - Database: SQLite via PDO, file at `/var/www/data/app.sqlite` (mounted volume).
-- Front end: server-rendered PHP templates + Tailwind (Play CDN) + jQuery 3.x
-  + jQuery UI 1.13 (CDN). No build step.
+- Front end: server-rendered PHP templates + Tailwind (Play CDN, slated for
+  vendoring in Phase 11) + jQuery 3.x + jQuery UI 1.13 (CDN). No build step yet.
 - Auth: Microsoft Entra ID via OpenID Connect (Authorization Code + PKCE),
   plus an optional env-configured "local admin" fallback for dev / on-prem.
 - Composer deps: `jumbojett/openid-connect-php`, `vlucas/phpdotenv`,
@@ -35,28 +35,35 @@ per-cell audit trail.
 ├── docker-compose.yml
 ├── .env.example
 ├── composer.json / composer.lock
+├── phpunit.xml
+├── ACCEPTANCE.md              # §10 manual checklist walkthrough
 ├── public/
-│   ├── index.php              # front controller + router wiring
+│   ├── index.php              # front controller + router wiring + security headers
 │   ├── .htaccess              # belt-and-suspenders rewrite
 │   └── assets/
 │       └── js/
-│           ├── sprint-planner.js    # main planning view (/sprints/{id})
-│           └── sprint-settings.js   # settings page
+│           ├── sprint-planner.js    # /sprints/{id} Arbeitstage + task list
+│           └── sprint-settings.js   # /sprints/{id}/settings
 ├── src/
 │   ├── Auth/            LocalAdmin, OidcClient, SessionGuard
-│   ├── Controllers/     AuthController, WorkerController, SprintController, TaskController
+│   ├── Controllers/     AuthController, WorkerController, SprintController,
+│   │                    TaskController, AuditController
 │   ├── Db/              Connection, Migrator
-│   ├── Domain/          User, Worker, Sprint, SprintWeek, SprintWorker, SprintWorkerDay, Task, TaskAssignment
+│   ├── Domain/          User, Worker, Sprint, SprintWeek, SprintWorker,
+│   │                    SprintWorkerDay, Task, TaskAssignment
 │   ├── Http/            Request, Response, Router, View (+ e() helper)
-│   ├── Repositories/    UserRepository, WorkerRepository, SprintRepository, SprintWeekRepository,
-│   │                    SprintWorkerRepository, SprintWorkerDayRepository,
-│   │                    TaskRepository, TaskAssignmentRepository
+│   ├── Repositories/    UserRepository, WorkerRepository, SprintRepository,
+│   │                    SprintWeekRepository, SprintWorkerRepository,
+│   │                    SprintWorkerDayRepository, TaskRepository,
+│   │                    TaskAssignmentRepository, AuditRepository
 │   └── Services/        AuditLogger, CapacityCalculator
 ├── migrations/          001_init.sql  (full schema per spec §3)
 ├── views/               layout.php, home.php, auth/local.php,
-│                        workers/index.php, sprints/{new,show,settings}.php
-├── data/                SQLite + sessions directory (volume-mounted, gitignored)
-└── tests/               (empty; PHPUnit setup pending in Phase 7)
+│                        workers/index.php,
+│                        sprints/{new,show,settings}.php,
+│                        audit/index.php
+├── tests/               TestCase.php + Services/ + Repositories/
+└── data/                SQLite + sessions directory (volume-mounted, gitignored)
 ```
 
 ## 4. Schema (migrations/001_init.sql)
@@ -74,6 +81,17 @@ Value constraints enforced in PHP (not SQL):
 - `task_assignments.days` ≥ 0, no hard upper bound.
 - `reserve_fraction`, `rtb` ∈ [0, 1].
 
+FK cascades (important for audit-integrity work in Phase 8):
+- `sprint_weeks.sprint_id → sprints(id)` ON DELETE CASCADE
+- `sprint_workers.sprint_id → sprints(id)` ON DELETE CASCADE
+- `sprint_workers.worker_id → workers(id)` ON DELETE RESTRICT
+- `sprint_worker_days.sprint_worker_id → sprint_workers(id)` ON DELETE CASCADE
+- `sprint_worker_days.sprint_week_id → sprint_weeks(id)` ON DELETE CASCADE
+- `tasks.sprint_id → sprints(id)` ON DELETE CASCADE
+- `tasks.owner_worker_id → workers(id)` ON DELETE SET NULL
+- `task_assignments.task_id → tasks(id)` ON DELETE CASCADE
+- `task_assignments.sprint_worker_id → sprint_workers(id)` ON DELETE CASCADE
+
 ## 5. Capacity math (spec §6.5)
 
 Runs identically in `App\Services\CapacityCalculator` (PHP) and in
@@ -109,6 +127,7 @@ Pages (HTML):
 | POST   | `/sprints`                  | admin          |
 | GET    | `/sprints/{id}`             | signed-in      |
 | GET    | `/sprints/{id}/settings`    | admin          |
+| GET    | `/audit`                    | admin          |
 
 JSON (admin-only, CSRF via `X-CSRF-Token` header; envelope per spec §7):
 | Method | Path                                         | What          |
@@ -172,65 +191,53 @@ with a `BOOTSTRAP_ADMIN` audit row.
 
 ## 9. Build phases — status
 
-- [x] **Phase 1 — Skeleton** (`58a6b30`): Dockerfile, docker-compose, composer,
-  front controller, router, migrator, hello home, `/healthz`.
+### Shipped
+
+- [x] **Phase 1 — Skeleton** (`58a6b30`)
 - [x] **Phase 2 — Auth** (`be193d2`, hotfix `83493d0`): Entra OIDC with PKCE,
-  session + CSRF, first-user-is-admin bootstrap, login/logout/LOGIN_FAILED/
-  BOOTSTRAP_ADMIN audit, local-admin fallback, scalar-safe Request.
-- [x] **Apache routing fix** (`82ddc98`): replaced sed-based docroot hack with
-  explicit site config using `FallbackResource /index.php`.
-- [x] **Phase 3 — Workers + sprints + audit** (`f189e7d`): workers CRUD,
-  sprints create, generic AuditLogger wired into repositories, home shows
-  sprint list.
+      session + CSRF, first-user-is-admin bootstrap, local-admin fallback.
+- [x] **Apache routing fix** (`82ddc98`): FallbackResource /index.php.
+- [x] **Phase 3 — Workers + sprints + audit** (`f189e7d`).
 - [x] **Phase 4 — Sprint settings** (`38ba151`): meta edit, weeks resize,
-  worker membership add/remove/reorder (jQuery UI sortable), per-row RTB.
-- [x] **Phase 5 — Arbeitstage grid** (`515d7d0`): editable matrix,
-  CapacityCalculator (server + client), per-cell persistence with audit.
-- [x] **Phase 6 — Task list** (`ad78283`): task CRUD, assignments grid,
-  client-side sort/filter/search, drag-reorder, capacity updates with
-  committed prio-1.
-- [x] **SRI fix** (`927b708`): dropped hand-typed SRI hashes, guarded
-  `.sortable()` calls so missing jQuery UI fails soft.
-- [ ] **Phase 7 — Audit viewer + polish** (pending). Required deliverables:
-  - [ ] `/audit` admin page: reverse-chronological, paginated (50/page),
-        filters (user / action / entity_type / date range / entity_id search),
-        pretty-printed collapsible before/after JSON.
-  - [ ] Security headers: `X-Content-Type-Options: nosniff`,
-        `X-Frame-Options: DENY`, `Referrer-Policy: strict-origin-when-cross-origin`,
-        a strict-ish CSP allowing `cdn.tailwindcss.com` and `code.jquery.com`.
-  - [ ] Double-check CSRF on every mutation (mostly in place — verify).
-  - [ ] PHPUnit tests from spec §10:
-        - `CapacityCalculator`: half-step rounding, prio-2 exclusion, empty-sprint edges.
-        - Day-value validation: rejects 0.3, 1.7, negative.
-        - `AuditLogger`: no-op update → 0 rows; real update → 1 row; DELETE
-          carries `before_json` + null `after_json`.
-        - OIDC bootstrap: first user becomes admin; subsequent users do not.
-  - [ ] Walk through the manual acceptance checklist in spec §10.
-
-## 10. Known gaps / deferred items
-
-- **Phase 4 removeWorker cascade is un-audited.** When a sprint_worker is
-  removed, the FK on `sprint_worker_days` cascade-deletes rows that are
-  NOT reflected in `audit_log`. TaskController::delete does the correct
-  snapshot-then-delete; SprintController::removeWorker should be brought
-  to parity. Fix sketch: in a tx, pre-read all `sprint_worker_days` rows
-  for that sprint_worker, write DELETE audit for each, then call
-  `SprintWorkerRepository::remove()`.
-- **removeWorker also cascades `task_assignments`** (via FK on sprint_worker)
-  — same audit gap.
-- **PHPUnit test harness not set up** (no `phpunit.xml` yet, tests/ is
-  empty). Phase 7 work.
-- **No users management page**. Admin bootstrap on first login is the only
-  way to gain admin rights. If you demote yourself, there's no UI to fix
-  it — you'd need to hand-edit the DB (or re-log-in via local admin with
-  `forceAdmin=true`).
-- **Column-visibility toggle** on the task list (spec §6.4 mentions it)
-  was deferred; not implemented. Horizontal scroll handles wide worker
-  counts for now.
-- **Multi-select owner filter** (spec §6.4) is a single-select in the UI.
+      worker membership add/remove/reorder, per-row RTB.
+- [x] **Phase 5 — Arbeitstage grid** (`515d7d0`): editable matrix, capacity
+      calc, per-cell persistence with audit.
+- [x] **Phase 6 — Task list** (`ad78283`): CRUD, assignments grid,
+      sort/filter/search, drag-reorder.
+- [x] **SRI hotfix** (`927b708`): guarded sortable() calls.
+- [x] **Phase 7 — Audit viewer + polish** (`21d0c4a`): `/audit` admin page
+      with filters + pagination + collapsible diffs, security headers +
+      strict-ish CSP, CSRF audit (18/18 mutations), PHPUnit harness with
+      59 tests covering CapacityCalculator, day-value validation,
+      AuditLogger, and OIDC bootstrap. [ACCEPTANCE.md](ACCEPTANCE.md)
+      captures the spec §10 manual walkthrough.
+
+### Upcoming
+
+Planned in §14. Short version:
+
+- [ ] **Phase 8 — Cascade audit integrity**
+- [ ] **Phase 9 — Users management**
+- [ ] **Phase 10 — Task list polish** (column visibility + multi-select owner filter)
+- [ ] **Phase 11 — CSP hardening** (vendor Tailwind + drop inline onclick)
+
+## 10. Residual known gaps / deferred items
+
+Items moved into Phases 8–11 are NOT listed here — see §14.
+
 - **Worker reorder on `/sprints/{id}` reloads the page** after drag so the
   task list's worker columns stay in sync. Acceptable, but noisy if the
-  user has a lot of edits in flight (they're all saved by then).
+  user has a lot of edits in flight (they're all saved by then). Not
+  scheduled; the reload is fast and the alternative (live-reordering
+  columns in JS) is complex for little win.
+- **OIDC library raises PHP 8.4 deprecations.** `jumbojett/openid-connect-php`
+  1.0.2 uses implicitly-nullable params. The container runs 8.3 where these
+  are E_DEPRECATED but still emit — harmless, and silenced by
+  `ini_set('display_errors','0')` in production. Upstream library needs a
+  release.
+- **Manual acceptance walkthrough** ([ACCEPTANCE.md](ACCEPTANCE.md))
+  hasn't been executed end-to-end by a human yet — it's a documentary
+  follow-up once Phase 8 lands.
 
 ## 11. Running locally
 
@@ -254,23 +261,22 @@ Syntax-check PHP without Docker:
 for f in $(git ls-files '*.php'); do php -l "$f" | tail -1 | sed "s|^|$f: |"; done
 ```
 
-Run the in-session smoke tests (used throughout to verify each phase):
-these live as ad-hoc `php -r '…'` scripts in the commit history and the
-handoff conversation. There's no formal test runner until Phase 7 lands
-PHPUnit.
+Run the test suite:
+```bash
+vendor/bin/phpunit
+# → OK (59 tests, 90 assertions)
+```
 
 ## 12. How to resume in a fresh Claude session
 
 Tell Claude:
 
 > Working on `/Users/achiappa/Development/claude_code_private/sprint_planer_web`.
-> Read `HANDOFF.md`, `CLAUDE.md` if present, the git log, and
-> `.planning/*` if present. Phases 1–6 are complete (including hotfixes).
-> Start **Phase 7 — Audit viewer + polish**: /audit page with filters and
-> pagination, security headers, strict-ish CSP allowing the two CDNs,
-> PHPUnit test harness with the four test groups listed in spec §10, and
-> walk through the manual acceptance checklist. Do it one deliverable at a
-> time, commit between each, keep the existing audit-logging conventions.
+> Read `HANDOFF.md`, the git log, and `ACCEPTANCE.md`. Phases 1–7 are
+> shipped (see §9). Start **Phase 8 — Cascade audit integrity**. Follow
+> §14 for the full plan of Phases 8–11. Do one phase at a time, commit
+> after each, and update `HANDOFF.md` §9 to move each phase from
+> "Upcoming" to "Shipped" with its commit SHA.
 
 Claude should verify what's described here against actual repo state
 before acting — nothing here is load-bearing once it grows stale.
@@ -278,6 +284,9 @@ before acting — nothing here is load-bearing once it grows stale.
 ## 13. Git history (as of this handoff)
 
 ```
+afa9e4f Ignore PHPUnit cache directory
+21d0c4a Phase 7: audit viewer + security headers + PHPUnit
+09b67f3 Add HANDOFF.md for resuming work in a fresh session
 927b708 Fix: drop unreliable SRI hashes + guard sortable() calls
 ad78283 Phase 6: task list, assignments, client-side sort/filter/search
 515d7d0 Phase 5: Arbeitstage grid + capacity calculator + cell persistence
@@ -292,3 +301,229 @@ be193d2 Phase 2: Entra OIDC auth + session + audit log
 Each commit message captures the deliverables, rationale, and the smoke
 tests that were run to validate it. Read those in preference to any
 summary here.
+
+---
+
+## 14. Upcoming phases (plan)
+
+Four phases close out every remaining item from the original §10 "Known
+gaps". Ordered by value and independent enough to ship one at a time —
+**do not batch them into one commit**.
+
+### Phase 8 — Cascade audit integrity
+
+**Why.** `task_assignments` deletes correctly snapshot-audit their
+children before the FK cascade (see `TaskController::delete`). Three
+other cascade paths don't:
+
+| Parent delete                       | Cascaded rows              | Where gap lives               |
+|-------------------------------------|----------------------------|-------------------------------|
+| `sprint_workers` row removed        | `sprint_worker_days`       | `SprintController::removeWorker` |
+| `sprint_workers` row removed        | `task_assignments`         | `SprintController::removeWorker` |
+| `sprint_weeks` row removed (shrink) | `sprint_worker_days`       | `SprintController::replaceWeeks` / `SprintWeekRepository::syncCount` |
+
+Spec §5 is explicit: every DELETE on a domain table produces exactly one
+audit row. Cascades violate that.
+
+**Deliverables.**
+- `SprintWorkerDayRepository::allForSprintWorker(int $swId): list<SprintWorkerDay>`
+- `SprintWorkerDayRepository::allForSprintWeek(int $weekId): list<SprintWorkerDay>`
+- `TaskAssignmentRepository::allForSprintWorker(int $swId): list<TaskAssignment>`
+- `SprintController::removeWorker` — inside the tx, fetch both child sets
+  for the target `sprint_worker`, emit DELETE audit rows for each, THEN
+  call `$sprintWorkers->remove($swId)` so the FK cascade runs cleanly.
+- `SprintController::replaceWeeks` — for each week in
+  `$weeks->syncCount(...)->removed`, fetch `sprint_worker_days` for that
+  week and emit DELETE audit rows BEFORE the inner DELETE (move the
+  fetch into the repo or do it in the controller before calling
+  `syncCount`). Simpler shape: change `syncCount` to return the
+  orphaned `sprint_worker_days` alongside `removed` weeks.
+- PHPUnit tests covering each of the three paths: set up a tiny sprint
+  with one worker + one week + one day cell + one task + one assignment,
+  trigger the delete, assert one audit row per cascaded child.
+
+**Commit message stub.**
+> `Phase 8: audit rows for FK-cascaded deletes`
+> - Three cascade paths now emit DELETE audit rows before the parent
+>   delete runs, matching the TaskController::delete pattern from Phase 6.
+> - Repos gain by-parent lookup helpers.
+> - Regression tests.
+
+**Estimated size.** ~150 LOC + 3 test classes.
+
+---
+
+### Phase 9 — Users management
+
+**Why.** The first login promotes a single user to admin. If that admin
+demotes themselves or wants to grant admin to someone else, there is no
+UI — they have to hand-edit SQLite or re-log in via local admin.
+
+**Design.**
+- New page `GET /users` (admin-only): table of every user with columns
+  email, display name, is_admin (checkbox), last_login_at.
+- New endpoint `POST /users/{id}` (admin-only form, CSRF) that toggles
+  `is_admin`. Form-based not JSON, to match `/workers/{id}`.
+- Guardrails (both enforced server-side with 422 on violation):
+  - You cannot demote yourself.
+  - You cannot demote the last remaining admin.
+- Every toggle writes an UPDATE audit row on `user`.
+
+**Deliverables.**
+- `UserRepository`:
+  - `all(): list<User>` — sorted by email.
+  - `countAdmins(): int`.
+  - `setAdmin(int $id, bool $isAdmin): array{before: User, after: User}`.
+- `UserController` (new):
+  - `index(Request): Response` — renders `views/users/index.php`.
+  - `updateAdmin(Request, array $params): Response` — form POST handler.
+- `views/users/index.php` — table + inline toggle forms + flash banner.
+- Route wiring + admin nav link ("Users" next to Workers / New sprint /
+  Audit log).
+- PHPUnit:
+  - Cannot demote self (return 422, no DB change, no audit).
+  - Cannot demote last admin (same).
+  - Admin→non-admin and back: UPDATE audit written with proper
+    before/after.
+
+**Commit message stub.**
+> `Phase 9: users management page (promote / demote admin)`
+
+**Estimated size.** ~200 LOC + tests + 1 view.
+
+---
+
+### Phase 10 — Task list polish
+
+**Why.** Spec §6.4 calls out a column-visibility toggle and a
+multi-select owner filter; Phase 6 shipped the task list with a
+single-select filter and no visibility toggle.
+
+**Deliverables — purely client-side.**
+
+1. **Multi-select owner filter** in the task toolbar.
+   - Replace the `<select data-owner-filter>` with a "trigger button →
+     checkbox dropdown" pattern. Options: `(No owner)` + every worker.
+   - State is a `Set<string>` of selected values. Empty set == show all.
+   - Click outside closes the dropdown.
+   - Filter logic: row visible iff the set is empty OR the row's owner
+     id is in the set.
+   - Persist in `localStorage` keyed by `sprint-{id}-owner-filter`.
+
+2. **Column-visibility toggle** in the task toolbar.
+   - "Columns" button opens a checkbox list: Owner, Prio, Tot, and one
+     entry per sprint worker column. Task column is always visible.
+   - Hidden columns get a `hidden` class on BOTH the `<th>` and every
+     corresponding `<td>`.
+   - Persist in `localStorage` keyed by `sprint-{id}-column-visibility`.
+   - Restoring a hidden state on page load runs BEFORE first sort /
+     filter so layout doesn't flash.
+
+3. Wire `recomputeAllCapacity()` to still work when some columns are
+   hidden (it reads from DOM, so values stay correct — but verify).
+
+4. Light "sort/filter don't touch hidden rows differently" check: sort
+   sees hidden rows the same as visible rows (correct). Filter and
+   visibility are independent dimensions.
+
+**Deliverables — no backend changes.** No repo, no controller, no route,
+no audit. All in `sprint-planner.js` + `views/sprints/show.php`
+(replace the `<select>`, add a `"Columns"` button, add the dropdown
+containers).
+
+**Commit message stub.**
+> `Phase 10: multi-select owner filter + column visibility toggle`
+
+**Estimated size.** ~250 LOC of JS + small view changes. No tests
+required (behaviour is pure DOM manipulation); manual acceptance
+covers it.
+
+---
+
+### Phase 11 — CSP hardening (vendor Tailwind + drop inline onclick)
+
+**Why.** Current CSP:
+```
+script-src 'self' https://cdn.tailwindcss.com https://code.jquery.com 'unsafe-inline'
+style-src 'self' https://code.jquery.com 'unsafe-inline'
+```
+
+- `cdn.tailwindcss.com` is a dev-only CDN that runs a JIT compiler in
+  the browser — not acceptable for production.
+- `'unsafe-inline'` in script-src is there for the one inline
+  `onclick="location.href=…"` in `views/home.php`.
+- `'unsafe-inline'` in style-src is there because Tailwind's runtime JIT
+  injects `<style>` blocks.
+
+Once Tailwind is pre-compiled and the inline onclick is gone, we can
+drop both `'unsafe-inline'` directives.
+
+**Deliverables.**
+
+1. **Vendor Tailwind via a multi-stage Docker build.**
+   - `package.json` with `tailwindcss@^3` (v3 — simpler than v4).
+   - `tailwind.config.js` scanning `views/**/*.php`, `src/**/*.php`,
+     `public/assets/js/**/*.js`.
+   - `assets/css/input.css` (NOT under `public/`) with `@tailwind base;
+     components; utilities;`.
+   - `Dockerfile` gains a build stage:
+     ```
+     FROM node:20-alpine AS css-builder
+     WORKDIR /build
+     COPY package.json package-lock.json* ./
+     RUN npm ci
+     COPY tailwind.config.js ./
+     COPY assets/css/input.css ./assets/css/input.css
+     COPY views/ ./views/
+     COPY src/ ./src/
+     COPY public/assets/js/ ./public/assets/js/
+     RUN npx tailwindcss -i ./assets/css/input.css -o /build/app.css --minify
+     ```
+     Final stage `COPY --from=css-builder /build/app.css
+     /var/www/html/public/assets/css/app.css`.
+   - `.gitignore` gains `/node_modules/` (already there).
+   - `views/layout.php`: replace `<script src="https://cdn.tailwindcss.com">`
+     with `<link rel="stylesheet" href="/assets/css/app.css">`.
+
+2. **Move the inline `onclick` out of `home.php`.**
+   - Change each sprint-list row to `data-href="/sprints/{id}"` and
+     drop the `onclick` attribute.
+   - New tiny `public/assets/js/app.js` (loaded in `layout.php`) wires a
+     delegated `click` handler on any `[data-href]` and navigates.
+
+3. **Tighten CSP in `public/index.php`.**
+   ```
+   default-src 'self';
+   script-src 'self' https://code.jquery.com;
+   style-src  'self' https://code.jquery.com;
+   img-src 'self' data:;
+   font-src 'self' data: https://code.jquery.com;
+   connect-src 'self';
+   frame-ancestors 'none';
+   base-uri 'self';
+   form-action 'self' https://login.microsoftonline.com;
+   ```
+   No more `'unsafe-inline'`, no more `cdn.tailwindcss.com`.
+
+4. **Verification checklist (manual).**
+   - Pages render correctly after the rebuild (`docker compose build
+     --no-cache && docker compose up`).
+   - Browser DevTools → Console: zero CSP violations on every page
+     (home, sprint, settings, workers, audit, local sign-in form).
+   - `curl -I http://localhost:8080/` shows the tightened CSP.
+   - Sprint-row click on the home page still navigates.
+
+**Commit message stub.**
+> `Phase 11: vendor Tailwind + drop inline onclick + tighten CSP`
+> - Multi-stage Docker build produces public/assets/css/app.css.
+> - home.php uses data-href instead of inline onclick; app.js handles nav.
+> - CSP drops 'unsafe-inline' and the Tailwind CDN.
+
+**Estimated size.** Infrastructure changes (Dockerfile, package.json,
+tailwind.config.js, input.css, app.js, layout.php, index.php header
+builder). ~120 LOC net + the dep manifest. Manual verification
+dominates the cost.
+
+**Rollback plan.** If Tailwind build breaks production, reverting this
+commit is a single `git revert` and the app returns to the Play-CDN
+state. No schema or data touched.