From 070c95e2548e0058a22c8105a16379bf624fba5c Mon Sep 17 00:00:00 2001 From: "khalid@traclabs.com" Date: Sun, 24 May 2026 08:44:29 -0500 Subject: [PATCH] Break editor into a module --- MIGRATION_RUNBOOK.md | 321 ++ e2e/crop.spec.js | 235 -- e2e/fixtures/editor.js | 281 -- e2e/history.spec.js | 189 -- e2e/persistence.spec.js | 148 - e2e/text-editing.spec.js | 138 - fonts/.gitkeep | 0 fonts/README.md | 44 - package-lock.json | 2883 ++++------------- package.json | 30 +- package.monolith.json | 72 + playwright.config.js | 112 - scripts/cutover-host.sh | 168 + scripts/migrate-to-module.sh | 161 + server.js | 982 +----- server.monolith.js | 1103 +++++++ src/App.css | 453 --- src/App.jsx | 2277 ++----------- src/App.monolith.jsx | 2086 ++++++++++++ src/components/Header.jsx | 122 +- src/components/MobileBottomSheet.jsx | 78 - src/components/OfflineIndicator.jsx | 3 +- src/components/PWAInstall.jsx | 13 +- src/components/Toast.jsx | 82 + .../canvas/BackgroundRemovalButton.jsx | 188 -- src/components/canvas/CanvasHint.jsx | 19 - src/components/canvas/CropOverlay.jsx | 283 -- src/components/canvas/DebugOverlay.jsx | 996 ------ src/components/canvas/DesignCanvas.jsx | 1506 --------- src/components/canvas/ElementToolbar.jsx | 1092 ------- src/components/canvas/HistoryControls.jsx | 70 - src/components/canvas/ImageElement.jsx | 374 --- src/components/canvas/InlineTextEditor.jsx | 128 - src/components/canvas/ModelSilhouette.jsx | 128 - src/components/canvas/SlotPlaceholder.jsx | 103 - src/components/canvas/TShirtSVG.jsx | 149 - src/components/canvas/TemplateLayer.jsx | 37 - src/components/canvas/TextEditAffordance.jsx | 286 -- src/components/canvas/TextElement.jsx | 557 ---- src/components/canvas/ZoomControls.jsx | 100 - src/components/canvas/index.js | 12 - src/components/editor/PhotoPreEditor.jsx | 116 - src/components/editor/PreviewModal.jsx | 123 - src/components/editor/index.js | 1 - src/components/panels/LayersPanel.jsx | 404 --- src/components/panels/PropertiesPanel.jsx | 110 - src/components/panels/index.js | 2 - .../sidebar/BackgroundRemovalButton.jsx | 11 - src/components/sidebar/EmojiTab.jsx | 173 - src/components/sidebar/ShirtOptionsPanel.jsx | 162 - src/components/sidebar/Sidebar.jsx | 329 -- src/components/sidebar/StickersTab.jsx | 180 - src/components/sidebar/TemplatesTab.jsx | 107 - src/components/sidebar/TextTab.jsx | 1017 ------ src/components/sidebar/UploadTab.jsx | 168 - src/components/sidebar/index.js | 9 - src/constants/elements.js | 23 - src/constants/fonts.js | 24 - src/constants/imageFilters.js | 60 - src/constants/stickers.js | 154 - src/constants/textMetrics.js | 54 - src/constants/transformer.js | 62 - src/hooks/index.js | 4 - src/hooks/useBackgroundRemoval.js | 144 - src/hooks/useCroppedThumbnail.js | 214 -- src/hooks/useDesignEditor.js | 592 ---- src/hooks/useDesignEditor.test.js | 837 ----- src/hooks/useExport.js | 66 - src/hooks/useFocusTrap.js | 91 - src/hooks/useFontsReady.js | 50 - src/hooks/useTemplate.js | 61 - src/hooks/useTimer.js | 62 - src/i18n/messages.js | 211 +- src/i18n/t.js | 49 +- src/index.css | 117 +- src/styles/CanvasHint.css | 39 - src/styles/DesignCanvas.css | 189 -- src/styles/ElementToolbar.css | 569 ---- src/styles/Header.css | 30 +- src/styles/LayersPanel.css | 313 -- src/styles/MobileBottomSheet.css | 102 - src/styles/ModelSilhouette.css | 16 - src/styles/PhotoPreEditor.css | 7 - src/styles/PreviewModal.css | 226 -- src/styles/PropertiesPanel.css | 113 - src/styles/ShirtOptionsPanel.css | 263 -- src/styles/Sidebar.css | 248 -- src/styles/StickersTab.css | 234 -- src/styles/TShirtSVG.css | 10 - src/styles/TemplatesTab.css | 149 - src/styles/TextTab.css | 472 --- src/styles/Toast.css | 98 + src/styles/UploadTab.css | 201 -- src/styles/ZoomControls.css | 66 - src/test/setup.js | 45 - src/utils/blobUrl.js | 36 - src/utils/blobUrl.test.js | 103 - src/utils/colorUtils.js | 206 -- src/utils/colorUtils.test.js | 392 --- src/utils/cropMath.js | 183 -- src/utils/cropMath.test.js | 479 --- src/utils/elementBounds.js | 237 -- src/utils/elementBounds.test.js | 497 --- src/utils/flipOffset.js | 67 - src/utils/flipOffset.test.js | 105 - src/utils/fontPreload.js | 61 - src/utils/imageLoading.js | 52 - src/utils/konvaCoords.js | 60 - src/utils/makeId.js | 36 - src/utils/makeId.test.js | 107 - src/utils/persistence.js | 80 - src/utils/persistence.test.js | 209 -- src/utils/storage.js | 64 - src/utils/storage.test.js | 126 - src/utils/textGeometry.js | 412 --- src/utils/textGeometry.test.js | 415 --- src/utils/textMetrics.js | 166 - vite.config.js | 90 +- vitest.config.js | 73 - 119 files changed, 5318 insertions(+), 26124 deletions(-) create mode 100644 MIGRATION_RUNBOOK.md delete mode 100644 e2e/crop.spec.js delete mode 100644 e2e/fixtures/editor.js delete mode 100644 e2e/history.spec.js delete mode 100644 e2e/persistence.spec.js delete mode 100644 e2e/text-editing.spec.js delete mode 100644 fonts/.gitkeep delete mode 100644 fonts/README.md create mode 100644 package.monolith.json delete mode 100644 playwright.config.js create mode 100644 scripts/cutover-host.sh create mode 100644 scripts/migrate-to-module.sh create mode 100644 server.monolith.js delete mode 100644 src/App.css create mode 100644 src/App.monolith.jsx delete mode 100644 src/components/MobileBottomSheet.jsx create mode 100644 src/components/Toast.jsx delete mode 100644 src/components/canvas/BackgroundRemovalButton.jsx delete mode 100644 src/components/canvas/CanvasHint.jsx delete mode 100644 src/components/canvas/CropOverlay.jsx delete mode 100644 src/components/canvas/DebugOverlay.jsx delete mode 100644 src/components/canvas/DesignCanvas.jsx delete mode 100644 src/components/canvas/ElementToolbar.jsx delete mode 100644 src/components/canvas/HistoryControls.jsx delete mode 100644 src/components/canvas/ImageElement.jsx delete mode 100644 src/components/canvas/InlineTextEditor.jsx delete mode 100644 src/components/canvas/ModelSilhouette.jsx delete mode 100644 src/components/canvas/SlotPlaceholder.jsx delete mode 100644 src/components/canvas/TShirtSVG.jsx delete mode 100644 src/components/canvas/TemplateLayer.jsx delete mode 100644 src/components/canvas/TextEditAffordance.jsx delete mode 100644 src/components/canvas/TextElement.jsx delete mode 100644 src/components/canvas/ZoomControls.jsx delete mode 100644 src/components/canvas/index.js delete mode 100644 src/components/editor/PhotoPreEditor.jsx delete mode 100644 src/components/editor/PreviewModal.jsx delete mode 100644 src/components/editor/index.js delete mode 100644 src/components/panels/LayersPanel.jsx delete mode 100644 src/components/panels/PropertiesPanel.jsx delete mode 100644 src/components/panels/index.js delete mode 100644 src/components/sidebar/BackgroundRemovalButton.jsx delete mode 100644 src/components/sidebar/EmojiTab.jsx delete mode 100644 src/components/sidebar/ShirtOptionsPanel.jsx delete mode 100644 src/components/sidebar/Sidebar.jsx delete mode 100644 src/components/sidebar/StickersTab.jsx delete mode 100644 src/components/sidebar/TemplatesTab.jsx delete mode 100644 src/components/sidebar/TextTab.jsx delete mode 100644 src/components/sidebar/UploadTab.jsx delete mode 100644 src/components/sidebar/index.js delete mode 100644 src/constants/elements.js delete mode 100644 src/constants/fonts.js delete mode 100644 src/constants/imageFilters.js delete mode 100644 src/constants/stickers.js delete mode 100644 src/constants/textMetrics.js delete mode 100644 src/constants/transformer.js delete mode 100644 src/hooks/index.js delete mode 100644 src/hooks/useBackgroundRemoval.js delete mode 100644 src/hooks/useCroppedThumbnail.js delete mode 100644 src/hooks/useDesignEditor.js delete mode 100644 src/hooks/useDesignEditor.test.js delete mode 100644 src/hooks/useExport.js delete mode 100644 src/hooks/useFocusTrap.js delete mode 100644 src/hooks/useFontsReady.js delete mode 100644 src/hooks/useTemplate.js delete mode 100644 src/hooks/useTimer.js delete mode 100644 src/styles/CanvasHint.css delete mode 100644 src/styles/DesignCanvas.css delete mode 100644 src/styles/ElementToolbar.css delete mode 100644 src/styles/LayersPanel.css delete mode 100644 src/styles/MobileBottomSheet.css delete mode 100644 src/styles/ModelSilhouette.css delete mode 100644 src/styles/PhotoPreEditor.css delete mode 100644 src/styles/PreviewModal.css delete mode 100644 src/styles/PropertiesPanel.css delete mode 100644 src/styles/ShirtOptionsPanel.css delete mode 100644 src/styles/Sidebar.css delete mode 100644 src/styles/StickersTab.css delete mode 100644 src/styles/TShirtSVG.css delete mode 100644 src/styles/TemplatesTab.css delete mode 100644 src/styles/TextTab.css create mode 100644 src/styles/Toast.css delete mode 100644 src/styles/UploadTab.css delete mode 100644 src/styles/ZoomControls.css delete mode 100644 src/test/setup.js delete mode 100644 src/utils/blobUrl.js delete mode 100644 src/utils/blobUrl.test.js delete mode 100644 src/utils/colorUtils.js delete mode 100644 src/utils/colorUtils.test.js delete mode 100644 src/utils/cropMath.js delete mode 100644 src/utils/cropMath.test.js delete mode 100644 src/utils/elementBounds.js delete mode 100644 src/utils/elementBounds.test.js delete mode 100644 src/utils/flipOffset.js delete mode 100644 src/utils/flipOffset.test.js delete mode 100644 src/utils/fontPreload.js delete mode 100644 src/utils/imageLoading.js delete mode 100644 src/utils/konvaCoords.js delete mode 100644 src/utils/makeId.js delete mode 100644 src/utils/makeId.test.js delete mode 100644 src/utils/persistence.js delete mode 100644 src/utils/persistence.test.js delete mode 100644 src/utils/storage.js delete mode 100644 src/utils/storage.test.js delete mode 100644 src/utils/textGeometry.js delete mode 100644 src/utils/textGeometry.test.js delete mode 100644 src/utils/textMetrics.js delete mode 100644 vitest.config.js diff --git a/MIGRATION_RUNBOOK.md b/MIGRATION_RUNBOOK.md new file mode 100644 index 0000000..40a1e9f --- /dev/null +++ b/MIGRATION_RUNBOOK.md @@ -0,0 +1,321 @@ +# Migration runbook — apparel-designer → goods-editor module + +Step-by-step instructions for executing the module extraction. This +document is intentionally minimal-fluff: do each step in order, verify +the marker at each checkpoint, move to the next. + +## Prerequisites + +Verify both repos exist as siblings: + +``` +/Users/khalid/Documents/Claude-Desktop/ +├── apparel-designer/ (the host app, in working state) +└── goods-editor-module/ (the empty module repo we just scaffolded) +``` + +## Phase 1 — Module scaffolding (already done) + +These were created during the planning session and are ready to use: + +``` +goods-editor-module/ +├── package.json +├── vite.config.js (library mode) +├── vite.server.config.js (Node server entry) +├── vitest.config.js +├── .gitignore +├── README.md +├── src/ +│ ├── ApparelDesigner.jsx (~30KB orchestration absorbed from App.jsx) +│ ├── index.js (public API barrel) +│ └── server/ +│ ├── index.js +│ └── mountEditorApi.js (Express middleware factory) +├── examples/dev-host/ (standalone dev playground) +└── scripts/ + ├── release.sh + └── post-migrate-patches.mjs +``` + +**Verification:** `ls goods-editor-module/src/index.js` should succeed. + +## Phase 2 — File migration + +Copy all the editor source from the host into the module. + +```bash +cd /Users/khalid/Documents/Claude-Desktop +bash apparel-designer/scripts/migrate-to-module.sh +``` + +**What this does:** Copies utils, hooks, constants, components, +styles, fonts, stickers, tests from `apparel-designer/src/` (and +related directories) into `goods-editor-module/src/`. Source is left +intact in `apparel-designer/` — nothing is deleted yet. + +**Verification:** `ls goods-editor-module/src/components/canvas/` +should show DesignCanvas.jsx, ElementToolbar.jsx, etc. + +## Phase 3 — Post-copy patches + +The copied files import constants like `SHIRT_COLORS` directly. Inside +the module, those need to come from props. Run the patch script: + +```bash +cd /Users/khalid/Documents/Claude-Desktop/goods-editor-module +node scripts/post-migrate-patches.mjs +``` + +**What this does:** Modifies a handful of files (`ShirtOptionsPanel`, +`Sidebar`, `UploadTab`) to accept their data as props instead of +importing constants. + +**Verification:** Search the module source for `SHIRT_COLORS` — should +return 0 hits in component files (still present as a `constants/shirt.js` +default that's no longer imported by components). + +## Phase 4 — Module install + build + +```bash +cd /Users/khalid/Documents/Claude-Desktop/goods-editor-module +npm install +npm run build +``` + +**Verification:** `ls dist/` should show: + +- `goods-editor.es.js` +- `goods-editor.cjs.js` +- `style.css` +- `server/index.js` +- `server/index.cjs` + +If the build fails: + +- **"Cannot find module 'X'"** — the migration may have missed a file. + Check `goods-editor-module/src/` for the missing path and copy it + from `apparel-designer/src/` manually. +- **"SHIRT_COLORS is not defined"** — patch script didn't run or didn't + fully match. Manually search for the symbol in the offending file + and replace with the prop. + +## Phase 5 — Module dev-host smoke test (optional but recommended) + +Verify the module works in isolation before wiring it into the host: + +```bash +cd /Users/khalid/Documents/Claude-Desktop/goods-editor-module +npm run dev +# open http://localhost:3000 in a browser +``` + +You should see the editor with the dev host's minimal top bar. If +something's broken it's a module issue; fix here before touching the +host. + +The dev host doesn't have an /api/upload endpoint, so uploads will +fail — that's expected. Text, stickers, emoji, and templates work +without a server. + +## Phase 6 — Host cutover + +Replace the host's monolithic App.jsx + server.js with the new +module-consuming versions: + +```bash +cd /Users/khalid/Documents/Claude-Desktop/apparel-designer +bash scripts/cutover-host.sh +``` + +**What this does:** + +1. Renames the monolithic files to `*.monolith.*` (kept for reference) +2. Promotes `App.host.jsx` → `App.jsx`, `server.host.js` → `server.js`, + `package.host.json` → `package.json` +3. Deletes the module-owned source from `src/` + +**Verification:** `ls apparel-designer/src/` should show only: + +- `App.jsx` +- `App.monolith.jsx` (backup) +- `main.jsx` +- `index.css` +- `components/Header.jsx`, `components/PWAInstall.jsx`, + `components/OfflineIndicator.jsx` +- `styles/Header.css`, `styles/PWAInstall.css` (or however they + organize after cleanup) + +(The host's `src/styles/` and `src/components/` may still have +host-specific CSS that wasn't deleted. That's expected — only +module-owned files were removed.) + +## Phase 7 — Host install + run + +```bash +cd /Users/khalid/Documents/Claude-Desktop/apparel-designer +rm -rf node_modules package-lock.json +npm install +npm run dev +``` + +The `file:../goods-editor-module` reference in package.json points at +the local module directory. npm install will copy it into +`node_modules/goods-editor/`. + +### Important: host-owned constants survive cutover + +The `cutover-host.sh` script preserves three files in `src/constants/`: + +- `shirt.js` — host's color / size / price catalog +- `templates.js` — host's template definitions +- `products.js` — host's product configuration + +These are imported by the new `App.jsx` and passed into +`` as props. Other constants (fonts, stickers, +elements, etc.) are module-owned and get deleted. + +If you customize the host's catalog (different shirt colors, new +templates), you edit those files — not the module. + +### Important: host-required native deps for server-side export + +`goods-editor/server`'s `mountEditorApi()` accepts the host's `canvas` +and `sharp` modules via options: + +```js +import canvas from 'canvas'; +import sharp from 'sharp'; +import { mountEditorApi } from 'goods-editor/server'; + +mountEditorApi(app, { canvas, sharp, exportsDir, fontsDir, ... }); +``` + +This injection pattern means: + +- The module declares both as **optional peer dependencies** — listed + for tooling visibility but not auto-installed. +- Hosts that mount the server install canvas + sharp in their OWN + `package.json` (as regular deps) and import them at the top of + their `server.js`. +- Hosts that DON'T mount the server (editor-only, preview-only) + don't install them at all. The React side of `goods-editor` doesn't + need them. + +Why injection rather than `require()` inside the module: when the +module is consumed via `file:` (local dev) or `git+ssh://` (release), +the module's built server bundle lives in the consuming app's +`node_modules`. A `require('canvas')` from inside that bundle would +resolve from the MODULE'S directory chain, not the host's. The +injection pattern sidesteps this entirely — the host imports canvas +from its own `node_modules` and passes the imported object in. + +**System libs:** `canvas` builds native bindings against cairo/pango. +On macOS: + +``` +brew install pkg-config cairo pango libpng jpeg giflib librsvg pixman +``` + +If `npm install` fails on the canvas build step, those libs are +usually the missing piece. + +If the host doesn't need server-side export (e.g. editor-only flows), +skip the `mountEditorApi(app, ...)` call in `server.js` entirely and +don't install canvas/sharp — the editor still works as a pure React +component. + +### Important: the host's `virtual:sticker-manifest` setup stays in place + +The module's `src/constants/stickers.js` imports from +`virtual:sticker-manifest` — a Vite virtual module that the HOST'S +`vite.config.js` provides via `stickerManifestPlugin()`. This is +intentional: it makes the host the source-of-truth for which stickers +are available. + +**Don't delete `stickerManifestPlugin()` from the host's +`vite.config.js`,** and don't delete `public/stickers/`. The cutover +script leaves both alone. If you accidentally remove the plugin, you +will see this error when running the editor: + +``` +Failed to resolve import "virtual:sticker-manifest" from +"node_modules/goods-editor/dist/goods-editor.es.js". +``` + +The fix is to restore the plugin (it's the `stickerManifestPlugin()` +function at the top of `vite.config.js`, plus its inclusion in the +`plugins:` array). + +If you want to ship the editor on a host that doesn't use Vite, or +that has a fundamentally different sticker layout, you can pass +stickers in via props on `` instead and provide a +no-op virtual module that exports an empty `STICKER_FILES` array. +(That extension point doesn't exist yet — file an issue / add a prop +when the second host needs it.) + +**Verification:** `http://localhost:3000` should show the editor with +the host's full Header and the editor mounted below it. Functionality +should match the pre-migration version exactly. + +## Phase 8 — Final checks + +Things to verify in the running app: + +- [ ] Can add text from the Text tab +- [ ] Can upload a photo and see it on the canvas +- [ ] Can add a sticker / emoji +- [ ] Cmd+Z undoes; Cmd+Shift+Z redoes +- [ ] Crop an image, Cmd+Z restores pre-crop (the May 22 regression) +- [ ] Save button triggers an export (download via the export pipeline) +- [ ] Reload the page — designs persist via localStorage +- [ ] Production build: `npm run build && NODE_ENV=production npm start` + +If everything works: + +1. `git add -A && git commit -m "Migrate to goods-editor module"` in both + repos +2. Cut a release of the module: + ```bash + cd goods-editor-module + ./scripts/release.sh --version=0.1.0-alpha.0 + ``` +3. In the host, swap the dep from `file:` to `git+ssh://` once you've + pushed to Gitea. + +## Rollback + +If anything goes sideways and you need to undo the cutover: + +```bash +cd apparel-designer +mv src/App.jsx src/App.host.jsx +mv server.js server.host.js +mv package.json package.host.json +mv src/App.monolith.jsx src/App.jsx +mv server.monolith.js server.js +mv package.monolith.json package.json +# Restore deleted module-owned files from git: +git checkout -- src/ fonts/ e2e/ playwright.config.js vitest.config.js +rm -rf node_modules package-lock.json +npm install +npm run dev +``` + +The `goods-editor-module/` repo stays put — rollback only affects the +host. You can iterate on the module separately and try the cutover +again later. + +## Done + +After successful cutover, future development looks like: + +- **Editor changes** (canvas behavior, sidebar UX, hooks, utilities): + edit in `goods-editor-module/`. Test via `npm run dev` (dev host). + When happy, `./scripts/release.sh --version=X.Y.Z` and bump the + host's `package.json`. + +- **Host changes** (homepage, marketing, cart, top bar): edit in + `apparel-designer/`. AI tools pointed at this directory only see + the host's ~10 files plus the editor's public API surface, not the + editor's ~100 internal files. That's the win. diff --git a/e2e/crop.spec.js b/e2e/crop.spec.js deleted file mode 100644 index e1dc5bf..0000000 --- a/e2e/crop.spec.js +++ /dev/null @@ -1,235 +0,0 @@ -// E2E: crop flow + the May 22 "canvas blanks on Cmd+Z after crop" -// regression. -// -// What this guards -// ──────────────── -// The headline bug from the May 22 cleanup arc: cropping an image and -// then pressing Cmd+Z would land on an empty canvas instead of -// restoring the pre-crop state. Root cause was in the initializeHistory -// path — see Refinements_2026-05-22.md for the full story. The unit -// tests cover the math (cropMath) and the history-pointer behaviour -// (useDesignEditor) at the function level; this spec exercises the -// full pipeline end-to-end so a future regression that breaks any -// link in the chain (the Konva stage walk for naturalW/H, the -// updateAndCommit wiring, the keyboard handler firing on undo) gets -// caught. -// -// Why this spec is more complex than the other three -// ────────────────────────────────────────────────── -// Cropping is image-only — ElementToolbar's `allowCrop = isImage && -// !!onStartCrop` gates the Crop button on type === 'image'. Stickers -// don't get a Crop button (even though App's handleStartCrop accepts -// them). That means this spec can't use a one-click sticker add; it -// needs a real file upload via the Upload Photo tab. -// -// Rather than committing a binary fixture file to the repo (and -// dealing with Git LFS or just bloat), we build the upload payload -// inline as a base64-encoded 1×1 transparent PNG. Playwright's -// `setInputFiles({ name, mimeType, buffer })` accepts an in-memory -// buffer with no file on disk. The PNG is the smallest valid PNG -// the spec can use — 67 bytes — which keeps the upload fast and -// avoids any minimum-dimension validation surprises. -// -// Image-load timing -// ───────────────── -// The Konva Image node loads the src URL asynchronously after the -// element is added to state. handleApplyCrop walks the stage to -// find the image node and read naturalWidth/Height; if those are -// 0 (image not yet decoded), it surfaces the -// "Photo is still loading. Try Apply again in a moment." toast -// and keeps crop mode active. Our helper below clicks Apply, then -// waits for crop mode to exit (Apply button vanishes); if it -// doesn't within a short window, that means the toast fired and -// we should wait + retry. This is the same UX a human user -// follows ("oh, didn't work, let me click again in a second"). - -import { test, expect } from '@playwright/test'; -import { - gotoFreshEditor, - getElementsCount, - pressUndo, -} from './fixtures/editor.js'; - -// Smallest valid PNG: 1×1 transparent. 67 bytes raw, ~90 base64 -// chars. Decoded to a buffer for the upload. The image is too small -// to be visually meaningful but that's fine — the bug we're guarding -// against is history-shaped, not pixel-shaped. Crop math reduces to -// an identity crop on a 1×1 source, which still pushes a real -// history entry that undo must restore from. -const TINY_PNG_BASE64 - = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='; - -/** - * Click "Apply crop" and wait for crop mode to exit. If the - * "Photo is still loading" toast appears (because the Konva - * Image node hasn't decoded the src yet), wait briefly and try - * again — up to a small attempt budget. - * - * Success signal: the Apply button stops being visible (because - * the ElementToolbar drops back to its non-crop face). - * - * This mirrors the toast-and-retry guidance in messages.js: - * 'toast.crop-not-ready': 'Photo is still loading. Try Apply - * again in a moment.' - */ -async function applyCropWithRetry(page) { - const applyButton = page.getByRole('button', { name: 'Apply crop' }); - // 8 attempts × ~1.5s budget per attempt + intermediate waits = - // up to ~20s for the image to decode. In practice the buffer is - // already in memory so this resolves on the first attempt; the - // retry budget is purely defensive against slow CI environments. - for (let attempt = 0; attempt < 8; attempt++) { - await applyButton.click(); - // If crop mode exits within the timeout, Apply succeeded. - // We use `waitFor` on the button's hidden state because - // toBeHidden() polls but doesn't return a boolean. - const exited = await applyButton - .waitFor({ state: 'hidden', timeout: 1500 }) - .then(() => true) - .catch(() => false); - if (exited) return; - // Apply button still visible → toast probably fired. Wait - // for the image to finish loading. The toast itself - // auto-dismisses; we just need the underlying load to - // complete before the next click. - await page.waitForTimeout(500); - } - throw new Error('Apply crop did not exit crop mode after 8 attempts'); -} - -test.beforeEach(async ({ page }) => { - await gotoFreshEditor(page); -}); - -test('cropping an uploaded image and then Cmd/Ctrl+Z does not blank the canvas (May 22 regression)', async ({ page }) => { - // Step 1 — Switch to the Upload Photo tab. In a fresh editor - // the default tab is Upload, so this is mostly a defensive - // click in case that default changes; either way it makes the - // file input findable. - await page.getByRole('tab', { name: /upload/i }).click(); - - // Step 2 — Upload the PNG. The file input is hidden via CSS - // (`ut__hidden-input`); Playwright's setInputFiles bypasses - // visibility checks. The upload triggers a POST to /api/upload, - // which is processed by the Node server (Vite proxies the - // /api/* prefix to localhost:3001). The server's multer+sharp - // pipeline writes a preview and original to disk and returns - // their URLs; UploadTab's placeImage then adds the element. - // - // The selector targets `.ut__hidden-input` rather than a bare - // `input[type="file"]` because the page also contains a slot- - // upload hidden input (from the template-slot system) with - // accept="image/*" — a bare query would resolve to two elements - // and trip Playwright's strict mode. The class selector is - // unique to UploadTab. - await page.locator('input.ut__hidden-input').setInputFiles({ - name: 'pixel.png', - mimeType: 'image/png', - buffer: Buffer.from(TINY_PNG_BASE64, 'base64'), - }); - - // Step 3 — Wait for the upload to complete and the element - // to be added. expect.poll handles the upload's async chain - // (server roundtrip + image dimension probe + addElement - // commit). The default expect timeout (5s) is plenty for a - // 67-byte upload; if it ever flakes here, CI is overloaded - // and the test budget should grow rather than this assertion. - await expect.poll(() => getElementsCount(page)).toBe(1); - - // Step 4 — Make sure the element is selected so the - // ElementToolbar renders. The upload flow may or may not - // auto-select (depending on App's handleAddImage); clicking - // the layer row to select is defensive and explicit. The - // row's main button has class `.layers-item-main` and - // contains the element's display name (typically "Photo" - // or a filename-derived label for uploads). - await page.locator('.layers-item-main').first().click(); - - // Step 5 — Click the Crop button. It only renders for - // type==='image' elements (allowCrop guard in - // ElementToolbar). The button's accessible name comes from - // its ToolbarButton's `label` prop: "Crop image". - await page.getByRole('button', { name: /crop image/i }).click(); - - // Step 6 — The toolbar swapped to its crop-mode face; - // confirm by waiting for the Apply button before we drive it. - await expect(page.getByRole('button', { name: 'Apply crop' })).toBeVisible(); - - // Step 7 — Apply the crop. We don't modify the crop rect — - // this is an IDENTITY crop, which still pushes a real - // history entry (the element gets a crop attribute set even - // though sx/sy=0 and sWidth/sHeight match naturalW/H). The - // headline bug doesn't depend on a meaningful crop; it - // depends on undo restoring the pre-crop snapshot. - await applyCropWithRetry(page); - - // Step 8 — Sanity: still one element on canvas (the crop - // committed without removing the image). - expect(await getElementsCount(page)).toBe(1); - - // Step 9 — THE BUG CHECK. Cmd/Ctrl+Z must walk history back - // to the pre-crop snapshot. Pre-fix, this would land on an - // empty canvas (element count → 0); post-fix, the element - // is still there. - await pressUndo(page); - expect(await getElementsCount(page)).toBe(1); - - // Step 10 — Double check: the LayersPanel row for the - // image is still visible. A bug variant where the element - // exists in state but is invisible on canvas would slip - // past the count check alone; asserting the panel row - // catches the case where elements is still length 1 but - // populated with garbage. - await expect(page.locator('.layers-item-main')).toHaveCount(1); -}); - -test('cancelling crop leaves the original element untouched', async ({ page }) => { - // Sanity test for the cancel path. Not a regression check — - // just confirming the crop UI is reversible without going - // through history. If a future change accidentally pushes a - // history entry on Cancel (or worse, applies the crop - // anyway), this test catches it. - await page.getByRole('tab', { name: /upload/i }).click(); - await page.locator('input.ut__hidden-input').setInputFiles({ - name: 'pixel.png', - mimeType: 'image/png', - buffer: Buffer.from(TINY_PNG_BASE64, 'base64'), - }); - await expect.poll(() => getElementsCount(page)).toBe(1); - - // History-state baseline. After adding ONE element, history is - // `[empty, [img]]` at idx=1 — the Undo button is ENABLED - // ("step back to the empty floor"). We capture this enabled - // state so we can verify Cancel doesn't ADD another history - // entry on top. - // - // The trip wire isn't "Undo disabled" — it's enabled either - // way. The trip wire is the count of clickable history steps: - // a single Undo click should land us at the empty canvas - // (0 elements). If Cancel had pushed a history entry, Undo - // would walk back to that entry first (still 1 element, just - // a different snapshot) and only the SECOND undo would empty - // the canvas. We test the count-after-one-undo as the - // definitive check. - const undoButton = page.getByRole('button', { name: 'Undo' }); - await expect(undoButton).toBeEnabled(); - - await page.locator('.layers-item-main').first().click(); - await page.getByRole('button', { name: /crop image/i }).click(); - await expect(page.getByRole('button', { name: 'Apply crop' })).toBeVisible(); - - // Cancel rather than Apply. The toolbar should drop back to - // its non-crop face; the element stays as it was. - await page.getByRole('button', { name: 'Cancel' }).click(); - await expect(page.getByRole('button', { name: 'Apply crop' })).not.toBeVisible(); - - // Element count unchanged. - expect(await getElementsCount(page)).toBe(1); - - // The definitive "Cancel didn't push history" check. ONE undo - // step should reach the empty floor. If Cancel had pushed a - // (no-op) entry, we'd undo to that intermediate state and still - // have 1 element here. - await undoButton.click(); - expect(await getElementsCount(page)).toBe(0); -}); diff --git a/e2e/fixtures/editor.js b/e2e/fixtures/editor.js deleted file mode 100644 index 7bd97dd..0000000 --- a/e2e/fixtures/editor.js +++ /dev/null @@ -1,281 +0,0 @@ -// Shared Playwright helpers for the editor tests. -// -// What's here -// ─────────── -// • `gotoFreshEditor(page)` — navigate to the editor with a clean -// localStorage and a freshly-unregistered service worker, so each -// test starts from a known empty canvas regardless of state left -// behind by previous tests or local browsing. -// -// • `addTextViaSidebar(page, { text })` — drive the Text tab to add -// a text element. Used by multiple specs so the pattern lives in -// one place. -// -// • `getElementsCount(page)` — read the LayersPanel's "Layers (N)" -// title to assert how many elements exist on the canvas. The -// panel is the canonical user-visible source of truth for that -// count. -// -// • `pressUndo(page)` / `pressRedo(page)` — Cmd+Z / Cmd+Shift+Z -// wrappers that send the same keystrokes the user would. Modifier -// is meta on macOS, control elsewhere; we detect via the -// userAgent. (The app's keyboard handler accepts either modifier -// so we could hardcode one, but using the platform-correct one -// keeps the tests honest about what real users do.) -// -// Why these live here rather than inline in each spec -// ─────────────────────────────────────────────────── -// Three reasons. (1) Several specs need fresh-canvas setup; if it -// drifts inline-in-each-test, "fresh" stops meaning the same thing -// across the suite. (2) The localStorage + service-worker reset is -// non-obvious and easy to skip; centralising it ensures every test -// gets the full reset rather than just the obvious bits. (3) When -// the app's selectors change (e.g. someone renames the LayersPanel -// title), one edit here updates every test rather than touching each -// spec file. - -import { expect } from '@playwright/test'; - -/** - * Navigate to the editor with a fully fresh state. - * - * The order matters: - * 1. goto('/') first so we have a page context to evaluate against. - * Without this, `evaluate` has nowhere to run. - * 2. Clear localStorage AND unregister any service workers AND - * clear caches. The PWA's service worker caches static assets; - * stale caches between test runs can mask real regressions - * (e.g. an app code change that breaks the editor but a cached - * SW returns the old version). - * 3. Reload to apply the wipe — the first goto loaded the app - * against whatever state was there, so step 2's wipe wouldn't - * affect the current page without a reload. - * 4. Wait for the canvas to mount before returning. The Konva - * Stage takes a few frames after the first paint to be ready - * for interaction; clicking buttons before then can race. - * - * The wait targets are: - * • `.canvas-area` — the DOM container is present immediately - * once React mounts. - * • A document-fonts-ready check — without this, text-related - * assertions can race against the woff2 fetches that - * useFontsReady waits for. measureTextWidth returns - * fallback-font values until the real fonts arrive. - */ -export async function gotoFreshEditor(page) { - // Step 1: land on the editor so we have storage access. - await page.goto('/'); - - // Step 2: wipe persisted state + SW caches. - await page.evaluate(async () => { - try { localStorage.clear(); } catch { /* ignore */ } - try { sessionStorage.clear(); } catch { /* ignore */ } - // Unregister all service workers. The app's PWA registers one - // on first load; without unregistering, a SW from a previous - // test run can serve stale assets. - if ('serviceWorker' in navigator) { - const regs = await navigator.serviceWorker.getRegistrations(); - await Promise.all(regs.map((r) => r.unregister())); - } - // Wipe Cache API entries (the SW's storage layer). - if ('caches' in window) { - const keys = await caches.keys(); - await Promise.all(keys.map((k) => caches.delete(k))); - } - }); - - // Step 3: reload to apply the wipe. - await page.reload(); - - // Step 4: wait for the editor's primary surfaces. - // canvas-area exists once App.jsx renders. The stage's - // tag is inside but doesn't need to be specifically awaited — - // it's children of canvas-area. - await page.locator('.canvas-area').waitFor({ state: 'visible' }); - - // Wait for fonts so the text-width measurements settle. This - // mirrors what useFontsReady does inside the app — both run - // until document.fonts.ready resolves. Without it, - // placeTextCentered uses fallback widths and the placement- - // assertion in tests can be off by tens of pixels. - await page.evaluate(() => document.fonts.ready); -} - -/** - * Add a text element via the Text tab. Returns once the new element - * has appeared in the LayersPanel — at which point it's safe to - * make assertions about its presence on the canvas. - * - * The flow is: - * 1. Click the Text tab in the sidebar to make sure it's active. - * In a fresh editor the default tab is Upload, so we have to - * switch. - * 2. Type into the "Your message" textarea, replacing the - * pre-populated "Your text here" draft. - * 3. Click "Add text to canvas". - * 4. Wait for the Layers panel to show the new layer. - * - * We don't change fontFamily / fontSize / fill here — they default - * to whatever the draft has (DEFAULT_DRAFT in TextTab.jsx). Tests - * that care about a specific font / size can call the relevant - * controls afterward. - */ -export async function addTextViaSidebar(page, { text }) { - // Ordering matters: deselect FIRST, switch to Text tab SECOND. - // ──────────────────────────────────────────────────────────── - // On desktop, App.jsx derives `editingTextElement` directly from - // `selectedElement?.type === 'text'`, AND it has an auto-tab-switch - // effect that runs on every text-selection transition: - // - // text → not-text : auto-switches sidebar from 'text' → 'upload' - // anything → text : auto-switches sidebar to 'text' - // - // So after a previous addTextViaSidebar: - // • A text element is selected. - // • The sidebar is on the 'text' tab. - // • TextTab is rendered in EDIT MODE (the Add button is gone, - // replaced by fields editing the selected element). - // - // The naive ordering (click Text tab → deselect → wait for Add - // button) doesn't work, because the deselect ALSO fires the - // auto-tab-switch which snaps the sidebar back to 'upload'. The - // Text tab unmounts entirely; the Add button isn't hidden, it's - // gone with the rest of the tab. The waitFor times out looking - // for a button that doesn't exist anywhere on the page. - // - // Correct ordering: deselect FIRST (sidebar auto-snaps to 'upload', - // any prior selection clears), THEN click the Text tab to switch - // back into it. Our explicit click is the LAST tab interaction, - // so it wins; TextTab mounts in draft mode (no selected text - // element to bind to) and renders the Add button. - // - // Deselection strategy - // ──────────────────── - // We tried three approaches; only the third works, and the journey - // is documented so a future maintainer doesn't repeat it. - // - // 1. Press Escape. App.jsx wires Escape to `deselectAll`, but the - // keyboard handler has an input-gate early return on INPUT / - // TEXTAREA targets. After Playwright's previous `.fill()` the - // textarea owns focus, so bubbled keydown's `e.target` is the - // textarea and the handler bails. Programmatic blur didn't - // help — Playwright tracks focus internally and routes - // keystrokes through that internal state, not document.activeElement. - // - // 2. Click `.canvas-area` at `{x:5, y:5}`. App.jsx's mousedown - // deselect handler skips clicks inside the Konva stage container - // (stageContainerRef). The stage container fills most of - // canvas-area, so corner positions land inside it and the - // handler bails. The pink-frame deselect zone is narrower than - // it looks from the docblock alone. - // - // 3. (Current) Dispatch a real `mousedown` event with the - // canvas-area element itself as `e.target`. The handler's - // early-return checks pass cleanly: canvasRef.contains(canvas-area) - // is true (a node contains itself), and none of the excluded - // refs (toolbar / zoom / stage container) CONTAIN their - // ancestor canvas-area. The handler proceeds to deselectAll, - // which clears selectedId AND triggers the auto-tab-switch to - // 'upload'. - const addButton = page.getByRole('button', { name: 'Add text to canvas' }); - - // Cheap selection probe: if there's no LayersPanel content (count is - // zero) OR the Text tab isn't even active, there's no selection to - // clear and we can skip the deselect path entirely. The probe avoids - // a 300ms isVisible wait on every call when we'd just no-op. - const currentCount = await getElementsCount(page); - if (currentCount > 0) { - // Deselect via a dispatched mousedown on canvas-area itself. This - // is the only reliable path; see strategy notes above. - await page.evaluate(() => { - const area = document.querySelector('.canvas-area'); - if (!area) return; - const event = new MouseEvent('mousedown', { - bubbles: true, - cancelable: true, - view: window, - }); - area.dispatchEvent(event); - }); - } - - // Now switch to the Text tab. After the deselect above, sidebar - // is on 'upload' (auto-snapped); our explicit click moves it to - // 'text' for the add. TextTab mounts in draft mode and renders - // the Add button. - await page.getByRole('tab', { name: /text/i }).click(); - - // Wait for the Add button. With the correct tab+deselect order, - // this should be near-instant. - await addButton.waitFor({ state: 'visible', timeout: 5000 }); - - // Fill the textarea. The label is "Your message" per TextTab.jsx - // (``). - const textarea = page.getByLabel('Your message'); - await textarea.fill(text); - - // Capture the current layer count so we can wait for it to - // increment. Reading the title text avoids us having to count - // rendered
  • rows, which can be slower to read than the - // pre-aggregated count in the title. - const before = await getElementsCount(page); - - // Step 3: submit. The button is exposed by its visible text. - await addButton.click(); - - // Step 4: wait for the count to increment. expect.poll handles - // the race between the click and the React commit + Konva mount. - await expect.poll(() => getElementsCount(page)).toBe(before + 1); -} - -/** - * Read the current element count from the LayersPanel title. - * Returns 0 when no layers exist (the panel renders an empty-state - * banner instead of the "Layers (N)" title in that case). - * - * The panel's title is `Layers (N)` per LayersPanel.jsx: - *

    Layers ({elements.length})

    - * - * We parse the N out of the title rather than counting
  • - * elements because the panel's empty state doesn't render any - *
  • s OR the title — we'd need two different code paths for - * "zero" vs "non-zero" if we counted rows. - */ -export async function getElementsCount(page) { - // Title may not exist if the panel is showing its empty state. - // Use .count() rather than waitFor to make this non-blocking. - const title = page.locator('.layers-title'); - if ((await title.count()) === 0) return 0; - const text = await title.first().textContent(); - // Title format: "Layers (N)". Extract N. Defensive fallback to 0 - // if the format changes (test still fails downstream with a - // useful message rather than throwing here). - const match = /\((\d+)\)/.exec(text || ''); - return match ? parseInt(match[1], 10) : 0; -} - -/** - * Send a Cmd+Z (macOS) / Ctrl+Z (other) keystroke. The app's - * keyboard handler in App.jsx accepts either modifier, but using - * the platform-correct one keeps the test mirror of real user - * behaviour. - * - * The keystroke is sent to `document.body` (Playwright's default - * for page.keyboard.press) rather than a specific element. The - * app's handler is attached to `window`, so the focus target - * doesn't matter — as long as it isn't inside an or - *