diff --git a/.gsd/DECISIONS.md b/.gsd/DECISIONS.md index 1dd1054..749d7d2 100644 --- a/.gsd/DECISIONS.md +++ b/.gsd/DECISIONS.md @@ -15,3 +15,4 @@ | D007 | | architecture | Canvas object type system design | Discriminated union on `type` field: 'rect' \| 'circle' \| 'ellipse' \| 'line' \| 'image'. All objects share BaseCanvasObject (id, name, x, y, visible, locked, stroke, fill, opacity). Type-specific fields via intersection types. | TypeScript discriminated unions enable exhaustive type narrowing in switch/if statements, catching missing cases at compile time. Shared base keeps CRUD operations generic while type-specific panels (ShapeProperties) safely narrow to access unique fields. 'text' type will be added in S03. | Yes | agent | | D008 | | frontend | opentype.js integration strategy for font loading and text-to-path conversion | opentype.js v1.3.4 with dynamic import(), local type declarations, per-character glyph positioning for letter spacing, and Y-axis flip (canvas_y = ascender - font_y * scale) for canvas-compatible SVG path coordinates | opentype.js has no @types package, so local declarations are needed. The library's getPath() doesn't support letter spacing natively — manual per-character positioning with x-advance accumulation is required. Font coordinate system is Y-up while canvas is Y-down, requiring the ascender-based flip formula. Dynamic import() avoids bundling issues with the library's CommonJS/ESM dual packaging. | Yes | agent | | D009 | | frontend | Text-to-paths conversion strategy | Convert-to-paths uses a single onConvertToPath(textObjectId, imageObject) callback that generates an SVG Blob URL and creates an ImageObject replacement, reusing the existing image rendering pipeline | Reusing the ImageObject type and its existing Konva rendering avoids adding a new 'path' object type. A single callback keeps the prop interface simpler than separate onAddObject + onRemoveObject and makes the replacement atomic. The SVG Blob URL contains the path data with fill/stroke matching the original text object. | Yes | agent | +| D010 | | architecture | Embed mode delivery strategy for Web Component | Separate Vite library mode build (vite.embed.config.ts) producing self-contained ES+IIFE bundle in dist-embed/. Shadow DOM for style isolation. setEngineBaseUrl() module-level setter for API URL configuration (not React context). @font-face injected into document.head from connectedCallback(). :root rewritten to :host in shadow root styles. | Vite library mode is the most natural fit for the existing Vite-based build. Shadow DOM provides real CSS isolation without iframe overhead. Module-level setter for engine URL avoids threading a React context through every API-calling component (simpler than context approach with minimal app code changes). @font-face must be in document scope per browser spec — Shadow DOM @font-face doesn't trigger font downloads. :host is more reliable than :root inside shadow trees across browsers. | Yes | agent | diff --git a/.gsd/PROJECT.md b/.gsd/PROJECT.md index 8810f20..424daa6 100644 --- a/.gsd/PROJECT.md +++ b/.gsd/PROJECT.md @@ -24,7 +24,10 @@ Built the complete React frontend: - App: Built ExportView with DXF/SVG/PNG format selector, validation panel, unit selector, download wiring - **120 app tests + 36 engine output tests, zero TypeScript errors** -**S02: Docker Packaging + README — ⬜ Queued** +**S02: Docker Packaging + README — ✅ Complete** +- Docker: Multi-stage Dockerfile.app (node→nginx), nginx.conf (SPA + /engine proxy), docker-compose.yml with healthchecks +- README: 253-line comprehensive docs with API reference (4 endpoints), preset table, quick start, dev setup +- Both containers start healthy, engine runs independently, nginx proxies /engine/* correctly **S03: Embed Mode — ⬜ Queued** diff --git a/.gsd/event-log.jsonl b/.gsd/event-log.jsonl index 864907d..dafb7fe 100644 --- a/.gsd/event-log.jsonl +++ b/.gsd/event-log.jsonl @@ -39,3 +39,6 @@ {"cmd":"plan-slice","params":{"milestoneId":"M003","sliceId":"S02"},"ts":"2026-03-26T06:39:00.941Z","actor":"agent","hash":"24790b4f3bd69e22","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"} {"cmd":"complete-task","params":{"milestoneId":"M003","sliceId":"S02","taskId":"T01"},"ts":"2026-03-26T06:43:57.680Z","actor":"agent","hash":"bd4dc62bfd702053","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"} {"cmd":"complete-task","params":{"milestoneId":"M003","sliceId":"S02","taskId":"T02"},"ts":"2026-03-26T06:46:18.215Z","actor":"agent","hash":"6adfe7bebac07a1a","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"} +{"cmd":"complete-slice","params":{"milestoneId":"M003","sliceId":"S02"},"ts":"2026-03-26T06:48:34.805Z","actor":"agent","hash":"b305679ef7f0a27b","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"} +{"cmd":"plan-slice","params":{"milestoneId":"M003","sliceId":"S03"},"ts":"2026-03-26T06:54:15.756Z","actor":"agent","hash":"eaf42bd487467df8","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"} +{"cmd":"complete-task","params":{"milestoneId":"M003","sliceId":"S03","taskId":"T01"},"ts":"2026-03-26T06:59:26.868Z","actor":"agent","hash":"a47a8fb4b4e7bb97","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"} diff --git a/.gsd/milestones/M003/M003-ROADMAP.md b/.gsd/milestones/M003/M003-ROADMAP.md index 37e2900..cb2202f 100644 --- a/.gsd/milestones/M003/M003-ROADMAP.md +++ b/.gsd/milestones/M003/M003-ROADMAP.md @@ -7,5 +7,5 @@ Complete the Kerf App with the Export view (View 3), Docker Compose packaging fo | ID | Slice | Risk | Depends | Done | After this | |----|-------|------|---------|------|------------| | S01 | Export Flow (View 3) + DXF Generation | high — dxf scale accuracy and geometry quality | — | ✅ | Design a sign with text + imported vector, export as DXF, open in Inkscape/LightBurn with correct geometry and scale | -| S02 | Docker Packaging + README | low — docker packaging is well-understood pattern | S01 | ⬜ | docker-compose up starts all services; Engine container runs independently; healthchecks pass | +| S02 | Docker Packaging + README | low — docker packaging is well-understood pattern | S01 | ✅ | docker-compose up starts all services; Engine container runs independently; healthchecks pass | | S03 | Embed Mode | medium — shadow dom + konva.js + font loading interactions | S02 | ⬜ | Embed snippet in plain HTML page; component renders; styles don't bleed; download works from embedded context | diff --git a/.gsd/milestones/M003/slices/S02/S02-SUMMARY.md b/.gsd/milestones/M003/slices/S02/S02-SUMMARY.md new file mode 100644 index 0000000..3050b0b --- /dev/null +++ b/.gsd/milestones/M003/slices/S02/S02-SUMMARY.md @@ -0,0 +1,88 @@ +--- +id: S02 +parent: M003 +milestone: M003 +provides: + - Docker Compose full-stack packaging with healthchecks + - nginx reverse-proxy configuration for SPA + engine API + - Comprehensive README with API reference and quick start +requires: + - slice: S01 + provides: Export Flow (View 3) — complete app with all 3 views for Docker packaging +affects: + - S03 +key_files: + - docker/Dockerfile.app + - docker/nginx.conf + - docker-compose.yml + - README.md +key_decisions: + - Used npm workspace root install (npm ci --workspace=app) for proper lockfile resolution in multi-stage Docker build + - Used wget with 127.0.0.1 for nginx:alpine healthcheck (curl unavailable, localhost resolves to IPv6) + - Set proxy_read_timeout 120s and client_max_body_size 50m for large image processing through nginx proxy + - Documented all four engine endpoints with full parameter tables and curl examples in README +patterns_established: + - docker compose up starts full stack with automatic health-gated service ordering (app waits for engine healthy) + - nginx reverse-proxy pattern: SPA try_files + /engine/* proxy_pass to backend container by service name +observability_surfaces: + - Engine healthcheck at /engine/health (direct :8000 and proxied :3000) + - Docker HEALTHCHECK on both containers — docker compose ps shows health status +drill_down_paths: + - .gsd/milestones/M003/slices/S02/tasks/T01-SUMMARY.md + - .gsd/milestones/M003/slices/S02/tasks/T02-SUMMARY.md +duration: "" +verification_result: passed +completed_at: 2026-03-26T06:48:34.767Z +blocker_discovered: false +--- + +# S02: Docker Packaging + README + +**Full-stack Docker Compose packaging (engine + app behind nginx reverse-proxy) with healthchecks, plus comprehensive 8-section README with API reference, preset table, and curl examples** + +## What Happened + +This slice packaged the complete Kerf stack into a single `docker compose up` experience and replaced the placeholder README with comprehensive documentation. + +**T01 — Docker Infrastructure:** Created three files: `docker/Dockerfile.app` (multi-stage node:22-alpine build → nginx:1.27-alpine runtime), `docker/nginx.conf` (SPA routing with try_files, /engine/* reverse proxy to kerf-engine:8000, 120s proxy timeouts, 50m upload limit, gzip compression), and `docker-compose.yml` (two services with healthchecks, app depends_on engine healthy). The app Dockerfile uses npm workspace-aware install (`npm ci --workspace=app`) to properly resolve the root lockfile. A notable fix was using `wget -qO- http://127.0.0.1:80/` for the nginx healthcheck — Alpine containers resolve `localhost` to IPv6 `::1` while nginx listens on IPv4 only. Both containers reach `(healthy)` status within ~15 seconds. + +**T02 — README Documentation:** Wrote a 253-line README covering: project description, Docker Compose quick start, repository structure tree, engine API reference (all 4 endpoints — /engine/health, /engine/presets, /engine/trace, /engine/simplify — with full parameter tables and curl examples), preset comparison table (all 5 presets with mode, epsilon, use case), font system, standalone engine usage, development setup for both engine and app, and known limitations. + +## Verification + +**Build verification:** `docker compose build` succeeded for both images (kerf-engine, kerf-app). **Runtime verification:** `docker compose up -d` started both containers; `docker compose ps` showed both `(healthy)` within 20s. **Endpoint verification:** `curl -sf http://localhost:8000/engine/health` → `{"status":"ok"}` (engine direct), `curl -sf http://localhost:3000/` → HTML doctype (app serves SPA), `curl -sf http://localhost:3000/engine/health` → `{"status":"ok"}` (proxied through nginx), `curl -sf http://localhost:3000/engine/presets` → preset JSON (proxied API). **Clean teardown:** `docker compose down` removed all containers and network. **README verification:** 8 H2 sections (≥6 required), all key terms present (docker compose up, /engine/trace, /engine/simplify, /engine/presets, /engine/health). + +## Requirements Advanced + +None. + +## Requirements Validated + +None. + +## New Requirements Surfaced + +None. + +## Requirements Invalidated or Re-scoped + +None. + +## Deviations + +Changed healthcheck URLs from `localhost` to `127.0.0.1` in Dockerfile.app and docker-compose.yml to fix IPv6 resolution failure in nginx:alpine containers (L013). + +## Known Limitations + +None. Docker packaging and README are complete as specified. + +## Follow-ups + +None. + +## Files Created/Modified + +- `docker/Dockerfile.app` — Multi-stage Dockerfile: node:22-alpine build stage with npm workspace install, nginx:1.27-alpine runtime with wget healthcheck +- `docker/nginx.conf` — nginx config: SPA try_files routing, /engine/* reverse proxy to kerf-engine:8000, 120s timeouts, 50m upload limit, gzip +- `docker-compose.yml` — Two-service compose: kerf-engine (port 8000) + kerf-app (port 3000→80), healthchecks, depends_on healthy +- `README.md` — 253-line comprehensive README: quick start, repo structure, 4-endpoint API reference, preset table, font system, dev setup, limitations diff --git a/.gsd/milestones/M003/slices/S02/S02-UAT.md b/.gsd/milestones/M003/slices/S02/S02-UAT.md new file mode 100644 index 0000000..2074a55 --- /dev/null +++ b/.gsd/milestones/M003/slices/S02/S02-UAT.md @@ -0,0 +1,114 @@ +# S02: Docker Packaging + README — UAT + +**Milestone:** M003 +**Written:** 2026-03-26T06:48:34.767Z + +# S02: Docker Packaging + README — UAT + +**Milestone:** M003 +**Written:** 2026-03-26 + +## UAT Type + +- UAT mode: live-runtime +- Why this mode is sufficient: Docker packaging requires verifying actual container builds, healthchecks, and network connectivity — artifacts alone cannot prove this. + +## Preconditions + +- Docker Engine running on the host +- Ports 3000 and 8000 available (not bound by other processes) +- Project repository cloned with all source files present + +## Smoke Test + +```bash +docker compose build && docker compose up -d && sleep 20 && docker compose ps | grep -c "(healthy)" | xargs test 2 -eq && echo "SMOKE OK" && docker compose down +``` + +Both containers should show `(healthy)` status. + +## Test Cases + +### 1. Full Stack Build and Start + +1. Run `docker compose build` from project root +2. Run `docker compose up -d` +3. Wait 20 seconds for healthchecks to converge +4. Run `docker compose ps` +5. **Expected:** Both `kerf-engine` and `kerf-app` services show `(healthy)` status. Engine on port 8000, app on port 3000. + +### 2. Engine Direct Access + +1. With stack running, run `curl -sf http://localhost:8000/engine/health` +2. **Expected:** `{"status":"ok"}` +3. Run `curl -sf http://localhost:8000/engine/presets` +4. **Expected:** JSON with `presets` key containing `sign`, `patch`, `stencil`, `detailed`, `custom` + +### 3. App Serves SPA + +1. Run `curl -sf http://localhost:3000/` +2. **Expected:** HTML response starting with `` containing the React app +3. Run `curl -sf http://localhost:3000/nonexistent-route` +4. **Expected:** Same HTML response (SPA try_files fallback — nginx serves index.html for unknown routes) + +### 4. Nginx Proxies Engine API + +1. Run `curl -sf http://localhost:3000/engine/health` +2. **Expected:** `{"status":"ok"}` (proxied to engine container) +3. Run `curl -sf http://localhost:3000/engine/presets` +4. **Expected:** Same preset JSON as direct engine access + +### 5. Engine Runs Independently + +1. Run `docker compose down` +2. Run `docker compose up -d kerf-engine` +3. Wait 10 seconds +4. Run `curl -sf http://localhost:8000/engine/health` +5. **Expected:** `{"status":"ok"}` — engine works without the app container +6. Run `docker compose down` + +### 6. README Documentation Quality + +1. Open `README.md` +2. Verify it contains at least 6 H2 sections (`## `) +3. Verify it documents `docker compose up` quick start +4. Verify it documents all 4 endpoints: `/engine/health`, `/engine/presets`, `/engine/trace`, `/engine/simplify` +5. Verify each endpoint has parameter tables and curl examples +6. Verify the preset comparison table lists all 5 presets +7. **Expected:** All documentation sections present with accurate content matching the actual API + +## Edge Cases + +### Large File Upload Through Proxy + +1. Create a large test image (~10MB): `dd if=/dev/urandom bs=1M count=10 | convert - test-large.png` (or use any large PNG) +2. Run `curl -sf -X POST -F "file=@test-large.png" http://localhost:3000/engine/trace` +3. **Expected:** Request accepted (not rejected by nginx) — `client_max_body_size` is set to 50m + +### Service Recovery After Engine Restart + +1. With full stack running, run `docker compose restart kerf-engine` +2. Wait 15 seconds for healthcheck +3. Run `curl -sf http://localhost:3000/engine/health` +4. **Expected:** `{"status":"ok"}` — nginx reconnects to restarted engine + +## Failure Signals + +- `docker compose ps` shows `(unhealthy)` or `(starting)` after 30 seconds +- `curl` to port 3000 returns connection refused or 502 Bad Gateway +- `curl` to port 8000 returns connection refused +- Proxy requests to `/engine/*` via port 3000 return 502 or 504 +- README missing key sections or documenting wrong endpoints + +## Not Proven By This UAT + +- Actual image vectorization through the full proxy pipeline (that's S01's concern) +- Browser-based UI interaction through Docker (manual testing) +- Production deployment to a real host with TLS +- Embed mode functionality (S03) + +## Notes for Tester + +- Alpine-based nginx uses wget, not curl, for healthchecks. If you see healthcheck failures, verify the container has wget available. +- The IPv6 localhost issue (L013) is already fixed — healthchecks use 127.0.0.1 explicitly. +- Engine container takes ~5-8s to start, app waits for engine health before starting — total startup is ~15-20s. diff --git a/.gsd/milestones/M003/slices/S02/tasks/T02-VERIFY.json b/.gsd/milestones/M003/slices/S02/tasks/T02-VERIFY.json new file mode 100644 index 0000000..34d7850 --- /dev/null +++ b/.gsd/milestones/M003/slices/S02/tasks/T02-VERIFY.json @@ -0,0 +1,46 @@ +{ + "schemaVersion": 1, + "taskId": "T02", + "unitId": "M003/S02/T02", + "timestamp": 1774507580150, + "passed": true, + "discoverySource": "task-plan", + "checks": [ + { + "command": "grep -q 'docker compose up' README.md", + "exitCode": 0, + "durationMs": 7, + "verdict": "pass" + }, + { + "command": "grep -q '/engine/trace' README.md", + "exitCode": 0, + "durationMs": 6, + "verdict": "pass" + }, + { + "command": "grep -q '/engine/simplify' README.md", + "exitCode": 0, + "durationMs": 8, + "verdict": "pass" + }, + { + "command": "grep -q '/engine/presets' README.md", + "exitCode": 0, + "durationMs": 8, + "verdict": "pass" + }, + { + "command": "grep -q '/engine/health' README.md", + "exitCode": 0, + "durationMs": 5, + "verdict": "pass" + }, + { + "command": "echo 'README OK'", + "exitCode": 0, + "durationMs": 3, + "verdict": "pass" + } + ] +} diff --git a/.gsd/milestones/M003/slices/S03/S03-PLAN.md b/.gsd/milestones/M003/slices/S03/S03-PLAN.md index 0226f1a..9106011 100644 --- a/.gsd/milestones/M003/slices/S03/S03-PLAN.md +++ b/.gsd/milestones/M003/slices/S03/S03-PLAN.md @@ -1,6 +1,34 @@ # S03: Embed Mode -**Goal:** Build embed mode: bundled JS + Shadow DOM scoped styles + postMessage event communication with host page +**Goal:** Package the Kerf App as a self-contained `` Web Component that renders all 3 views inside Shadow DOM with full style isolation, configurable engine URL, and a working demo page. **Demo:** After this: Embed snippet in plain HTML page; component renders; styles don't bleed; download works from embedded context ## Tasks +- [x] **T01: Added setEngineBaseUrl() to engine API client, created Web Component with Shadow DOM CSS injection, and configured Vite library-mode build producing self-contained ES + IIFE bundles** — This task implements the core embed mode infrastructure: (1) add `setEngineBaseUrl()` to the API client so the embed can point API calls at a configurable engine origin, (2) create `embed.tsx` which defines the `` custom element with Shadow DOM, CSS injection, and React mounting, and (3) create `vite.embed.config.ts` for Vite library mode builds. Also update `tsconfig.node.json` to include the new vite config. + +The embed entry must: +- Define a `KerfEmbed` class extending `HTMLElement` +- In `connectedCallback()`: attach open shadow DOM, inject bundled CSS into shadow root as `