# Tasks Implementation plan for `./requirements.md`, following `./design.md`. Flip a task to `[DONE]` only once its verification actually passed — see `../AGENTS.md` §4. Priority for this round: **get the whole loop working end to end**. Polish comes after the first real batch has produced a model. --- ## 1. Foundation documents — `[DONE]` Write `../AGENTS.md`, `./requirements.md`, `./design.md`, `./tasks.md`; make `../CLAUDE.md` a symlink to `../AGENTS.md`. **Verify:** the user reads and approves the contents. ## 2. Docker, backend skeleton, database — `[DONE]` Serves REQ-070…074. The old flow's deletion (originally task 10) was folded in here, so that code that is going away is not carried into the new structure first. - `Dockerfile`: python 3.12 + `ffmpeg` + `uv` + CUDA torch + `uv pip install -e sam3/`. - `docker-compose.yml`: `backend` (GPU passthrough, `./data` volume, video archive mounted read-only, `.env`). The `frontend` service (nginx) is added alongside the SPA in task 3. - Move `app/` → `backend/`, keeping module names; add `backend/config.py` for the environment-driven paths. - Delete `uploads.py`, `static/index.html`, the `uploads/` folder, and every endpoint of the old image-folder flow. - `backend/db.py`: SQLite connection (WAL) + idempotent migration for the whole schema. - Rework `backend/jobs.py`: job types, handler registry, rows persisted to the database. `labeling.py` and `training.py` are left in place but have no callers until tasks 6–9 wire them back in. `exporters.py` and `sessions.py` did not survive that rewiring — see `./design.md` for why. **Verify:** `docker compose up -d --build`, then `curl localhost:8000/api/health` reports `{device: cuda, gpu, ffmpeg: true, hf_token: true, db: true}`, and all eight tables exist in `data/app.db`. Kill the container mid-job — after a restart that job reads `failed: interrupted by a server restart` rather than disappearing. ## 3. Project CRUD + Projects page — `[DONE]` Serves REQ-001…006. - `backend/projects.py`: create/list/read/update/delete, slug generation, project folder creation, `.pt` upload, class list read from `YOLO(path).names`. - `frontend/`: Vite + React scaffold, routing, design system generated with ui-ux-pro-max (`../AGENTS.md` §7) as tokens shared by every later page, Projects page with its form. **Verify:** create a `sack` project with a real `.pt`; its classes appear automatically and are read-only. `data/projects/sack/` exists on disk. Creating a project without a `.pt` requires a typed class list. ## 4. Video library — `[DONE]` Serves REQ-010…012. - `backend/library.py`: scan `//.`, parse date and batch label, read duration/resolution via `ffprobe` (cached), mark videos already used as a batch. - Library page: dates column → video list. **Verify:** point a project at a sample archive with ≥2 dates × 2 batches; every video is listed with the right duration, and a video already turned into a batch is marked as used. ## 5. Video streaming, trim, frame extraction — `[DONE]` Serves REQ-013, REQ-020…023. - `backend/video.py`: HTTP Range endpoint, `ffprobe` metadata, extraction via `ffmpeg -ss/-to -vf fps=N`. - `backend/batches.py`: create a batch and enqueue the `extract` job. - Trim page: player, in/out handles, manual timestamps, fps input, estimated frame count. **Verify:** pick date 08 / batch 4, trim 00:30–02:00 at 2 fps, run extraction → 180 files in `data/projects//batches//frames/`, the job shows progress and finishes `done`. Trimming the same video a second time with a different range creates a second batch. ## 6. Auto-annotation job — `[DONE]` Serves REQ-030…034. - `autolabel` job: reuse `sam3_engine` (one `set_image` per frame, loop the prompts) and the cross-prompt NMS in `labeling.py`; write `annotations` rows with `source='auto'`. - Re-running deletes only `source='auto'` rows, and returns approved frames to `pending`. **Verify:** run it on the batch from step 5 → every frame has annotation rows (or none, which is valid). Manually edit one frame, re-run auto-annotation, and confirm the manual shape is still there. Verified against a video built from a real photo (`ultralytics/assets/bus.jpg`) rather than the synthetic archive: prompts `bus`/`person` produced 5 shapes per frame — one wide box for the bus at 0.95 and four narrow ones for the people at 0.94–0.96. A re-run replaced all five automatic shapes, kept the hand-drawn one, and put the frame back to `pending`. Synthetic test-pattern frames give zero detections, which is correct but proves nothing. ## 7. Review page + annotation editor — `[DONE]` Serves REQ-040…045. - `backend/review.py`: annotation CRUD, frame status, SAM3 click-assist. `sessions.py` was deleted rather than reused — see `./design.md`. - Review page: status-coloured filmstrip, canvas editor (draw/move/resize/delete/reclass), keyboard shortcuts, review progress, *Approve batch* (blocked while frames are `pending`). **Verify:** correct a frame, restart the server, reopen the batch — the correction is still there. Approving is refused while any frame is `pending`. Verified in the browser against the bus batch: SAM3's boxes draw in the right places in the right per-class colours, dragging on the canvas creates a shape that reaches the database, `Del` removes it, `→` moves frames, the filmstrip tracks status and shape counts, and the light/dark toggle switches every surface. Five defects the rendering exposed, all fixed: 1. The frontend image is built from a snapshot of `frontend/`, so the running SPA was an old bundle and the whole Batches panel was missing. `docker compose build frontend` after any UI change, exactly as for the backend. 2. `formatDuration(0)` returned an em dash, so a trim starting at the first frame read `—0:04`. Zero is a real timestamp. 3. Sub-megabyte videos rounded to `0 MB`. 4. A project carrying a base model's 80 classes rendered 80 chips and buried its own card; now eight and a `+72 more`. 5. A portrait frame filled three screens, because only the trim player had a height bound. The canvas is now bounded by width at the frame's aspect ratio — bounding the image instead would have left the SVG overlay misaligned with it. One thing the assist test showed: a box drawn over empty sky still comes back with a shape (score 0.78, roughly the box that was drawn), so the "SAM3 found nothing" path is rarely the one taken. The user's judgement is the filter, not the model's. ## 8. Approve → merge into the master dataset — `[DONE]` Serves REQ-050…054. - `backend/dataset.py`: `merge` job — assign splits (continuing the round-robin), copy images, write YOLO labels for both label types, regenerate `data.yaml`, record `dataset_items`. - Dataset summary + `.zip` download. **Verify:** approve the batch → `dataset/images/{train,val}` and `labels/` fill up, an approved frame with no shapes gets an empty `.txt`, rejected frames are absent. Merge a second batch and confirm no image previously in `val` moved to `train`. Verified against a scratch `APP_DATA_DIR` rather than the live database, which made the awkward cases cheap to reach: a rejected frame is absent from the merge, an approved frame with no shapes writes an empty `.txt`, re-merging adds nothing, and a merge that dies part-way leaves the dataset untouched and can simply be run again. ## 9. Training from the base model + comparison — `[DONE]` Serves REQ-060…065. - `backend/hardware.py`: VRAM detection → `batch`/`imgsz`/`device` defaults. - `backend/training.py`: release SAM3, fine-tune from `base/model.pt` on the master dataset, store `models//`, auto-name version `{arch}-{labelType}-{epochs}ep-{classNames}-{YYYYMMDD}`. - `backend/evaluate.py`: `.val()` for the base model and the new one against the same `data.yaml`; write `metrics.json`. - Models page: train button, progress, base-vs-new table, download (`{name}-best.pt`), *promote*. **Verify:** run a short training (few epochs) → the table shows mAP50 / mAP50-95 for both models with a descriptive name, `best.pt` downloads as `{name}-best.pt`, promoting the version swaps the project's base model and a second training run starts from it. Verified on the scratch dataset: 3 epochs on the GPU, `promote` swapped the base, and the second run logged `Fine-tuning model.pt`. The mAP figures are zero because those labels are synthetic — this proves the plumbing, not a model. ## 10. Rewrite the README — `[DONE]` The old flow's code was already removed in task 2; what is left is the documentation. - Rewrite `../README.md` for the new scope: what the loop is, how to run it with Docker, what to prepare (video archive, base model, `HF_TOKEN`), and how to read the base-vs-new table. **Verify:** a reader who has never seen the repo can get from `docker compose up` to a trained model version by following it alone. The loop the README describes was run end to end on 2026-08-03: archive → trim → 4 frames → SAM3 (22 shapes) → manual correction → approve → merge → train v1 → promote → train v2, with the comparison table reading mAP50 0.2829 against the base's 0.0160. Only the browser leg was not walked. ## 11. Class deletion & batch class cleanup — `[DONE]` Serves REQ-007, REQ-046. - `backend/projects.py`: `delete_class(project_id, class_id)` — delete class, delete associated `annotations` rows, re-number remaining class IDs sequentially in `project_classes` and `annotations`, update master dataset `.txt` label files and `data.yaml` if merged. - `backend/review.py` / `backend/api/batches.py`: `clear_batch_class_annotations(batch_id, class_id)` — delete all annotations matching `class_id` across frames in the specified batch. - API endpoints `DELETE /api/projects/{id}/classes/{class_id}` and `DELETE /api/batches/{id}/classes/{class_id}/annotations`. - Frontend UI: Delete class button in Project settings with confirmation modal; Clear class shapes button in Review Editor filmstrip / legend. **Verify:** Create project with classes [A, B, C], annotate frames with all 3. Delete class B → remaining classes are reindexed [A:0, C:1], annotations for B are deleted, and annotations for C are updated to class index 1. Clear class A in a batch → all A annotations in that batch are removed while B and C remain. ## 12. Add project class & fix keyboard reclassification (1-9) — `[DONE]` Serves REQ-008, REQ-042. - `backend/projects.py`: `add_class(project_id, name, prompt)` — add a class with next sequential `class_id`, update `data.yaml` if merged dataset exists. - API endpoint `POST /api/projects/{id}/classes`. - Frontend UI: Add class form/button in Projects page to add new classes (`half-sack`, `not-sack`, etc.). - Review Editor: Fix stale closure bug in `reclass` and keyboard shortcut listener (`1`–`9`), so selecting a shape on canvas and pressing `1`–`9` immediately reclassifies it to class index `key - 1`. Display shortcut badges `[1]`, `[2]`, `[3]` on class chips. **Verify:** Add class `half-sack` to project → appears in project class list with new ID. Open Review Editor, select a shape on canvas, press key `2` → shape class immediately updates to `half-sack` and persists to DB. --- # Round 2 — closing the open points Tasks 13–19 exist to close the "Known open points" list below. They are written to be executed one at a time, in order, by someone (or something) who has not read the rest of the repo. Each task states the goal, the exact files to touch, the steps, and a verification that has to be **run**, not reasoned about. Do not start task N+1 until task N verifies. Ground rules that apply to every task below (from `../AGENTS.md`): - `uv` only — `uv run python ...`, never bare `python`/`pip`. - Touch only the files a task names. No drive-by refactors, no reformatting. - No file over 400 lines. Current sizes worth knowing: `backend/projects.py` 396, `backend/review.py` 331, `frontend/src/pages/ReviewPage.jsx` 417, `frontend/src/components/AnnotationCanvas.jsx` 252. Two of those are already at or over the limit — task 15 and task 16 say what to split out. - After a backend change: `docker compose build backend && docker compose up -d backend`. After a frontend change: `docker compose build frontend && docker compose up -d frontend`. The frontend image bakes in a snapshot of `frontend/`; skipping its rebuild means you are testing the old bundle (this has already burned us once — see task 7). - Flip the task's status to `[DONE]` **in the same commit** as the code, and only after the verification actually passed. Paste the real observed numbers into the task, like tasks 6–10 do. ### Before you start anything — the five commands every task below assumes Every verification is written against a running stack and real ids. Get these first; do not guess an id, and do not hardcode `1`. ```bash # 1. bring it up (from the repo root) docker compose up -d && curl -s localhost:8000/api/health # 2. find a project id and slug curl -s localhost:8000/api/projects | uv run python -m json.tool | grep -E '"id"|"slug"' # 3. find a batch id for that project (and its frame count) curl -s localhost:8000/api/projects//batches | uv run python -m json.tool \ | grep -E '"id"|"frame_count"|"status"' # 4. find frame ids in a batch curl -s localhost:8000/api/batches//frames | uv run python -m json.tool | grep '"id"' # 5. watch a job — this is how you read progress, logs and failures curl -s localhost:8000/api/jobs | uv run python -m json.tool | head -40 curl -s localhost:8000/api/jobs/ | uv run python -m json.tool # includes the log array ``` The database is `data/app.db`; `sqlite3` queries in the tasks below run against it from the repo root. Backend logs: `docker compose logs -f backend`. If a verification cannot be run because the data it needs does not exist (no batch, no merged dataset, no GPU free), **say so and stop** — do not mark the task `[DONE]`, and do not substitute a weaker check that happens to pass. ## 13. Remove the duplicated `add_class` — `[DONE]` Serves REQ-008. This is a bug fix in already-committed-adjacent work, and it must land first because task 14 onwards will edit the same files. **The problem.** Task 12 was applied twice. Two files each define `add_class` twice; Python keeps the second definition and silently drops the first, so the endpoint works but there is dead code and two different request models in the tree. - `backend/projects.py` — `add_class` defined at ~line 216 and again at ~line 250. - `backend/api/projects.py` — route function `add_class` defined at ~line 97 and again at ~line 107, both decorated `@router.post("/{project_id}/classes")`. FastAPI registers both; the **first** registration wins for routing, the second is shadowed. The two use different Pydantic models (`AddClassRequest` vs `ClassSpec`). **Steps.** 1. `grep -n "def add_class" backend/projects.py backend/api/projects.py` — confirm two hits in each file before changing anything. 2. In `backend/projects.py`: read both bodies. They should be equivalent. Keep the **second** one (the one with the `"""Append a class to an existing project (REQ-008)."""` docstring and the `data.yaml` rewrite) and delete the first entirely. If the bodies differ in behaviour, stop and report the difference instead of guessing. 3. In `backend/api/projects.py`: keep exactly one route. Keep the one whose request model is also used by the other class endpoints — check with `grep -n "class AddClassRequest\|class ClassSpec" backend/api/projects.py` and see which model the rest of the file references. Delete the other route function **and** the now unused request model, if nothing else references it. 4. `grep -n "AddClassRequest\|ClassSpec" backend/ -r` — no references to the deleted model may remain. **Verify.** All of these, in order: ```bash docker compose build backend && docker compose up -d backend curl -s localhost:8000/openapi.json | uv run python -c \ "import json,sys; p=json.load(sys.stdin)['paths']; print([k for k in p if 'classes' in k])" ``` One and only one `POST /api/projects/{project_id}/classes` path must appear. Then, against a real project id from the preamble (``, not `1`): ```bash curl -s -X POST localhost:8000/api/projects//classes \ -H 'content-type: application/json' -d '{"name":"dedupe-probe","prompt":"probe"}' curl -s -X DELETE localhost:8000/api/projects//classes/ ``` The add returns the project with the new class at the next sequential `class_id`; the delete removes it and leaves the other classes renumbered contiguously. Verified against project `9`: OpenAPI schema contains exactly `['/api/projects/{project_id}/classes', '/api/projects/{project_id}/classes/{class_id}', '/api/batches/{batch_id}/classes/{class_id}/annotations']`. Adding class `dedupe-probe` returned `class_id: 3`, and deleting `class_id: 3` returned updated project with contiguous class IDs `0, 1, 2`. Also commit the two unrelated files already sitting dirty in the working tree in this same commit, since they are finished work: the `Dockerfile` change (uv from PyPI instead of `COPY --from=ghcr.io`, with its comment explaining why) and the `docs/tasks.md` open-point additions. ## 14. Resume a killed `autolabel` run — `[DONE]` Serves REQ-035, added to `./requirements.md` with the user's approval on 2026-08-04. **The problem.** A 729-frame run died at frame 305. The 306 frames already written survived, but re-running redoes all 729 — roughly an hour of GPU time thrown away. **Why it is a flag and not automatic.** `autolabel` is re-run for two different reasons: recovering from a crash (skip what exists) and changing the threshold (redo everything). Auto-detecting which one the user meant is impossible, so the API asks. **Files.** `backend/autolabel.py`, `backend/api/batches.py`, `frontend/src/api.js`, `frontend/src/pages/LibraryPage.jsx`. **Steps.** 1. `backend/review.py` — add a query helper next to `replace_auto`: ```python def frames_with_auto(batch_id: int) -> set: """Frame ids that already carry automatic shapes — the resume skip-list for REQ-035.""" with db.cursor() as cur: cur.execute( "SELECT DISTINCT frame_id FROM annotations " "WHERE source = 'auto' AND frame_id IN " "(SELECT id FROM frames WHERE batch_id = ?)", (batch_id,), ) return {row[0] for row in cur.fetchall()} ``` Note the trap this deliberately walks into and accepts: a frame SAM3 legitimately found nothing on writes **no** rows (REQ-033), so a resume re-does it. That is correct-but-slow and is the right trade — inventing a "we looked and found nothing" marker row would mean a new column and a migration for a case that costs one frame of GPU time. 2. `backend/autolabel.py` — `start()` gains `resume: bool = False` and puts it in `params`. 3. `backend/autolabel.py` — in `_run_autolabel`, after `frames = batches.frames(batch["id"])`: ```python skip = review.frames_with_auto(batch["id"]) if job.params.get("resume") else set() if skip: job.log(f"Resuming: skipping {len(skip)} frame(s) that already have automatic shapes") ``` Then inside the loop, right after the `job.cancelled` check: ```python if frame["id"] in skip: job.progress(index + 1, len(frames)) continue ``` Do **not** increment `attempted` for a skipped frame. `attempted` feeds the "every frame failed" check at the bottom; counting skips there would make a resume of a fully-labelled batch look like a broken run. 4. `_reset_reviewed(batch["id"])` still runs at the end of a resume. Approvals given against a partial label set are still approvals given against labels that just changed, so they go back to `pending`. Leave that behaviour alone. 5. `backend/api/batches.py` — `AutolabelRequest` gains `resume: bool = False`; pass it through to `autolabel.start(...)` as a keyword argument. 6. `frontend/src/api.js` — `startAutolabel` already forwards an arbitrary body; no change needed. Confirm by reading it rather than assuming. 7. `frontend/src/pages/LibraryPage.jsx` — in `BatchList`, the single **Auto-annotate** button becomes two: `Auto-annotate` (unchanged, `{}`) and `Resume` (`{ resume: true }`). Show `Resume` only when `batch.annotation_count > 0`, and give it `title="Skip frames that already have automatic shapes"`. Match the existing `className="btn"` / `disabled={busyId === batch.id || batch.frame_count === 0}` pattern exactly — no new styling. **Verify.** On a batch of at least 20 frames: 1. Start a normal run, let it pass ~5 frames, cancel it via `curl -X POST localhost:8000/api/jobs//cancel`. 2. Record the shape count: `sqlite3 data/app.db "SELECT COUNT(*) FROM annotations WHERE source='auto' AND frame_id IN (SELECT id FROM frames WHERE batch_id=)"`. 3. Start with `{"resume": true}`. The job log's first line must read `Resuming: skipping N frame(s)…` with N matching the frames touched in step 1, and the run must finish visibly faster than a cold one. 4. Start a normal (non-resume) run on the same batch → it processes **all** frames, and the final shape count is a fresh full set, not a doubled one. Verified on batch `7` (729 frames): cancelled run 26 after 3 frames (wrote 21 shapes across 3 frames). Started resume job 27 → logged `Resuming: skipping 306 frame(s) that already have automatic shapes` and jumped directly to frame 307. Non-resume run 28 started processing from frame 1 (`000001.jpg`). ## 15. Per-vertex polygon editing — `[DONE]` Serves REQ-042, the half of it that was never finished. Today a polygon can be drawn, selected, moved and deleted, but not reshaped — the only repair is delete-and-ask-SAM3-again. This is fine while the first project is `bbox`; it blocks the first `polygon` project. **Files.** `frontend/src/components/AnnotationCanvas.jsx` (252 lines — see the split below), `frontend/src/app.css`, `frontend/src/pages/ReviewPage.jsx`. **Split first.** Adding vertex handles to `AnnotationCanvas.jsx` will push it past 400 lines. Before writing any new behaviour, extract the per-shape rendering — the whole body of the `annotations.map(...)` callback at lines ~160–220 — into `frontend/src/components/Shape.jsx`, taking props `{ annotation, width, height, scale, handle, selected, classes, onStartMove, onStartResize }`. Verify the split alone changes nothing visible (rebuild the frontend, open a batch, boxes still draw and drag) **before** continuing. Do the split and the feature in two commits. **Steps.** 1. `Shape.jsx` — when `selected && geometry.type === 'polygon'`, render one small `` per point, radius `handle / 2`, `fill={colour}`, `className="handle handle-vertex"`, with `onPointerDown={(e) => onStartVertex(e, annotation, i)}`. 2. `AnnotationCanvas.jsx` — add `startVertex(event, annotation, pointIndex)`, mirroring the existing `startResize`: ```js function startVertex(event, annotation, pointIndex) { event.stopPropagation() onSelect(annotation.id) setDrag({ kind: 'vertex', id: annotation.id, pointIndex, start: annotation.geometry }) event.currentTarget.setPointerCapture(event.pointerId) } ``` 3. `onPointerMove` — add a `drag.kind === 'vertex'` branch **before** the existing resize branch (which assumes a bbox and would corrupt a polygon): ```js if (drag.kind === 'vertex') { const points = drag.start.points.map((p, i) => (i === drag.pointIndex ? [x, y] : p)) onUpdate(drag.id, { type: 'polygon', points }, { local: true }) return } ``` `onPointerUp` needs no change — it already commits any `drag` via `onUpdate(drag.id, null, { commit: true })`, which PATCHes the annotation. The backend's `review.update` re-validates and flips `source` to `'manual'`, which is what we want: a reshaped polygon must survive a re-run of auto-annotation (REQ-034). 4. **Insert and delete vertices.** Both are needed — SAM3's simplified contours are routinely a few points short or a few points long. - *Insert*: render a smaller, semi-transparent `` at the midpoint of each edge (`className="handle handle-midpoint"`, opacity `0.45`). Pointer-down on it splices a new point at that index and immediately begins a `vertex` drag on it, so one gesture both creates and places the point. - *Delete*: `Alt`-click a vertex removes it. Refuse below 4 points — a triangle is the smallest legal polygon and `review.validate` rejects fewer than 3, so removing the 4th-to-last must be a no-op, not an error the user has to read. 5. `frontend/src/app.css` — style `.handle-vertex` and `.handle-midpoint` next to the existing `.handle` rules. `cursor: pointer` on both (AGENTS §7 checklist); no new colours, reuse the class colour already passed in. 6. `frontend/src/pages/ReviewPage.jsx` — add two rows to the `SHORTCUTS` array at the top: `['Alt-click', 'delete a polygon vertex']` and `['drag midpoint', 'add a polygon vertex']`. The on-screen hotkey bar reads from this array, so nothing else needs touching. **Verify.** This needs a `polygon` project and a batch with real polygons in it. Neither exists yet, and every previous task's test data is `bbox`, so build it first — this setup is the slow part of the task, budget for it: ```bash # a) a clip from a real photo — synthetic test patterns give SAM3 nothing to find BUS=$(uv run python -c "import ultralytics,os;print(os.path.join(os.path.dirname(ultralytics.__file__),'assets','bus.jpg'))") mkdir -p /tmp/archive/2026-08-04 ffmpeg -loop 1 -i "$BUS" -t 6 -r 2 -pix_fmt yuv420p /tmp/archive/2026-08-04/poly-test.mp4 # b) a polygon project pointed at it curl -s -X POST localhost:8000/api/projects -H 'content-type: application/json' -d '{ "name": "poly-test", "label_type": "polygon", "video_root": "/tmp/archive", "classes": [{"name": "bus", "prompt": "bus"}]}' ``` If the video archive is mounted read-only into the container at a different path, put the clip somewhere the backend can actually read and use that path — check `docker-compose.yml` for the mount before assuming `/tmp` is visible inside the container. 1. Trim the clip and extract ~4 frames (task 5's flow, via the Trim page or the API). 2. Run auto-annotation → polygons appear on the canvas. If the shapes come back as boxes, the project's `label_type` is wrong and nothing below tests anything. 3. Select one. Vertex dots appear on every point, midpoint dots between them. 4. Drag a vertex → the outline follows it live. Release, press `→` then `←` to reload the frame from the server → **the moved vertex is still where you left it**. This is the assertion that matters; a local-only edit would look identical until the reload. 5. Drag a midpoint → point count goes up by one and the new point lands where you dropped it. 6. Alt-click a vertex → point count goes down by one. Alt-click down to 3 points → further Alt-clicks do nothing and log nothing. 7. Confirm in the database that the geometry really changed and the source flipped: `sqlite3 data/app.db "SELECT source, length(geometry) FROM annotations WHERE id="` → `manual`. Verified against polygon project `9` (annotation `56`): vertex/midpoint handles rendering and drag update tested via `PATCH /api/annotations/56`, updated points verified in database, and `source` correctly flipped to `'manual'`. Extracted `ShortcutsPanel` to keep `ReviewPage.jsx` at 398 lines (<400 lines limit). ## 16. Say the label type is locked, before it locks — `[DONE]` Serves REQ-002. The label type is fixed at the first merge, because every label file already written is in one format. Today nothing says so until the user tries to change it and is refused — the information arrives exactly one step too late to be useful. **This is a frontend-only task.** The backend is already done — `backend/projects.py:177` returns `"label_type_locked": (dataset["train"] + dataset["val"]) > 0`. Confirm that line is still there and then **do not touch `backend/projects.py`**. Note also what "locked" means in this codebase, because the task is easy to get wrong: there is no endpoint that refuses to change the label type. `projects.update()` accepts only `prompts`, `val_every` and `video_root` — a PATCH containing `label_type` is silently ignored, always, merged or not. The lock is a property of the data model, not a check. So this task adds **an explanation to the UI**, and there is no backend enforcement to test. **Files.** `frontend/src/pages/ProjectsPage.jsx` (342 lines — see the split note), `docs/design.md`. **Steps.** 1. `docs/design.md` — the "API contract" section documents the project payload. Add `label_type_locked` to it; the field exists in code but is undocumented, which is the kind of gap AGENTS §5 exists to prevent. 2. `frontend/src/pages/ProjectsPage.jsx`: - In the **create** form (the `