feat: Added setEngineBaseUrl() to engine API client, created <kerf-embe…
- "app/src/api/engine.ts" - "app/src/embed.tsx" - "app/vite.embed.config.ts" - "app/tsconfig.node.json" GSD-Task: S03/T01
This commit is contained in:
parent
c1d4001e9a
commit
e20de2166c
25 changed files with 51815 additions and 19 deletions
|
|
@ -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 <kerf-embed> 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 |
|
||||
|
|
|
|||
|
|
@ -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**
|
||||
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
88
.gsd/milestones/M003/slices/S02/S02-SUMMARY.md
Normal file
88
.gsd/milestones/M003/slices/S02/S02-SUMMARY.md
Normal file
|
|
@ -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
|
||||
114
.gsd/milestones/M003/slices/S02/S02-UAT.md
Normal file
114
.gsd/milestones/M003/slices/S02/S02-UAT.md
Normal file
|
|
@ -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 `<!doctype html>` 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.
|
||||
46
.gsd/milestones/M003/slices/S02/tasks/T02-VERIFY.json
Normal file
46
.gsd/milestones/M003/slices/S02/tasks/T02-VERIFY.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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 `<kerf-embed>` 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 <kerf-embed> 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 `<kerf-embed>` 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 `<style>`, inject `@font-face` rules into `document.head`, create mount div, call `createRoot(mountDiv).render(<App />)`
|
||||
- Rewrite `:root` to `:host` when injecting `index.css` content into shadow root
|
||||
- Read `engine-url` attribute and call `setEngineBaseUrl()` before rendering
|
||||
- Register via `customElements.define('kerf-embed', KerfEmbed)`
|
||||
|
||||
The Vite library config must:
|
||||
- Entry: `src/embed.tsx`
|
||||
- Output dir: `dist-embed/`
|
||||
- Library mode with formats `['es', 'iife']`, name `KerfEmbed`
|
||||
- CSS extracted (not inlined)
|
||||
- Nothing externalized — React, Konva, opentype.js all bundled
|
||||
- Estimate: 1h30m
|
||||
- Files: app/src/api/engine.ts, app/src/embed.tsx, app/vite.embed.config.ts, app/tsconfig.node.json
|
||||
- Verify: cd app && npx vite build --config vite.embed.config.ts && test -f dist-embed/kerf-embed.js && test -f dist-embed/style.css && echo 'Build OK'
|
||||
- [ ] **T02: Add demo page, unit tests for API configurability, and verify full build** — Create the embed demo HTML page and unit tests, then run full verification.
|
||||
|
||||
1. Create `examples/embed-demo.html` — a plain HTML page that loads the embed bundle via `<script>` tag and uses `<kerf-embed engine-url="http://localhost:8000">`. The page must have its own competing styles (Comic Sans font, bright background, custom colors) to demonstrate style isolation works.
|
||||
|
||||
2. Add/update unit tests for the `setEngineBaseUrl` functionality in `app/src/api/__tests__/engine.test.ts` — verify that after calling `setEngineBaseUrl('http://example.com/api')`, fetch calls use the new base URL, and that it can be reset.
|
||||
|
||||
3. Run full verification: `npx vitest run` (all tests pass), `npx tsc -b --noEmit` (TypeScript clean), embed build succeeds.
|
||||
- Estimate: 45m
|
||||
- Files: examples/embed-demo.html, app/src/api/__tests__/engine.test.ts
|
||||
- Verify: npx tsc -b --noEmit && npx vitest run && test -f examples/embed-demo.html && echo 'All checks pass'
|
||||
|
|
|
|||
104
.gsd/milestones/M003/slices/S03/S03-RESEARCH.md
Normal file
104
.gsd/milestones/M003/slices/S03/S03-RESEARCH.md
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
# S03 — Embed Mode — Research
|
||||
|
||||
**Date:** 2026-03-26
|
||||
|
||||
## Summary
|
||||
|
||||
The Embed Mode slice packages the Kerf App as a self-contained Web Component (`<kerf-embed>`) that third-party sites can drop into any HTML page. The full app (all 3 views: Import → Design → Export) needs to render inside a Shadow DOM boundary so styles don't bleed in either direction. The embed is delivered as a single JS bundle + a CSS file, loadable via a `<script>` tag.
|
||||
|
||||
The technical risk centers on three interactions: (1) Shadow DOM + Konva.js canvas rendering, (2) Shadow DOM + @font-face loading, and (3) file download (`triggerDownload`) from an embedded context. Research confirms all three are solvable:
|
||||
|
||||
- **Konva + Shadow DOM:** Konva's Stage creates its canvas via `document.createElement('canvas')` and appends it to whatever container DOM element react-konva passes via a `useRef`. It does NOT query the global DOM (no `getElementById` lookups at runtime). As long as React renders into the shadow root, the canvas will render correctly inside it.
|
||||
- **Fonts in Shadow DOM:** `@font-face` rules defined inside Shadow DOM don't reliably load fonts. The established workaround is to inject `@font-face` declarations into the main document's `<head>` from the web component's `connectedCallback()`. Since Kerf uses opentype.js for text-to-path (fetching .ttf via `fetch()`), the font rendering pipeline bypasses CSS @font-face entirely — only the UI labels need the CSS font declarations.
|
||||
- **Downloads from embed:** `triggerDownload()` uses `document.body.appendChild(a)` to trigger file downloads. This works from any JS context regardless of Shadow DOM — `document.body` is always accessible.
|
||||
|
||||
## Recommendation
|
||||
|
||||
**Use Vite library mode** to produce a single-entry bundle (`kerf-embed.js` + `kerf-embed.css`) that registers a `<kerf-embed>` custom element. The custom element uses open Shadow DOM for style encapsulation, renders React into the shadow root via `createRoot(shadowRoot)`, and injects the bundled CSS into the shadow root as a `<style>` tag. A separate Vite config file (`vite.embed.config.ts`) handles the library build, externalizing nothing (React, Konva, opentype.js are all bundled — the embed must be self-contained).
|
||||
|
||||
**Do NOT use Shadow DOM for the primary app build.** The embed is an additional build target. The existing `vite.config.ts` and `main.tsx` remain unchanged for the standalone app.
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
app/
|
||||
src/
|
||||
main.tsx ← existing app entry (unchanged)
|
||||
embed.tsx ← NEW: custom element definition + shadow DOM mount
|
||||
vite.embed.config.ts ← NEW: library mode build config
|
||||
dist-embed/ ← NEW: build output directory
|
||||
```
|
||||
|
||||
The `embed.tsx` entry:
|
||||
1. Defines a `KerfEmbed` class extending `HTMLElement`
|
||||
2. In `connectedCallback()`: attaches open shadow DOM, injects CSS `<style>`, injects `@font-face` rules into `document.head`, creates a mount div inside shadow root, calls `createRoot(mountDiv).render(<App />)`
|
||||
3. Reads optional attributes: `engine-url` (base URL for engine API — defaults to relative `/engine`), `width`, `height`
|
||||
4. Registers via `customElements.define('kerf-embed', KerfEmbed)`
|
||||
|
||||
### Engine API URL
|
||||
|
||||
The embed's API client (`app/src/api/engine.ts`) currently uses a hardcoded `const BASE = '/engine'` prefix. For embed mode, the engine URL must be configurable since the embed may be hosted on a different domain than the engine. The solution is to make `BASE` settable — either via a module-level setter or by reading from a React context that the embed entry provides. A context-based approach is cleaner: `<EngineUrlProvider value={engineUrl}>` wrapping `<App />` in `embed.tsx`, with the API client reading from context.
|
||||
|
||||
However, for simplicity and minimal app code changes, the simpler approach is to make the API module export a `setBaseUrl(url)` function and call it before rendering. This avoids threading a context through every component that calls the API.
|
||||
|
||||
## Implementation Landscape
|
||||
|
||||
### Key Files
|
||||
|
||||
- `app/src/embed.tsx` — **NEW.** Custom element class, shadow DOM setup, React mount, @font-face injection into document head, engine URL configuration.
|
||||
- `app/vite.embed.config.ts` — **NEW.** Vite library mode config: entry `src/embed.tsx`, output to `dist-embed/`, formats `['es', 'iife']` (ES module + IIFE for `<script>` tag), CSS extracted alongside. React, Konva, opentype.js are NOT externalized — everything bundled.
|
||||
- `app/src/api/engine.ts` — **MODIFY.** Add `let _base = '/engine'` + `export function setEngineBaseUrl(url: string)` so embed can override the API base URL. Change `const BASE = '/engine'` to `function getBase() { return _base; }`.
|
||||
- `app/src/utils/exportService.ts` — **VERIFY.** `triggerDownload()` uses `document.body` — works fine from Shadow DOM context. No changes needed.
|
||||
- `app/src/App.css` — **VERIFY.** Contains `@font-face` declarations and all global component styles. In embed mode, these get injected as a `<style>` tag inside the shadow root. The `@font-face` rules must additionally be injected into `document.head` for browser font loading to work.
|
||||
- `app/src/index.css` — **VERIFY.** Contains CSS custom properties (`:root { --text: ... }`). In embed mode, these must be scoped to the shadow host element (`:host { --text: ... }`) or injected as-is inside the shadow root (`:root` applies to shadow root context too — but only in some browsers). Safest approach: the embed entry rewrites `:root` to `:host` when injecting styles.
|
||||
- `examples/embed-demo.html` — **NEW.** A plain HTML page that loads the embed bundle via `<script>` and uses `<kerf-embed engine-url="http://localhost:8000">` to demonstrate the component working in isolation. This is the primary verification artifact.
|
||||
|
||||
### Build Order
|
||||
|
||||
1. **Engine URL configurability** (modify `api/engine.ts`) — Smallest change, unblocks everything. Without this, the embed can't talk to an engine on a different host.
|
||||
2. **Embed entry + Vite config** (create `embed.tsx` + `vite.embed.config.ts`) — Core work. Shadow DOM setup, React mount, CSS injection, @font-face injection. Build and verify bundle produces a single JS + CSS file.
|
||||
3. **Demo page + verification** (create `examples/embed-demo.html`) — Integration test. Load the bundle in a plain HTML page with its own styles, verify: component renders, host styles don't leak in, embed styles don't leak out, full flow works (upload → design → export → download).
|
||||
|
||||
### Verification Approach
|
||||
|
||||
1. **Build succeeds:** `npx vite build --config vite.embed.config.ts` produces `dist-embed/kerf-embed.js` + `dist-embed/kerf-embed.css` (or inlined).
|
||||
2. **Style isolation test:** Open `examples/embed-demo.html` in browser. The host page has its own font, colors, and layout. Verify:
|
||||
- Kerf UI renders with its own styles (purple accent, etc.)
|
||||
- Host page styles are unaffected by Kerf CSS
|
||||
- Kerf UI is unaffected by host page's `body { font-family: Comic Sans }` or similar
|
||||
3. **Functional test:** In the embed, complete the full flow: upload image → trace → design on canvas → export DXF/SVG/PNG → file downloads successfully.
|
||||
4. **Engine connectivity:** The `engine-url` attribute correctly routes API calls to the specified engine URL.
|
||||
5. **TypeScript:** `npx tsc -b --noEmit` passes with the new files.
|
||||
6. **Existing tests pass:** `npx vitest run` — no regressions in existing app tests.
|
||||
|
||||
## Constraints
|
||||
|
||||
- **Konva uses `document.createElement('canvas')` internally.** This is fine — `document` is always accessible from Shadow DOM code. The canvas element gets appended to the container div which is inside the shadow root.
|
||||
- **CSS custom properties (`:root`)** must be rewritten to `:host` for proper scoping inside Shadow DOM. `:root` inside a shadow tree refers to the shadow root in modern browsers, but `:host` is more explicit and reliable.
|
||||
- **The embed bundle must be self-contained.** Cannot externalize React/Konva/opentype since the host page may not have them. This means the bundle will be large (~500-800KB minified, ~150-250KB gzipped). Acceptable for an embed widget.
|
||||
- **`@font-face` must be in document scope.** Font declarations inside Shadow DOM won't trigger font downloads. The web component must inject them into `document.head`.
|
||||
- **CORS for engine API.** When embedded on a different origin, the engine API must support CORS. The engine (FastAPI) may need a CORS middleware addition. However, this is an operational concern — the embed itself just needs to use absolute URLs. Document this as a deployment requirement.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- **@font-face inside Shadow DOM doesn't load fonts** — The component must inject @font-face into `document.head`, not just into the shadow root's `<style>`. The font-family names reference inside shadow styles will match the globally loaded font. Kerf's text rendering uses opentype.js `fetch()` for path conversion (bypassing CSS fonts), but UI elements still need CSS fonts for labels.
|
||||
- **`:root` CSS variables may not cascade into Shadow DOM** — Some browsers treat `:root` inside shadow trees differently. Rewrite `:root { ... }` → `:host { ... }` when injecting `index.css` content into the shadow root.
|
||||
- **React 19 and Shadow DOM** — `createRoot` works fine on shadow DOM containers. However, React event delegation attaches to the root container, which in this case is inside the shadow root — this is correct behavior and events will work normally.
|
||||
- **`triggerDownload` uses `document.body`** — This works from Shadow DOM context since `document` is globally accessible. No pitfall here, just worth verifying.
|
||||
|
||||
## Open Risks
|
||||
|
||||
- **CORS configuration on engine API.** FastAPI engine has no CORS middleware currently. When the embed is served from a different origin than the engine, API calls will fail due to CORS. The embed slice should document this requirement and optionally add CORS headers to the engine. However, this may be out of scope for the slice — a `cors_origins` environment variable on the engine would be the pattern.
|
||||
- **Bundle size.** The self-contained bundle (React + Konva + opentype.js + app code + CSS) will be substantial. No mitigation needed for V1, but worth noting for future optimization (code splitting, lazy-loading views).
|
||||
|
||||
## Skills Discovered
|
||||
|
||||
| Technology | Skill | Status |
|
||||
|------------|-------|--------|
|
||||
| Web Components | wshobson/agents@web-component-design | available (4.5K installs) — potentially useful for Shadow DOM patterns |
|
||||
|
||||
## Sources
|
||||
|
||||
- @font-face doesn't work in Shadow DOM — inject into document.head instead (source: [Rob Dodson](https://robdodson.me/posts/at-font-face-doesnt-work-in-shadow-dom/))
|
||||
- Embeddable web apps with Shadow DOM — inject font links to head, use React inside shadow root (source: [Viget](https://www.viget.com/articles/embedable-web-applications-with-shadow-dom))
|
||||
- Vite library mode build config (source: [Vite docs](https://vite.dev/guide/build))
|
||||
45
.gsd/milestones/M003/slices/S03/tasks/T01-PLAN.md
Normal file
45
.gsd/milestones/M003/slices/S03/tasks/T01-PLAN.md
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
---
|
||||
estimated_steps: 13
|
||||
estimated_files: 4
|
||||
skills_used: []
|
||||
---
|
||||
|
||||
# T01: Make engine API base URL configurable and create embed entry + Vite library build
|
||||
|
||||
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 `<kerf-embed>` 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 `<style>`, inject `@font-face` rules into `document.head`, create mount div, call `createRoot(mountDiv).render(<App />)`
|
||||
- Rewrite `:root` to `:host` when injecting `index.css` content into shadow root
|
||||
- Read `engine-url` attribute and call `setEngineBaseUrl()` before rendering
|
||||
- Register via `customElements.define('kerf-embed', KerfEmbed)`
|
||||
|
||||
The Vite library config must:
|
||||
- Entry: `src/embed.tsx`
|
||||
- Output dir: `dist-embed/`
|
||||
- Library mode with formats `['es', 'iife']`, name `KerfEmbed`
|
||||
- CSS extracted (not inlined)
|
||||
- Nothing externalized — React, Konva, opentype.js all bundled
|
||||
|
||||
## Inputs
|
||||
|
||||
- ``app/src/api/engine.ts` — current API client with hardcoded BASE='/engine'`
|
||||
- ``app/src/App.tsx` — the App component to render inside the shadow root`
|
||||
- ``app/src/App.css` — contains @font-face declarations and component styles (926 lines)`
|
||||
- ``app/src/index.css` — contains :root CSS custom properties (111 lines)`
|
||||
- ``app/vite.config.ts` — existing Vite config for reference`
|
||||
- ``app/tsconfig.node.json` — needs to include new vite.embed.config.ts`
|
||||
|
||||
## Expected Output
|
||||
|
||||
- ``app/src/api/engine.ts` — modified with setEngineBaseUrl() export and getBase() internal function`
|
||||
- ``app/src/embed.tsx` — new Web Component entry with Shadow DOM, CSS injection, React mount`
|
||||
- ``app/vite.embed.config.ts` — new Vite library mode build config`
|
||||
- ``app/tsconfig.node.json` — updated include array with vite.embed.config.ts`
|
||||
- ``app/dist-embed/kerf-embed.js` — built bundle (ES module)`
|
||||
- ``app/dist-embed/style.css` — extracted CSS`
|
||||
|
||||
## Verification
|
||||
|
||||
cd app && npx vite build --config vite.embed.config.ts && test -f dist-embed/kerf-embed.js && test -f dist-embed/style.css && echo 'Build OK'
|
||||
84
.gsd/milestones/M003/slices/S03/tasks/T01-SUMMARY.md
Normal file
84
.gsd/milestones/M003/slices/S03/tasks/T01-SUMMARY.md
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
---
|
||||
id: T01
|
||||
parent: S03
|
||||
milestone: M003
|
||||
provides: []
|
||||
requires: []
|
||||
affects: []
|
||||
key_files: ["app/src/api/engine.ts", "app/src/embed.tsx", "app/vite.embed.config.ts", "app/tsconfig.node.json"]
|
||||
key_decisions: ["Use Vite ?inline CSS imports for Shadow DOM injection", "Extract @font-face to document.head for cross-browser font loading", "codeSplitting: false for single self-contained bundles"]
|
||||
patterns_established: []
|
||||
drill_down_paths: []
|
||||
observability_surfaces: []
|
||||
duration: ""
|
||||
verification_result: "TypeScript compiles clean (npx tsc -b --noEmit, exit 0). All 120 existing tests pass (npx vitest run, exit 0). Embed build produces expected outputs (npx vite build --config vite.embed.config.ts, kerf-embed.js + style.css present)."
|
||||
completed_at: 2026-03-26T06:59:26.822Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
# T01: Added setEngineBaseUrl() to engine API client, created <kerf-embed> Web Component with Shadow DOM CSS injection, and configured Vite library-mode build producing self-contained ES + IIFE bundles
|
||||
|
||||
> Added setEngineBaseUrl() to engine API client, created <kerf-embed> Web Component with Shadow DOM CSS injection, and configured Vite library-mode build producing self-contained ES + IIFE bundles
|
||||
|
||||
## What Happened
|
||||
---
|
||||
id: T01
|
||||
parent: S03
|
||||
milestone: M003
|
||||
key_files:
|
||||
- app/src/api/engine.ts
|
||||
- app/src/embed.tsx
|
||||
- app/vite.embed.config.ts
|
||||
- app/tsconfig.node.json
|
||||
key_decisions:
|
||||
- Use Vite ?inline CSS imports for Shadow DOM injection
|
||||
- Extract @font-face to document.head for cross-browser font loading
|
||||
- codeSplitting: false for single self-contained bundles
|
||||
duration: ""
|
||||
verification_result: passed
|
||||
completed_at: 2026-03-26T06:59:26.830Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
# T01: Added setEngineBaseUrl() to engine API client, created <kerf-embed> Web Component with Shadow DOM CSS injection, and configured Vite library-mode build producing self-contained ES + IIFE bundles
|
||||
|
||||
**Added setEngineBaseUrl() to engine API client, created <kerf-embed> Web Component with Shadow DOM CSS injection, and configured Vite library-mode build producing self-contained ES + IIFE bundles**
|
||||
|
||||
## What Happened
|
||||
|
||||
Replaced the hardcoded BASE constant in engine.ts with a mutable _baseUrl and exported setEngineBaseUrl() for embed configurability. Created embed.tsx defining the KerfEmbed custom element with Shadow DOM, ?inline CSS imports, @font-face hoisting to document.head, :root→:host rewriting, and React App mounting. Created vite.embed.config.ts for library-mode builds outputting kerf-embed.js (ES) and kerf-embed.iife.js (IIFE) to dist-embed/ with extracted style.css. Updated tsconfig.node.json to include the new vite config.
|
||||
|
||||
## Verification
|
||||
|
||||
TypeScript compiles clean (npx tsc -b --noEmit, exit 0). All 120 existing tests pass (npx vitest run, exit 0). Embed build produces expected outputs (npx vite build --config vite.embed.config.ts, kerf-embed.js + style.css present).
|
||||
|
||||
## Verification Evidence
|
||||
|
||||
| # | Command | Exit Code | Verdict | Duration |
|
||||
|---|---------|-----------|---------|----------|
|
||||
| 1 | `cd app && npx tsc -b --noEmit` | 0 | ✅ pass | 3200ms |
|
||||
| 2 | `cd app && npx vitest run` | 0 | ✅ pass | 4600ms |
|
||||
| 3 | `cd app && npx vite build --config vite.embed.config.ts && test -f dist-embed/kerf-embed.js && test -f dist-embed/style.css && echo 'Build OK'` | 0 | ✅ pass | 2900ms |
|
||||
|
||||
|
||||
## Deviations
|
||||
|
||||
Used codeSplitting: false instead of deprecated inlineDynamicImports. Added assetFileNames handler to rename CSS output from app.css to style.css. Consolidated duplicate rollupOptions that caused TS error.
|
||||
|
||||
## Known Issues
|
||||
|
||||
None.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `app/src/api/engine.ts`
|
||||
- `app/src/embed.tsx`
|
||||
- `app/vite.embed.config.ts`
|
||||
- `app/tsconfig.node.json`
|
||||
|
||||
|
||||
## Deviations
|
||||
Used codeSplitting: false instead of deprecated inlineDynamicImports. Added assetFileNames handler to rename CSS output from app.css to style.css. Consolidated duplicate rollupOptions that caused TS error.
|
||||
|
||||
## Known Issues
|
||||
None.
|
||||
33
.gsd/milestones/M003/slices/S03/tasks/T02-PLAN.md
Normal file
33
.gsd/milestones/M003/slices/S03/tasks/T02-PLAN.md
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
---
|
||||
estimated_steps: 4
|
||||
estimated_files: 2
|
||||
skills_used: []
|
||||
---
|
||||
|
||||
# T02: Add demo page, unit tests for API configurability, and verify full build
|
||||
|
||||
Create the embed demo HTML page and unit tests, then run full verification.
|
||||
|
||||
1. Create `examples/embed-demo.html` — a plain HTML page that loads the embed bundle via `<script>` tag and uses `<kerf-embed engine-url="http://localhost:8000">`. The page must have its own competing styles (Comic Sans font, bright background, custom colors) to demonstrate style isolation works.
|
||||
|
||||
2. Add/update unit tests for the `setEngineBaseUrl` functionality in `app/src/api/__tests__/engine.test.ts` — verify that after calling `setEngineBaseUrl('http://example.com/api')`, fetch calls use the new base URL, and that it can be reset.
|
||||
|
||||
3. Run full verification: `npx vitest run` (all tests pass), `npx tsc -b --noEmit` (TypeScript clean), embed build succeeds.
|
||||
|
||||
## Inputs
|
||||
|
||||
- ``app/src/api/engine.ts` — modified API client with setEngineBaseUrl() from T01`
|
||||
- ``app/src/embed.tsx` — embed entry from T01`
|
||||
- ``app/vite.embed.config.ts` — Vite library config from T01`
|
||||
- ``app/dist-embed/kerf-embed.js` — built bundle from T01`
|
||||
- ``app/dist-embed/style.css` — extracted CSS from T01`
|
||||
- ``app/src/api/__tests__/engine.test.ts` — existing API tests to extend`
|
||||
|
||||
## Expected Output
|
||||
|
||||
- ``examples/embed-demo.html` — demo page with competing host styles and <kerf-embed> usage`
|
||||
- ``app/src/api/__tests__/engine.test.ts` — updated with setEngineBaseUrl tests`
|
||||
|
||||
## Verification
|
||||
|
||||
npx tsc -b --noEmit && npx vitest run && test -f examples/embed-demo.html && echo 'All checks pass'
|
||||
File diff suppressed because one or more lines are too long
1
app/dist-embed/favicon.svg
Normal file
1
app/dist-embed/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
BIN
app/dist-embed/fonts/Lato-Regular.ttf
Normal file
BIN
app/dist-embed/fonts/Lato-Regular.ttf
Normal file
Binary file not shown.
BIN
app/dist-embed/fonts/OpenSans-Regular.ttf
Normal file
BIN
app/dist-embed/fonts/OpenSans-Regular.ttf
Normal file
Binary file not shown.
BIN
app/dist-embed/fonts/Roboto-Regular.ttf
Normal file
BIN
app/dist-embed/fonts/Roboto-Regular.ttf
Normal file
Binary file not shown.
24
app/dist-embed/icons.svg
Normal file
24
app/dist-embed/icons.svg
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
487
app/dist-embed/kerf-embed.iife.js
Normal file
487
app/dist-embed/kerf-embed.iife.js
Normal file
File diff suppressed because one or more lines are too long
50416
app/dist-embed/kerf-embed.js
Normal file
50416
app/dist-embed/kerf-embed.js
Normal file
File diff suppressed because one or more lines are too long
2
app/dist-embed/style.css
Normal file
2
app/dist-embed/style.css
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -2,13 +2,28 @@
|
|||
|
||||
import type { PresetsResponse, TraceResponse } from '../types/engine';
|
||||
|
||||
const BASE = '/engine';
|
||||
let _baseUrl = '/engine';
|
||||
|
||||
/**
|
||||
* Override the engine API base URL.
|
||||
* Used by the embed component to point at a remote engine origin.
|
||||
* @example setEngineBaseUrl('https://kerf.example.com/engine')
|
||||
*/
|
||||
export function setEngineBaseUrl(url: string): void {
|
||||
// Strip trailing slash for consistency
|
||||
_baseUrl = url.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
/** Return the current engine base URL. */
|
||||
function getBase(): string {
|
||||
return _baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all available presets from the engine.
|
||||
*/
|
||||
export async function getPresets(signal?: AbortSignal): Promise<PresetsResponse> {
|
||||
const res = await fetch(`${BASE}/presets`, { signal });
|
||||
const res = await fetch(`${getBase()}/presets`, { signal });
|
||||
if (!res.ok) {
|
||||
throw new Error(`GET /engine/presets failed: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
|
@ -31,7 +46,7 @@ export async function traceImage(
|
|||
form.append('preset', preset);
|
||||
form.append('params', JSON.stringify(params));
|
||||
|
||||
const res = await fetch(`${BASE}/trace`, {
|
||||
const res = await fetch(`${getBase()}/trace`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
signal,
|
||||
|
|
@ -56,7 +71,7 @@ export async function simplifyVector(
|
|||
form.append('epsilon', String(epsilon));
|
||||
form.append('output_format', 'svg');
|
||||
|
||||
const res = await fetch(`${BASE}/simplify`, {
|
||||
const res = await fetch(`${getBase()}/simplify`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
signal,
|
||||
|
|
@ -90,7 +105,7 @@ export async function exportAsDxf(
|
|||
form.append('units', units);
|
||||
form.append('scale_factor', String(scaleFactor));
|
||||
|
||||
const res = await fetch(`${BASE}/simplify`, {
|
||||
const res = await fetch(`${getBase()}/simplify`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
signal,
|
||||
|
|
|
|||
120
app/src/embed.tsx
Normal file
120
app/src/embed.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
/**
|
||||
* <kerf-embed> — Self-contained Web Component for the Kerf vectorization app.
|
||||
*
|
||||
* Usage:
|
||||
* <kerf-embed engine-url="https://kerf.example.com/engine"></kerf-embed>
|
||||
*
|
||||
* All styles are isolated inside Shadow DOM. @font-face rules are hoisted
|
||||
* to document.head because fonts declared in Shadow DOM don't reliably
|
||||
* load cross-browser.
|
||||
*/
|
||||
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { StrictMode } from 'react';
|
||||
import App from './App';
|
||||
import { setEngineBaseUrl } from './api/engine';
|
||||
|
||||
// Vite ?inline imports — CSS is embedded as strings, not injected into <head>
|
||||
import indexCss from './index.css?inline';
|
||||
import appCss from './App.css?inline';
|
||||
|
||||
/**
|
||||
* Extract @font-face blocks from a CSS string.
|
||||
* Returns [fontFaceRules, remainingCss].
|
||||
*/
|
||||
function extractFontFaces(css: string): [string, string] {
|
||||
const fontFaceBlocks: string[] = [];
|
||||
// Match @font-face { ... } — handles nested braces safely enough for our case
|
||||
const regex = /@font-face\s*\{[^}]*\}/g;
|
||||
let remaining = css;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = regex.exec(css)) !== null) {
|
||||
fontFaceBlocks.push(match[0]);
|
||||
}
|
||||
remaining = css.replace(regex, '').trim();
|
||||
return [fontFaceBlocks.join('\n'), remaining];
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite `:root` selectors to `:host` so CSS custom properties work
|
||||
* inside Shadow DOM.
|
||||
*/
|
||||
function rewriteRootToHost(css: string): string {
|
||||
return css.replace(/:root/g, ':host');
|
||||
}
|
||||
|
||||
/** Inject @font-face rules into document.head (idempotent). */
|
||||
function injectFontFaces(fontFaceCss: string): void {
|
||||
if (!fontFaceCss.trim()) return;
|
||||
const id = 'kerf-embed-fonts';
|
||||
if (document.getElementById(id)) return;
|
||||
const style = document.createElement('style');
|
||||
style.id = id;
|
||||
style.textContent = fontFaceCss;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
class KerfEmbed extends HTMLElement {
|
||||
private _root: ReturnType<typeof createRoot> | null = null;
|
||||
|
||||
connectedCallback(): void {
|
||||
// Read configurable engine URL before any rendering
|
||||
const engineUrl = this.getAttribute('engine-url');
|
||||
if (engineUrl) {
|
||||
setEngineBaseUrl(engineUrl);
|
||||
}
|
||||
|
||||
// Attach open Shadow DOM
|
||||
const shadow = this.attachShadow({ mode: 'open' });
|
||||
|
||||
// Process App.css: extract @font-face → document.head, rest → shadow
|
||||
const [fontFaceCss, appCssClean] = extractFontFaces(appCss);
|
||||
injectFontFaces(fontFaceCss);
|
||||
|
||||
// Rewrite :root → :host for index.css custom properties
|
||||
const indexCssRewritten = rewriteRootToHost(indexCss);
|
||||
|
||||
// Inject all styles into shadow root
|
||||
const styleEl = document.createElement('style');
|
||||
styleEl.textContent = `
|
||||
${indexCssRewritten}
|
||||
${appCssClean}
|
||||
|
||||
/* Embed-specific overrides */
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
`;
|
||||
shadow.appendChild(styleEl);
|
||||
|
||||
// Create mount point
|
||||
const mountDiv = document.createElement('div');
|
||||
mountDiv.id = 'root';
|
||||
shadow.appendChild(mountDiv);
|
||||
|
||||
// Mount React app
|
||||
this._root = createRoot(mountDiv);
|
||||
this._root.render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
// Clean up React root on removal
|
||||
if (this._root) {
|
||||
this._root.unmount();
|
||||
this._root = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register the custom element (guard against double-registration)
|
||||
if (!customElements.get('kerf-embed')) {
|
||||
customElements.define('kerf-embed', KerfEmbed);
|
||||
}
|
||||
|
||||
export { KerfEmbed };
|
||||
|
|
@ -22,5 +22,5 @@
|
|||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
"include": ["vite.config.ts", "vite.embed.config.ts"]
|
||||
}
|
||||
|
|
|
|||
42
app/vite.embed.config.ts
Normal file
42
app/vite.embed.config.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
/// <reference types="vite/client" />
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { resolve } from 'path';
|
||||
|
||||
/**
|
||||
* Vite library-mode config for the <kerf-embed> Web Component.
|
||||
*
|
||||
* Bundles everything (React, Konva, opentype.js) into a self-contained
|
||||
* output — nothing is externalized. Outputs ES module + IIFE formats.
|
||||
*/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
outDir: 'dist-embed',
|
||||
emptyOutDir: true,
|
||||
lib: {
|
||||
entry: resolve(__dirname, 'src/embed.tsx'),
|
||||
name: 'KerfEmbed',
|
||||
formats: ['es', 'iife'],
|
||||
fileName: (format) => {
|
||||
if (format === 'es') return 'kerf-embed.js';
|
||||
return 'kerf-embed.iife.js';
|
||||
},
|
||||
},
|
||||
// Don't inline CSS — extract to style.css for optional manual loading
|
||||
cssCodeSplit: false,
|
||||
rollupOptions: {
|
||||
// Explicitly empty to prevent any auto-externalization
|
||||
external: [],
|
||||
output: {
|
||||
// Prevent chunk splitting — single self-contained bundle
|
||||
codeSplitting: false,
|
||||
// Name the CSS output predictably
|
||||
assetFileNames: (assetInfo) => {
|
||||
if (assetInfo.names?.[0]?.endsWith('.css')) return 'style.css';
|
||||
return assetInfo.names?.[0] ?? '[name][extname]';
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue