Major update for v1 and tests

This commit is contained in:
khalid@traclabs.com
2026-05-23 03:28:58 -05:00
parent 628a6765f4
commit b1fb6fa3aa
1748 changed files with 27723 additions and 1854 deletions

8
.gitignore vendored
View File

@@ -14,3 +14,11 @@ uploads/*
!uploads/.gitkeep !uploads/.gitkeep
exports/* exports/*
!exports/.gitkeep !exports/.gitkeep
# Server-side font files — fetched on demand via `npm run fetch-fonts`. We don't
# commit them because TTFs are large binaries and the source is reproducible
# from the script. The Dockerfile runs the fetch at build time so production
# images carry the fonts.
fonts/*
!fonts/.gitkeep
!fonts/README.md

View File

@@ -6,20 +6,37 @@ WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm install RUN npm install
COPY . . COPY . .
# Pull Google Fonts into ./fonts/ so they end up in the runtime image. If the
# build host has no network this step will produce missing-variant warnings;
# the build still succeeds and the server falls back to system fonts.
RUN npm run fetch-fonts || echo "Font fetch had warnings; continuing with whatever was downloaded."
RUN npm run build RUN npm run build
FROM node:20-alpine FROM node:20-alpine
RUN apk add --no-cache cairo-dev pango-dev libjpeg-turbo-dev giflib-dev librsvg-dev pixman-dev python3 make g++ # Cairo + Pango are required by node-canvas at runtime.
# The font packages cover the proprietary template families (Impact, Times,
# Courier, Arial) via free equivalents that fontconfig will alias, plus emoji
# rendering for sticker exports.
RUN apk add --no-cache \
cairo pango libjpeg-turbo giflib librsvg pixman \
ttf-liberation ttf-dejavu font-noto font-noto-emoji \
fontconfig
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm install --omit=dev && apk del python3 make g++ RUN npm install --omit=dev
COPY server.js ./ COPY server.js ./
COPY --from=builder /app/dist ./dist COPY --from=builder /app/dist ./dist
COPY --from=builder /app/fonts ./fonts
RUN mkdir -p /app/uploads /app/exports RUN mkdir -p /app/uploads /app/exports
# Refresh fontconfig's cache so the newly-copied custom fonts are discoverable.
RUN fc-cache -f /app/fonts || true
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3001/api/health || exit 1 CMD wget --no-verbose --tries=1 --spider http://localhost:3001/api/health || exit 1

View File

@@ -31,6 +31,11 @@ T-shirt customization editor with drag-and-drop design, background removal, and
# Install dependencies # Install dependencies
npm install npm install
# Optional but recommended for accurate exports: fetch the Google Fonts the
# editor uses, so node-canvas can render them server-side. Skipping this means
# exports fall back to whatever fonts the host system provides.
npm run fetch-fonts
# Start development (client on :3000, server on :3001) # Start development (client on :3000, server on :3001)
npm run dev npm run dev
@@ -53,6 +58,8 @@ apparel-designer/
├── vite.config.js # Vite + PWA config ├── vite.config.js # Vite + PWA config
├── package.json # Single package — all deps ├── package.json # Single package — all deps
├── index.html # Entry HTML with Google Fonts ├── index.html # Entry HTML with Google Fonts
├── scripts/
│ └── fetch-fonts.mjs # Downloads TTFs for server-side rendering
├── src/ ├── src/
│ ├── main.jsx # React entry + SW registration │ ├── main.jsx # React entry + SW registration
│ ├── App.jsx # Root layout (sidebar / canvas / properties) │ ├── App.jsx # Root layout (sidebar / canvas / properties)
@@ -68,9 +75,10 @@ apparel-designer/
│ ├── hooks/ # useDesignEditor, useBackgroundRemoval, useExport, useTemplate │ ├── hooks/ # useDesignEditor, useBackgroundRemoval, useExport, useTemplate
│ └── constants/ # fonts, stickers, templates │ └── constants/ # fonts, stickers, templates
├── public/ # Favicon, PWA icons ├── public/ # Favicon, PWA icons
├── fonts/ # Server-side TTFs (gitignored, populated by fetch-fonts)
├── uploads/ # User uploads (gitignored) ├── uploads/ # User uploads (gitignored)
├── exports/ # Exported PNGs (gitignored) ├── exports/ # Exported PNGs (gitignored)
├── docs/ # Template JSON schema ├── docs/ # Bug log, suggestions, change notes, template schema
├── Dockerfile ├── Dockerfile
└── docker-compose.yml └── docker-compose.yml
``` ```

126
docs/BUGS.md Normal file
View File

@@ -0,0 +1,126 @@
# Apparel Designer — Bug Report
This is the active bug tracker. New regressions or freshly discovered defects belong here; once a bug is fixed, it moves to `CHANGES.md` (which is the historical record) and is removed from this file.
---
## 🔴 Critical
_None outstanding._
## 🟠 Major
### B1 — `Cmd/Ctrl+D` on a multi-selection produces N undo entries
**File:** `src/App.jsx` (keyboard-shortcut effect, around the Cmd+D branch)
The handler iterates the selection set and calls `duplicateElement(id)` once per id. Each `duplicateElement` call commits its own history entry, so duplicating 3 selected elements produces 3 separate undo steps — the user has to press Cmd-Z three times to "undo the duplicate" they just did. The single-select path is correct (one duplicate → one undo); only multi-select is wrong.
Fix shape: add `duplicateMany(ids)` to `useDesignEditor` that mirrors `deleteMany`'s "single history entry, set selection to the new ids" pattern, and have the Cmd+D handler call that when `selectedIds.size > 1`.
## 🟡 Minor
### B2 — `[` / `]` z-order shortcuts are inert in multi-select mode
**File:** `src/App.jsx`
The handler is gated on `selectedId` (the single-select derived value, which is `null` whenever `selectedIds.size > 1`). With multiple elements selected, `[` and `]` do nothing. Arrow-key nudging has the same gap — it operates on `selectedId` only.
Reasonable interpretations:
- Iterate the selection (each element moves one step in z-order) — note that for adjacent selected layers this can produce confusing reorderings.
- Treat the multi-selection as a contiguous block and shift it as a unit.
Either is a real call; the current behavior (silent no-op) is the worst.
A related stale-comment issue: the same handler still carries a comment "Cmd/Ctrl+A 'select all' is intentionally NOT bound here … makes sense once multi-select lands (S3)". S3 has now landed, so the comment misrepresents current state.
### B3 — `ImageElement.jsx` filter `useEffect` over-invalidates on width/height changes
**File:** `src/components/canvas/ImageElement.jsx`
The effect's dep array is `[filter, src, width, height]`, but the body only references `filter` and `src`. width/height are listed because Konva's filter cache is dimension-sensitive — but Konva ALSO re-caches automatically when the node's size changes, so listing them in the deps causes a redundant `node.cache()` + `clearCache()` cycle on every resize. During a continuous transformer drag this fires once per intermediate `onUpdate`, which on filtered images becomes a noticeable hot path.
Fix shape: drop width/height from the deps and rely on Konva's built-in re-cache. Verify with a brief drag of a filtered image that the filter still updates correctly.
### B4 — `useBackgroundRemoval.removeBackground` early-return doesn't toggle `loading`
**File:** `src/hooks/useBackgroundRemoval.js`
When `loadModel()` fails (network error fetching the 86MB model), the early-return path returns `null` without calling `setLoading(false)`. `loadModel` itself does call `setLoading(false)` in its catch block, so today the user-visible state is consistent — but the contract between the two functions is fragile: if `loadModel` is ever changed to return false without resetting loading, this caller silently strands the spinner.
Fix shape: defensive `setLoading(false)` in the early-return path of `removeBackground`.
### B5 — `useBackgroundRemoval` progress is non-monotonic and confusing
**File:** `src/hooks/useBackgroundRemoval.js`
Model-download phase reports 050 (scaled from the HF progress callback). Then `removeBackground` jumps straight to `setProgress(50)`, then `70`, `90`, `100` at fixed pipeline checkpoints. The 50→70→90 jumps are visible to the user as a stuttering bar that pauses, leaps, pauses, leaps. It still finishes — just looks broken.
Fix shape: either (a) replace the progress bar with an indeterminate spinner once the model is loaded — the per-image inference is fast enough that fake-stepping doesn't help, or (b) genuinely time-budget the post-load steps and report linearly.
### B6 — `UploadTab.jsx` uses bare `alert()` for validation errors
**File:** `src/components/sidebar/UploadTab.jsx`
Three `alert()` calls — file-type rejection, file-size rejection, upload-failure — bypass the app-wide toast system that #19 introduced. These are the only `alert()` calls left in the codebase. The toast system has the right "info / error" kinds for these messages.
Fix shape: thread `showToast` into UploadTab (via Sidebar prop drilling, or via a context) and replace the `alert` calls. Note: this IS a user-visible change (alert vs toast looks different) — log it here rather than fix immediately, per the "no user-facing changes" instruction.
### B7 — `UploadTab.placeImage` hard-codes 300 for canvas centering
**File:** `src/components/sidebar/UploadTab.jsx`
The drop-zone path computes `(300 - width) / 2` and `(300 - height) / 2` to center an uploaded photo. 300 is the current `canvasSize`, but `getActiveProduct().canvasSize` is the source of truth. Drift risk: if the active product is ever swapped to one with a different canvas size, uploaded photos won't center correctly.
Fix shape: `import { getActiveProduct } from '../../constants/products'` and read `canvasSize` once at module top (or per-call).
### B8 — `OfflineIndicator` initializes state without an SSR guard
**File:** `src/components/OfflineIndicator.jsx`
`useState(!navigator.onLine)` runs at module-execution time inside the function component. There's no guard for a missing `navigator`. The app isn't SSR'd today, but this is the pattern that breaks first if anyone tries — and it's gratuitous; the rest of the codebase consistently guards (`typeof window === 'undefined'`).
Fix shape: `useState(() => typeof navigator !== 'undefined' ? !navigator.onLine : false)`.
### B9 — `SlotPlaceholder` mouseLeave forces `cursor: 'default'`
**File:** `src/components/canvas/SlotPlaceholder.jsx`
`handleMouseLeave` sets `stage.container().style.cursor = 'default'`. This stomps on whatever cursor was inherited (e.g. another shape that sets `'pointer'` via its own enter handler may not get to restore on the next move event because we just hard-set 'default'). The standard idiom is `'auto'` or `''` to clear the inline style and fall back to the inherited cascade.
In practice the bug is hard to trigger today (the only other shape-level cursor handler IS this same one), so it's classified Minor — but it's a latent foot-gun.
### B10 — `persistence.js` `stripUnpersistable` filter narrowness
**File:** `src/utils/persistence.js`
The filter only inspects `type === 'image'` elements. Today stickers (`type: 'sticker'`) have data URLs, not blob URLs, so this works. But the contract isn't symmetric with reality — if any future code path produces a sticker with a `blob:` src (e.g. a sticker authored from a blob URL on first paste), it won't be filtered, and reload will produce a broken sticker.
Fix shape: filter on src URL prefix, not type — `if (typeof el.src === 'string' && el.src.startsWith('blob:')) drop`. Same intent, narrower-than-needed gate today.
### B11 — `useExport` progress simulator is misleading
**File:** `src/hooks/useExport.js`
`progressInterval` advances `progress` 0→90 in 10% steps every 200ms while the export is in flight, regardless of actual upload/render progress. On a fast export, the user sees 30% before the request even arrives at the server. On a slow export, the bar pegs at 90% and waits silently for whatever's actually slow.
Two real fixes:
1. Wire to fetch's upload-progress events (XHR has them; fetch+ReadableStream support is patchy but adequate for our use case). Server-render time is opaque from the client, so progress will still cap at "uploaded" and then sit indeterminate.
2. Replace the bar with an indeterminate spinner — honest about the fact that we don't know how far along the server is.
### B12 — `handleShare` has hardcoded English strings not in the i18n catalog
**File:** `src/App.jsx` (`handleShare`)
`'My Pawfectly Yours design'` (share title) and `'Check out the shirt I made!'` (share text) are passed directly to `navigator.share`. The S23 catalog covers toast messages and button labels but missed these because they don't render in the DOM — they go straight to the OS share sheet. Should be added to the catalog and pulled via `t()`.
Same applies to several inline strings in App.jsx render — FAB aria-labels (`'Close customization options'` / `'Open customization options'`), export-toast verbiage (`'Saving design…'`, `'⚠️ Save failed:'`, `'✅ Saved!'`, `'Download'`, `'Dismiss'`). All logged collectively under the S10 i18n-sweep follow-on, but B12 specifically calls out the share strings since they're easy to miss in a DOM-text grep.
---
## Closed since the May 2026 audit
The original May 2026 audit identified twelve critical (C1C12), seven major (M1M7), and nine minor (m1m9) bugs. **All of them have been fixed.** See `CHANGES.md` for the per-bug summary, the files touched, and the verification steps. The performance contract for the drag/rotate hot path (M4) is the most operationally important section — it documents the constraints any future change to `DesignCanvas.jsx`'s bound functions must respect.
Subsequent fix rounds (the polish/UX work tracked outside this file under issue numbers like #1#35) are also captured in `CHANGES.md`. Items addressed there but originally listed in `SUGGESTIONS.md` have been removed from that file too.
---
## Notes on items deliberately not flagged as bugs
These are observations from the audit that were *not* bugs at the time and are kept here as ongoing watch-items so future contributors don't re-audit ground we've already considered.
- **Express 4 wildcard routes** (`app.all('/api/*')`, `app.get('*')`) are correct for the pinned `^4.18.2`. These would break under Express 5's path-to-regexp v6 and are worth revisiting if/when the project upgrades, but they aren't bugs today.
- **`canvas` package version `^2.11.2`** is older than the current 3.x line. 2.x is still maintained and works; mentioning here only for transparency.
- **CORS in dev allows all origins** — intentional, gated on `IS_PRODUCTION`. Not a bug.
- **`saveToHistory` JSON-stringify dedupe** — uses string equality on `JSON.stringify(elements)`, which is sensitive to numeric formatting (`0` vs `0.0`, key ordering between `Object.assign` results, etc.). In practice React's setState updaters produce stable object shapes so this is fine, but if a future code path constructs elements differently, identical-content snapshots could end up as separate history entries. Worth keeping aware of, not worth fixing speculatively.
- **`useBackgroundRemoval.hasModel`** — returned from the hook but ignored by every consumer (only `BackgroundRemovalButton` calls the hook). Not a user-visible bug; logged as S21 in suggestions for cleanup.
- **`useTemplate.templateRef`** — set in `loadTemplate` and `clearTemplate` but never read anywhere. Dead state; logged as S22 in suggestions.

436
docs/CHANGES.md Normal file
View File

@@ -0,0 +1,436 @@
# Apparel Designer — Bug Fixes
This document summarizes the code changes that fix the bugs documented in `BUGS.md`. All twelve critical (C1C12), seven major (M1M7), and nine minor (m1m9) bugs are addressed.
## Files changed / created
| File | Bugs addressed |
|------|----------------|
| `server.js` | C1, C2, C3, C4, C5, C6, M1 |
| `src/hooks/useExport.js` | C7 |
| `src/hooks/useTemplate.js` | C8, M2, M3 |
| `src/hooks/useDesignEditor.js` | (supports C9, C10), M7, m1 |
| `src/App.jsx` | C9, C10, C11, m9 |
| `src/App.css` | m4 |
| `src/index.css` | m9 |
| `src/main.jsx` | M5 |
| `src/components/canvas/DesignCanvas.jsx` | M2 cleanup, M3, **M4 (perf-critical)** |
| `src/components/PWAInstall.jsx` | M5 |
| `src/components/panels/LayersPanel.jsx` | m8 |
| `src/components/sidebar/UploadTab.jsx` | C12 |
| `src/components/sidebar/StickersTab.jsx` | m7 |
| `src/components/sidebar/BackgroundRemovalButton.jsx` | m3 |
| `src/constants/stickers.js` | m7 |
| `index.html` | m6 |
| `vite.config.js` | M5 |
| `package.json` | M1, M6 |
| `Dockerfile` | M1 |
| `.gitignore` | M1 |
| `README.md` | M1 |
| `scripts/fetch-fonts.mjs` | **new** — M1 |
| `fonts/README.md`, `fonts/.gitkeep` | **new** — M1 |
---
## Critical bug summaries
### `server.js`
The export pipeline was the most damaged area. All six server-side criticals landed here.
- **C1 — fontSize formula.** Removed the stray `/ 32` divisor, so `fontSize * EXPORT_SCALE` matches the editor's pixel-to-export-pixel scaling.
- **C2 — rotation pivot.** Translate to `(x, y)` before rotating and back after, matching how Konva rotates around the node origin.
- **C3 — text placement.** `textAlign='left'`, `textBaseline='top'`, draw at `(x, y)` directly. No more synthetic `centerX/centerY` derived from `fontSize`. Default fallback fontFamily is now `'DM Sans'` (matching the editor — see also m5).
- **C4 — Vite dev port.** Redirect target uses `process.env.VITE_DEV_PORT || 3000`.
- **C5 — download path traversal.** `req.params.filename` passes through `path.basename` (strips directory components), then resolved-path bounds-check against `exportsDir`.
- **C6 — export src path traversal.** New `safeUploadPath()` helper requires `/uploads/` prefix and verifies the resolved path stays inside `uploadsDir`. Applied to element `src` and template background. `data:` URLs (emoji stickers) pass through unchanged.
### `src/hooks/useExport.js` (C7)
`progressInterval` declared in outer scope; cleared in a `finally` block. Error JSON parse wrapped in `.catch(() => ({}))` so a non-JSON error response doesn't shadow the original error.
### `src/hooks/useTemplate.js` (C8)
`assignImageToSlot` is now `async`, awaits the image load, computes the crop, and only then constructs and returns `elementData`. Caller is async too, with user-visible error on load failure.
### `src/App.jsx` (C9, C10, C11)
`handleAddTemplate` calls `replaceElements` (one state update, one history entry, no `setTimeout`). `PhotoPreEditor.onComplete` revokes the previous `src` if it's a `blob:` URL, after the new image is loaded so the revoke doesn't race the decode.
### `src/components/sidebar/UploadTab.jsx` (C12)
`fileInputRef.current.value = ''` in a `finally` block.
---
## Major bug summaries
### M1 — Server font registration
**The big one.** Without this, every server-side text export silently substituted a system font for the editor's Google Fonts.
- **`scripts/fetch-fonts.mjs`** (new) — downloads TTFs from the Fontsource jsDelivr CDN for all 22 families used by the editor, at weights 400 + 700. Idempotent (skips existing files unless `--force`); graceful when a variant isn't published. Filename convention `Family_Name-Variant.ttf` (underscore for spaces, suffix `Regular`/`Bold`/`Italic`).
- **`server.js`** — imports `registerFont` from canvas. New `registerFontsFromDir(dir)` runs at module load (before any canvas is created — `registerFont` is a no-op after that point). Parses each filename via `lastIndexOf('-')`, infers `weight: 'bold'` from `/Bold/i` and `style: 'italic'` from `/Italic/i`. Logs a tally and gracefully handles a missing directory.
- **`Dockerfile`** — builder stage runs `npm run fetch-fonts`; runtime stage `apk add cairo pango libjpeg-turbo giflib librsvg pixman ttf-liberation ttf-dejavu font-noto font-noto-emoji fontconfig`, copies `/app/fonts`, runs `fc-cache -f /app/fonts`.
- **`.gitignore`** — `fonts/*` excluded except `.gitkeep` and `README.md`.
- **`fonts/README.md`** documents the convention and how to populate the directory.
- **`package.json`** — `"fetch-fonts": "node scripts/fetch-fonts.mjs"` script.
- **`README.md`** — added `npm run fetch-fonts` to the setup steps.
### M2 — Dead `getDragBoundFunc` removed
`createDragBoundFunc` and `getDragBoundFunc` deleted from `useTemplate.js`. Prop plumbing through `App.jsx` and `DesignCanvas.jsx` removed.
### M3 — Slot occupancy single-source-of-truth
`assignedSlots` state removed from `useTemplate`. `DesignCanvas` derives `occupiedSlotIds` via `useMemo` from `elements.some(e => e.slotId === slot.id)`. Deleting a slot-bound element auto-empties the slot. Hook returns: `currentTemplateId, currentTemplate, loadTemplate, clearTemplate, getSlots, assignImageToSlot`.
### M4 — Performant rotation/transform bounds *(performance-critical)*
This is the change that runs on every pointer move during scale/rotate/drag, so the implementation is held to a strict performance contract. Both `boundBoxFunc` (`constrainTransform`) and `dragBoundFunc` (`canvasDragBound`) live in `DesignCanvas.jsx`.
**Performance contract — `constrainTransform` (`boundBoxFunc`):**
1. **Allocation-free hot path.** No array spreads, no `Math.min(...arr)` (which spreads to a varargs call and allocates), no destructuring per call. Eight corner coordinates are tracked as eight scalar locals; min/max are computed with `if` cascades.
2. **In-bounds returns the same `newBox` reference.** The most common case during a transform is "still inside the canvas" — that path computes the rotated AABB and falls through to `return newBox`. No object allocation.
3. **Rotation-changed branch (the user is dragging the rotation handle):**
- 4 sliding-offset comparisons (`if (minX < 0) dx = -minX; ...`)
- 4 sliding-validity comparisons against the canvas bounds + `BOUNDS_EPSILON`
- If the slide doesn't fit: return `oldBox` (caller's previous box). One comparison decides.
- If the slide does fit and is non-zero: allocate one box object. Otherwise return `newBox`.
4. **Scale-only branch (rotation unchanged):**
- Linear-interpolation algorithm: corners are linear in `t` at constant rotation, so the largest valid `t∈(0, 1]` is found in 8 axis-checks (one per corner), each constant time.
- The inner `checkCorner` is a closure that captures `maxT` by reference. It's allocated once per `constrainTransform` call (outside any loop), not per corner — JS engines elide that allocation under V8's escape analysis in practice.
- Result allocates one box object only when `maxT < 1`.
5. **`BOUNDS_EPSILON = 0.5`** absorbs Konva's sub-pixel transformer rounding. Without this, the function rejects boxes that are visually inside but numerically a hair outside.
**Performance contract — `canvasDragBound` (`dragBoundFunc`):**
1. **Reads node attrs via `this`** — Konva binds `this` to the node, so `this.width()`, `this.height()`, `this.rotation()` are direct property reads.
2. **Single trig pair**`cos`/`sin` computed once.
3. **Eight scalar comparisons** for the rotated AABB min/max.
4. **Single `{x, y}` object allocation** — Konva's API requires a fresh object as the return.
**What this means for users on slow machines:**
The hot path of `constrainTransform` is roughly 20 numeric operations and 4 conditional branches with no allocation; on a 2017-era mobile CPU at ~1 GHz that's well under a microsecond per call. Even at a sustained 120 Hz pointer rate during a rotation, the bound function consumes a fraction of a percent of a frame budget. The drag bound function is similar. There is no rendering work in either path — they only compute the constrained position/box that Konva then applies to the underlying node, which is the work that would happen anyway.
The implementation **does not allocate per pointer event** in the common case (in-bounds) and allocates a single small object in the constrained case. Garbage collection during a drag is therefore not an issue.
`ImageElement.jsx` wraps `transformBoundFunc` in a tiny inline arrow (to add the `< 20px` minimum-size short-circuit). That allocation happens once per render of the selected element, not per pointer event — and `ImageElement` doesn't re-render during a transform (state only commits on `onTransformEnd`). `TextElement.jsx` passes `transformBoundFunc` straight through with no wrapper.
### M5 — Proper PWA registration
- **`vite.config.js`**: `injectRegister: false` so the plugin doesn't auto-inject a registration script.
- **`src/main.jsx`**: removed the hand-rolled `navigator.serviceWorker.ready` listener.
- **`src/components/PWAInstall.jsx`**: now uses `useRegisterSW` from `virtual:pwa-register/react`. `needRefresh` and `updateServiceWorker(true)` drive the update banner directly. Install banner (`beforeinstallprompt`) flow unchanged.
### M6 — Direct deps for `styled-components` and `@emotion/is-prop-valid`
Added to `package.json` dependencies at versions matching the existing transitive resolution.
### M7 — `saveToHistory` dedupe
`useDesignEditor.js`: early return when `JSON.stringify(newElements) === historyRef.current[historyIndexRef.current]`. Catches React StrictMode's double-invoked updaters in dev without restructuring `addElement`. Also a correctness improvement outside StrictMode — calling `saveToHistory` with no actual change is a no-op rather than a wasted history slot.
The new `replaceElements` was written without the anti-pattern from the start (it doesn't read previous state, so it can do `setElements(next); saveToHistory(next);` outside any updater).
---
## Minor bug summaries
| Bug | Resolution |
|---|---|
| **m1** | `useDesignEditor.js`: `substr(2, 9)``slice(2, 11)`. |
| **m2** | Resolved by C4. |
| **m3** | `BackgroundRemovalButton.jsx`: removed the outer `loadModel` call (the inner one in `removeBackground` is sufficient). |
| **m4** | `App.css`: `@media (max-width: 768px), (max-height: 700px)` switches header/toolbar/layers panel from `position: absolute` to `position: static`. |
| **m5** | Resolved by C3. |
| **m6** | `index.html`: consolidated two `<link>` tags into one alphabetized URL that includes DM Sans + Space Mono. |
| **m7** | `constants/stickers.js`: `STICKER_CATEGORIES` no longer includes `'all'`. `StickersTab.jsx` defines a local `FILTER_CATEGORIES = ['all', ...STICKER_CATEGORIES]`. |
| **m8** | `LayersPanel.jsx`: removed `.substring(0, 20)`. CSS `text-overflow: ellipsis` on `.layers-item-name` decides where to clip. |
| **m9** | `App.jsx` + `index.css`: green `.export-success` banner with "Download again" link when `exportUrl && !exporting && !error`. Dismissible via `clearExport`. |
---
## Verifying the fixes
A smoke-test plan, since there are no automated tests:
### Critical
1. **Text export size (C1, C3).** Add a 48px text. Export. Open the resulting PNG; the text should be visually large (~720px tall on the 4500×4500 canvas), placed near where the editor showed it.
2. **Rotation (C2).** Apply the "Gradient Vibes" template. Export. The "GOOD" and "VIBES" labels should appear rotated by their template-specified -5° / +5° around their top-left corners.
3. **Dev redirect (C4).** Run `npm run dev`, visit `http://localhost:3001/`. Should redirect to `http://localhost:3000/`.
4. **Path traversal (C5).** `curl -v 'http://localhost:3001/api/download/..%2F..%2F..%2Fetc%2Fpasswd'` returns `400 Invalid filename`.
5. **Path traversal (C6).** POST to `/api/export` with `elements: [{ type: 'image', src: '/uploads/../../etc/passwd', x: 0, y: 0, width: 100, height: 100 }]`. Export succeeds with the bad image silently skipped; PNG must not contain anything resembling `/etc/passwd`.
6. **Export interval cleanup (C7).** Stop the server, click "Export HD"; button returns to idle with a visible error rather than getting stuck on "Exporting…".
7. **Slot crop (C8).** Apply the "Team Sport" template. Click the "Number" slot, pick a tall portrait image. Image should be center-cropped to the slot's aspect ratio, not squashed.
8. **Template switch (C9).** Apply "Band Merch", then "Minimal Quote". Only the Minimal Quote elements should be visible. Press undo once — canvas should be empty.
9. **Photo editor leak (C11).** Open the photo editor on the same image five times in a row. `performance.memory.usedJSHeapSize` (Chrome with `--enable-precise-memory-info`) should not climb monotonically with each save.
10. **File input reset (C12).** Upload an image, delete it, click the upload tile, pick the *same* file. Upload should fire and the image should reappear.
### Major
11. **Font registration (M1).** Without `npm run fetch-fonts`: text exports silently fall back to a system font. With it: a "Bebas Neue" or "Oswald" text element exports in that exact font. Verify by overlaying the export at 1:6 scale on the editor preview — letterforms should match.
12. **Slot deletion sync (M3).** Apply "Team Sport", drop an image into a slot, delete that element from the layers panel. The slot placeholder should reappear, and dropping a new image in should work.
13. **Rotation bounds (M4).** Add a sticker. Rotate the rotation handle until the element would leave the canvas. The element should slide to stay in-bounds; if it can't fit at that rotation, the rotation should snap back. Drag the element fast around the edges — never leaves the 300×300 region. **Performance:** sustained 60+ fps during continuous rotate; no frame drops.
14. **PWA update (M5).** Build, serve, install. Re-build with a code change. Reload — the green "New version available" banner should appear and clicking Refresh should pick up the new code.
### Minor
15. **Re-upload after success (m9).** Export a design. Green banner appears with "Download again". Click — file downloads.
16. **Mobile layout (m4).** Resize the browser to 720×600. Header/toolbar/layers panel stack above the canvas instead of overlapping it.
---
## Performance regression watch
The only change that runs in a hot path is M4. Before merging any future change to `DesignCanvas.jsx`, confirm:
- `constrainTransform` returns `newBox` by reference when in-bounds.
- No `[...]` spreads, no `Math.min(...arr)`, no destructuring inside `constrainTransform` or `canvasDragBound`.
- `ImageElement` and `TextElement` are still wrapped in `memo`, and their props from `DesignCanvas` (`transformBoundFunc`, `dragBoundFunc`) are stable across renders (`useCallback` with empty deps in `DesignCanvas`).
- `occupiedSlotIds` `useMemo` depends only on `elements`.
---
# Phase 3 — Pawfectly Yours redesign
The editor was re-skinned and re-laid-out to match a soft pink / cream watercolor pet-shirt customizer brand ("Pawfectly Yours"). The architecture is unchanged — every bug fix listed above remains in place, including the M4 drag/rotate hot path — but the layout, components, and styling are new.
## What changed structurally
**Before:** three-column layout (left sidebar / center canvas / right properties panel) with a top header inside the canvas area.
**After:** top header bar + two-column body (canvas left, right rail right), with the properties panel replaced by a floating element toolbar that appears only when an element is selected. Mobile collapses to a single canvas view + bottom-sheet modal for customization.
## New components
| Component | Purpose |
|---|---|
| `Header.jsx` | Top app bar: brand mark, page title, undo/redo, Save (filled pink), Share (lavender ghost), cart icon with badge. Mobile collapses to logo + menu icon. |
| `ShirtOptionsPanel.jsx` | First card in the right panel. Color swatches (white/black/pink/blue/peach), size pills (S/M/L/XL), price. |
| `ElementToolbar.jsx` | Floating action surface that replaces `PropertiesPanel`. Delete / Duplicate / Up / Down / Flip H / Flip V buttons + Opacity & Size sliders + an "Edit Photo" + Background Removal advanced section for image elements. Sliders use an anchored-ratio approach (anchor captured per-element-id via `useMemo`) to avoid floating-point drift on repeated drags. |
| `CanvasHint.jsx` | Top-left badge ("Canvas — Click and drag to move") that hides itself once an element is selected. |
| `ZoomControls.jsx` | ± buttons + percentage indicator. Drives a CSS `transform: scale()` on the canvas wrapper, **not** Konva's stage scale — see "Why CSS-only zoom" below. |
| `MobileBottomSheet.jsx` | Slide-up bottom sheet wrapper for the right panel on mobile. ESC dismisses, backdrop tap dismisses, body-scroll-lock while open. Transform-based animation for GPU-friendly slide. |
## Modified components
- **`DesignCanvas.jsx`** — accepts `shirtColor`, `zoom`, `showHint` props. Wraps the Stage in a `.design-canvas-frame` (pink-tinted card) and `.design-canvas-wrapper` (CSS-zoom target). **The M4 hot path is unchanged — explicit `DO NOT MUTATE` comment markers wrap `constrainTransform` and `canvasDragBound`.** All scalar math, allocation-free in-bounds case, `useCallback([], [])` for stable refs, `useMemo` `occupiedSlotIds` — all preserved.
- **`TShirtSVG.jsx`** — full rewrite. Stylized tee silhouette (collar + sleeves + body) parameterized by `color`. Drop-shadow filter, print-zone corner brackets (220×220 centered). Local helpers `parseHex`/`darken`/`perceptualLuminance` for stroke contrast.
- **`ImageElement.jsx`** — added `opacity`, `flipX`, `flipY` props. Flip is implemented via `scaleX/scaleY = ±1` + matching `offsetX/offsetY` so the flip pivots around the bounding-box center (not the (x, y) origin, which would visually shoot the element off-screen). On `onTransformEnd`, scale magnitude is absorbed into width/height, scale sign is preserved as flip state.
- **`TextElement.jsx`** — same flip/opacity treatment as `ImageElement`. Approximates bounding box from `fontSize × length × 0.55` since Konva's text width isn't synchronously available before render.
- **`Sidebar.jsx`** — full rewrite to host the right panel. Renders ShirtOptionsPanel, a tabbed tool surface (Upload Photo / Stickers / Emoji / Text / Templates), and a sticky cart bar with Preview-on-Model toggle + Add to Cart pink pill button.
- **`UploadTab.jsx`** — full rewrite. Drop zone with cloud-arrow icon, "Drop your pet photo here / click to browse" copy, Recent Uploads grid (max 5 thumbs, hover-to-zoom). C12 file-input reset preserved.
- **`StickersTab.jsx`** — added `variant` prop (`"full"` shows category pills All/Hearts/Stars/Paws/Pets/Bones with brand-friendly labels mapped from underlying data; `"emoji"` is a flat grid). Both render from the same dataset.
- **`TextTab.jsx`** — restyled with new `.tt__*` class names. Brand color quick-picks; live preview swatch; pink-themed inputs with focus rings.
- **`TemplatesTab.jsx`** — pink-themed restyle (class names unchanged).
- **`useDesignEditor.js`** — added `duplicateElement` (offsets +12px, clears slotId, inserts above source), `bringForward`, `sendBackward`. All preserve M7 history dedupe and the existing API.
- **`App.jsx`** — full rewrite for the new layout. Wires Header, DesignCanvas, ZoomControls, ElementToolbar, Sidebar, MobileBottomSheet. Tracks `shirtColorId`, `shirtSize`, `cartCount`, `zoom`, `previewOnModel`, `recentUploads`, `isMobile`, `isMobileSheetOpen`. **All bug fixes preserved**: C8 `await assignImageToSlot`, C9/C10 `replaceElements`, C11 blob revoke (in `handlePhotoEditComplete`), keyboard shortcuts, click-outside-to-deselect.
- **`App.css`** — full rewrite. Top-level `.editor-shell` flex column, `.editor-body` flex row, `.canvas-area` with absolute-positioned `.canvas-area__toolbar` (bottom-left) and `.canvas-area__zoom` (bottom-right). Mobile-only `.app-fab` and `.mobile-toolbar-wrap`. Below 900px-but-above-mobile, the toolbar/zoom drop to static stacking to avoid overlap. Mobile bottom sheet is a separate transform-based slide-up.
- **`index.css`** — full rewrite for the new design tokens. Pink/cream palette, Pacifico display font, Nunito body font, pink-tinted shadows, larger radii, gentle background grid texture. Legacy `--accent` kept as alias to `--brand-pink` so older components that reference it still work. Range-input track/thumb rebuilt in pink. Background-removal button styles preserved (so the BackgroundRemovalButton inside ElementToolbar renders correctly).
- **`index.html`** — added Pacifico + Caveat to the Google Fonts request, `theme-color` meta `#fdf2f4`, `viewport-fit=cover`, title "Pawfectly Yours — Customize Your Shirt".
- **`scripts/fetch-fonts.mjs`** — added Pacifico + Caveat to the FAMILIES list so server-side text exports can use them.
- **`src/constants/fonts.js`** — added Pacifico + Caveat options.
## New constants
- **`src/constants/shirt.js`** — `SHIRT_COLORS` (id/label/hex/borderHint), `SHIRT_SIZES` (`['S','M','L','XL']`), `SHIRT_BASE_PRICE_USD` (28.00), `formatUsd()` helper.
## Components retained but no longer rendered
- **`LayersPanel.jsx`**, **`PropertiesPanel.jsx`** — kept on disk and exported from `panels/index.js` so future surfaces (e.g. a "layers" advanced view) can re-introduce them. The new design replaces their function with the `ElementToolbar` floating panel.
## Why CSS-only zoom
The Konva stage continues to render at a fixed 300×300 design-pixel resolution. Zoom is implemented as `transform: scale(...)` on the `.design-canvas-wrapper` outside the Stage. This is deliberate:
- The hot-path bound functions (`constrainTransform`, `canvasDragBound`) compute against the canonical 300×300 coordinate system. Any change to that math (e.g. multiplying by zoom) would slow them down and risk pixel-snapping drift.
- The export endpoint receives elements with x/y/width/height in canonical units. CSS zoom doesn't touch those.
- CSS transforms run on the GPU compositor — a 1.4× zoom is essentially free.
- Pointer events on a CSS-scaled element still hit the underlying coordinate system unmodified, so Konva hit-testing works without changes.
The trade-off is that text inside the Konva stage doesn't re-rasterize sharper at higher zooms. For a 300×300 design canvas this is fine — the print export is at 4500×4500 anyway.
## Responsive breakpoints
| Breakpoint | Behavior |
|---|---|
| > 1100 px | Full desktop layout. Header pill labels visible. |
| 9011100 px | Header pill button labels hide; right panel narrows from 420px to 360px. |
| 769900 px | Toolbar + zoom drop from absolute-positioned to static-stacked below the canvas to avoid overlap on cramped widths. |
| ≤ 768 px | Mobile. Header collapses to logo + menu icon. Right panel becomes the bottom-sheet modal triggered by FAB or menu. ElementToolbar floats fixed near top of canvas. Cart bar accessible only inside the open sheet. |
| ≤ 480 px | Tighter padding, smaller FAB (52×52), 4-column Recent Uploads grid (down from 5). |
## Files added by the redesign
- `src/components/Header.jsx`, `src/styles/Header.css`
- `src/components/MobileBottomSheet.jsx`, `src/styles/MobileBottomSheet.css`
- `src/components/sidebar/ShirtOptionsPanel.jsx`, `src/styles/ShirtOptionsPanel.css`
- `src/components/canvas/ElementToolbar.jsx`, `src/styles/ElementToolbar.css`
- `src/components/canvas/CanvasHint.jsx`, `src/styles/CanvasHint.css`
- `src/components/canvas/ZoomControls.jsx`, `src/styles/ZoomControls.css`
- `src/constants/shirt.js`
## Verifying the redesign
17. **Brand surface.** Open the editor on a 1440-wide desktop. The header should show the Pacifico-style "Pawfectly Yours" wordmark on the left, "Customize Your Shirt 💕" centered, undo/redo + Save (filled pink) + Share (lavender) + cart on the right.
18. **Shirt color.** Click the black swatch in the right panel. The on-screen t-shirt should turn black; the canvas frame stays the same; the print zone brackets should switch to a light stroke for contrast.
19. **Element toolbar lifecycle.** Add an image. Toolbar appears at bottom-left of canvas. Click empty canvas area — toolbar disappears. Re-select — toolbar reappears.
20. **Flip in place.** Add an image. Click Flip H. Element should mirror around its center, **not** shoot off to the right.
21. **Opacity slider commit.** Drag opacity from 100 → 50%. The element should fade live. Release the mouse — one history entry produced (one undo restores it). Without the `onMouseUp` commit, dragging would produce dozens of history entries.
22. **Size slider anchored.** Drag size to 60% then back to 100%. The element should return to its original dimensions, not drift smaller from compounded rounding.
23. **Zoom decoupling.** Zoom in to 150%. Drag an element. It should still constrain to the canonical 300×300 region (visually larger now, but the same logical area). Drag/rotate FPS unchanged from un-zoomed.
24. **Mobile layout.** Resize to 414×896 (iPhone 11 Pro). Header collapses, FAB appears bottom-right, canvas fills available space. Tap FAB — sheet slides up from below covering ~78vh. Tap backdrop — sheet slides back down. ESC dismisses too.
25. **Mobile element toolbar.** On mobile, select an element. Toolbar appears as a fixed-position card just below the header. Includes the same delete/duplicate/up/down/flip + sliders.
## Performance regression watch (continued)
The redesign added new components but **did not** add anything to the drag/rotate hot path. Confirm that:
- `DesignCanvas.jsx` still has the `PERFORMANCE-CRITICAL HOT PATH — DO NOT MUTATE` comment markers around `constrainTransform` and `canvasDragBound`.
- The new `shirtColor`, `zoom`, `showHint` props don't appear inside those functions.
- Zoom is applied via `transform: scale()` on the wrapper, not by passing zoom to Konva.
- `ImageElement` / `TextElement` have stable `dragBoundFunc` / `transformBoundFunc` references via the `useCallback([], [])` pattern in `DesignCanvas`.
- The new opacity/flipX/flipY props don't break `memo` equality — they're primitive values, so React's default shallow compare handles them correctly.
---
# May 2026 — Text-editing & selection-chrome refinements (May 21)
Eight UI fixes landed in one batch, all touching the text-editing flow and the canvas selection chrome. The largest change — moving every Transformer out of its element component and into a dedicated layer — changes how selection handles relate to elements and is worth understanding before further work on canvas overlays.
See `Project - Apparel Designer/Refinements_2026-05-21.md` in the Obsidian vault for the longer-form architectural narrative; this section is the per-file change log.
## Files changed
| File | Bugs addressed |
|------|----------------|
| `src/constants/transformer.js` | 7 |
| `src/styles/TextTab.css` | 5 |
| `src/components/canvas/TextEditAffordance.jsx` | 1 |
| `src/components/canvas/TextElement.jsx` | 6, 8 |
| `src/components/canvas/ImageElement.jsx` | 6, 8 |
| `src/components/canvas/DesignCanvas.jsx` | 8 (main work) |
| `src/App.jsx` | 2 |
| `src/components/sidebar/Sidebar.jsx` | 3 (plumbing) |
| `src/components/sidebar/TextTab.jsx` | 3, 4 |
## Bug 1 — Pencil affordance stuck on previous text after switching selection
**Problem.** When the user clicked one text element, then clicked another, the pencil edit-icon overlay stayed glued to the first element's position. The affordance only "unstuck" after some unrelated event (window resize, scroll) forced a re-measure.
**Root cause.** `TextEditAffordance.jsx` syncs the latest `element` prop into a ref so its rAF-throttled window listeners can read it without going stale. That sync used to live in a `useEffect`, which runs AFTER `useLayoutEffect`. The measurement effect ran first, with the OLD element in the ref — it measured the old node and set `pos` to the old rect. Then the sync effect updated the ref, but the measurement had already committed wrong data.
**Solution.** Assign the ref synchronously in the render body. By the time the layout effect runs, the ref already points at the new element, so `measure()` looks up the right node. Also added a render-body guard that clears `pos` to `null` when the element id changes and a previous-id rect would otherwise linger — schedules the clear via `queueMicrotask` to avoid "setState during render" warnings, and tags each rect with `_forId` so a stale microtask can't wipe a newer measurement.
## Bug 2 — Text edit mode exits when it shouldn't, doesn't exit cleanly when it should
**Problem.** The user couldn't predict when the text-tab edit form would stay bound to their text element and when it would fall back to draft mode. Spec: stay in edit mode until the user (a) clicks empty canvas, (b) clicks a different non-text element, or (c) adds a new element.
**Solution.** Rewrote the edit-mode useEffect in `App.jsx` with an explicit three-way branch:
1. Selection unchanged → keep edit mode.
2. New selection is a different text element → *transfer* edit mode to it (the user clearly still wants to edit text; just a different piece).
3. New selection is null or non-text → exit edit mode and flip the sidebar back to the Upload tab.
The "adds a new element" exit is enforced separately by three new wrapper callbacks (`handleAddImage`, `handleAddStickerOrEmoji`, `handleAddNewText`) that clear `editingTextElementId` BEFORE calling `addElement`. The clear-then-add ordering matters: `addElement` also moves selection, which fires the edit-mode useEffect; clearing first ensures that effect's bail-out branch runs (no transfer to the freshly-added element).
## Bug 3 — Draft text panel ignores canvas-wide style
**Problem.** If the user added a text element with a specific font / colour / outline and then opened the text panel to add ANOTHER piece of text, the form reset to the global default (Pacifico, black, no outline). They had to re-pick the same style every time.
**Solution.** `TextTab.jsx` now derives a `sharedStyle` via `useMemo` from the elements array — if every text element on the canvas agrees on `fontFamily / fill / stroke / strokeWidth`, the shared style is non-null and serves as a fallback. The form's per-field accessors now cascade in three steps:
```
draft.X (user override) ?? sharedStyle?.X ?? DEFAULT_DRAFT.X
```
Draft state was also refactored from a full `DEFAULT_DRAFT` snapshot to a `{}` of overrides. This means a change to the shared style propagates into fields the user hasn't touched, while preserving any field they've explicitly set.
`fontSize / arc / text` are deliberately excluded from `sharedStyle` — they're per-piece properties, not styling.
`Sidebar.jsx` was updated to forward the `elements` prop to TextTab.
## Bug 4 — "Font" should read "Style"
**Problem.** The font dropdown's user-facing label was "Font". Since the dropdown rows are live previews and the user is effectively picking a visual style rather than a typeface name, "Style" is closer to what the user is doing.
**Solution.** Two-character change in `TextTab.jsx`: `<label>Font</label>``<label>Style</label>`, and the `aria-label` on the `Select` component to match. The underlying dropdown still picks a `fontFamily`; only the label changes.
## Bug 5 — Text preview background washes out white and pale text
**Problem.** The TextTab preview's background was `--brand-pink-wash` (`#fdf2f4`) — nearly white. White text and pale pinks were invisible against it.
**Solution.** Inlined a mid-tone gray-pink `#9a8a91` for `.tt__preview` in `TextTab.css`. Contrast ratios at this value:
- vs `#1f1d23` (default ink): ≈5.4:1 ✓ (AA small text)
- vs `#ffffff` (white): ≈3.0:1 ✓ (AA large text)
- vs brand pinks: lower luminance contrast but high hue separation — visibly distinct
Not promoted to a token because no other surface needs this exact colour. CSS comment documents the contrast math so the next maintainer who tweaks it understands the trade-offs.
## Bug 6 — Drag handles don't appear on click-then-immediate-drag
**Problem.** Pressing on an unselected element and immediately dragging didn't show the resize/rotate handles — the user couldn't see what was about to scale. Selection was happening but only after the user released the mouse.
**Root cause.** Both element components selected on `onClick` / `onTap`. Konva fires `click` on the mouseup completing a press-release pair WITHOUT intervening motion; a press that immediately becomes a drag never produces a click. So during the drag, the element wasn't selected and the Transformer hadn't mounted.
**Solution.** Added `onMouseDown` and `onTouchStart` handlers in both `TextElement.jsx` and `ImageElement.jsx` (the `URLImage` inside) that select before the drag starts. Selecting an already-selected element is a no-op at the `useDesignEditor` level, so the click + mousedown pair on a simple click doesn't double-commit.
## Bug 7 — Rotate handle floats too far above the element
**Problem.** `rotateAnchorOffset: 28` placed the rotation handle 28 design-units above the element. After the bug-8 refactor it became more noticeable: the handle floats over unrelated content with a long visual gap to its anchor element, reading as "this handle belongs to no one".
**Solution.** Reduced `rotateAnchorOffset` to `14` in `constants/transformer.js`. Keeps the handle clearly outside the bbox border at typical zooms, but cuts the float distance in half so the "this handle controls THIS element" association reads at a glance. Verified the new value doesn't collide with corner anchors across the `MIN_ELEMENT_SIZE` range.
## Bug 8 — Resize/rotate handles render BELOW later elements (main architectural work)
**Problem.** Each element rendered its own `<Transformer>` as a sibling inside the elements layer. Konva paints children of a layer in array order, so any element drawn after the selected one (i.e. above it in z-order) painted over the selected element's handles. Selecting an element in the back of the stack meant invisible handles.
**Solution.** Moved both the single-select and multi-select Transformers into a new dedicated `<Layer>` in `DesignCanvas.jsx`, rendered after (above) the elements layer. The component tree now looks like:
```
<Layer ref={elementsLayerRef}> ← elements
{elements.map(...)}
</Layer>
<Layer> ← transformers (NEW)
<Transformer ref={singleTransformerRef} ... />
<Transformer ref={multiTransformerRef} ... />
</Layer>
```
Key implementation details:
- **Element components no longer hold a Transformer.** `TextElement.jsx` and `ImageElement.jsx` dropped their Transformer JSX, `trRef`, `attachTransformer`, and `setTransformerRef`. `TextElement` kept its `getSelfRect` override (now re-applied on bbox-affecting attr changes); `ImageElement`'s transformer-attaching useEffect was removed entirely.
- **DesignCanvas owns both Transformers.** A new `singleTransformerRef` lives next to the existing `multiTransformerRef`. A new `singleSelectedEl` `useMemo` resolves the single-selection target (selectedIds-with-one-element wins, falls back to legacy `selectedId`). A new `useEffect` attaches the single-Transformer to the matched node by id (`findOne('.' + id)`), calls `tr.forceUpdate()` to re-measure the node's geometry, and detaches in multi-select / no-select / crop-mode cases.
- **Element type-specific min-size.** The MIN_ELEMENT_SIZE floor that used to live in `ImageElement`'s boundBoxFunc is now applied conditionally in `DesignCanvas`'s single-Transformer boundBoxFunc — only for image/sticker types. Text continues to enforce its own `MIN_FONT_SIZE` at `onTransformEnd` time and doesn't get a bbox floor (which would actively interfere with small-font resizing).
- **Layer offsets match.** The new transformer layer shares the elements layer's `LAYER_OFFSET_X/Y` so handle positions align with the visible element bboxes — Konva's transformer geometry is computed in the attached node's coordinate space but rendered in the Transformer's own layer.
- **Multi-transformer effect updated.** `layer.batchDraw()` calls now target `tr.getLayer()?.batchDraw()` (the transformer's layer, not the elements layer) so the moved Transformers actually repaint.
## Verifying these fixes
26. **Bug 1.** Place two text elements, A and B. Click A — pencil icon appears next to A. Click B — pencil icon should move to B immediately, not stay on A.
27. **Bug 2 — transfer.** Click pencil on text A (enter edit mode). Form shows A's values. Click on text B. Form should rebind to B's values; tab stays on Text. — **exit on click-off:** click empty canvas; tab switches to Upload Photo. — **exit on non-text:** click an image element; tab switches to Upload Photo. — **exit on add:** while editing text A, switch to Stickers tab, click a sticker. New sticker is selected; tab implicitly leaves Text (the user moved to a different tab); edit mode is cleared so re-opening Text shows draft, not A's edit form.
28. **Bug 3.** Add one text element styled with Pacifico font, pink fill, outline width 2. Open Text tab to add another (clicking empty canvas / pressing escape). The Style dropdown should already show Pacifico, the fill swatch should show pink, and the outline toggle should be on with width 2. Adding text via the button produces a piece styled identically. Now change one text to a different fill — the canvas no longer has a shared fill, so the form falls back to DEFAULT_DRAFT for fill while preserving sharedStyle for the other fields.
29. **Bug 4.** Open the Text tab. The dropdown label above the style picker should read "Style", not "Font".
30. **Bug 5.** Open the Text tab. The live preview swatch background should be a mid-tone dusty pink. Pick black text — readable. White text — readable. Brand pink text — readable (hue separation carries it).
31. **Bug 6.** Place a photo. Click empty canvas to deselect. Press on the photo and IMMEDIATELY drag without releasing. The pink resize/rotate handles should appear as soon as the drag starts, not only after releasing the mouse.
32. **Bug 7.** Select any element. The rotate handle (the lone anchor above the bbox) should sit close to the top edge of the bbox — still clearly separate from the corner anchors, but visually associated with the element rather than floating in space.
33. **Bug 8.** Place three overlapping photos. Select the BOTTOM one (use the Layers panel or click into a non-overlapping edge). Its resize/rotate handles should render on top of the OTHER two photos, not be hidden behind them. Multi-select two non-adjacent elements (shift-click or marquee) — the shared transformer's handles also render on top of everything.
## Cross-cutting notes
- The dedicated-transformer-layer architecture (bug 8) is the most invasive change in this batch. Future work that introduces per-element selection chrome (e.g. element-specific badges, drag-affordance arrows) should add its own Layer above the elements layer rather than rendering inside the elements layer, to preserve the property that selection-related UI always sits above every element.
- `isSingleSelected` is retained in `DesignCanvas.jsx`'s elements.map even though the element components ignore it after this refactor. It's cheap to compute and keeps the prop signature stable for any future re-introduction of per-element selection state. Same for the `_isSelected` / `_shiftHeld` / `_transformBoundFunc` underscored params in `TextElement`/`ImageElement`.
- The bug-2 fix prefers "transfer edit mode between text elements" over "exit on any selection change". This is the literal reading of "stay in editing mode" — the user IS still editing text, just a different piece. If user testing shows people want the exit-everywhere behaviour instead, the change is one branch flip in the edit-mode useEffect.

193
docs/SUGGESTIONS.md Normal file
View File

@@ -0,0 +1,193 @@
# Apparel Designer — Suggestions
This is the parallel document to `BUGS.md`. Where that file says "this is broken," this one says "this works, but it could be better." Suggestions are grouped by what kind of work they are: things the user feels, things the team feels, and things the server feels.
Items that have been implemented since the original audit have been removed from this file. See `CHANGES.md` for the historical record.
---
## What the user feels
### S1 — Confirm before destructive template switch
Switching templates wipes the canvas (correct behavior — it would be worse to pile elements from each template on top of the previous). It's the right outcome, but it's a cliff. A small confirm modal ("Switching to '<template>' will replace your current design — keep going?") avoids accidental data loss without adding much friction. Could be a one-time dismissable warning.
### S2 — Export resolution presets
4500×4500 PNG is the right number for print, but it's overkill for sharing a design preview. Add a small dropdown on the export button: "Print (4500×4500)", "Web (1500×1500)", "Thumbnail (500×500)". The server already has the scale factor parameterized; the only real work is plumbing it through.
### S3 — Undo/redo buttons that show what they'll undo
Hovering "Undo" could show "Undo: Move text" or similar. Requires labeling history entries when they're created. Low-priority polish, but it's a small thing that makes the editor feel responsive.
### S4 — Visible loading indicator while the background-removal model downloads
86MB on first use is a lot. The current spinner shows progress, but it's small and inside the button. A more prominent "Downloading background removal model — this happens once" toast would be reassuring.
---
## What the team feels
### S5 — Tests
Zero tests in the repo. A Vitest setup with the following targets pays for itself quickly:
- `useDesignEditor` history mechanics (the slice/push/shift/index-shifting logic in particular is subtle enough to deserve regression tests, plus the M7 dedupe, the new restoration-aware `initializeHistory` flow, and the multi-select / drag-reorder paths added in S3)
- `useTemplate.calculateAutoCrop` and `assignImageToSlot`
- `server.js` `/api/export` rendering against a small set of golden-image fixtures, including filter pipeline (S21) and stroke text (S8)
- The hot-path bound functions in `DesignCanvas.jsx` (`constrainTransform`, `canvasDragBound`) — assertions that they return the same `newBox` reference when in-bounds, plus targeted tests on the rotation slide-or-revert and scale-clamp branches
- The Zod export-request schema (S19) — fuzz invalid bodies and assert 400s, then verify the renderer accepts the validated shape
- The newly-extracted shared utilities — `loadImageDimensions`, `unflipReportedXY`, `getNodePageRect`, `useTimer`, `safeLocalStorage` (readJson/writeJson/removeKey), `makeElementId`. Most are pure functions and trivial to cover.
### S6 — Type checking
The project is plain JS with `@types/react` installed but no `tsconfig.json`. A non-blocking step toward TypeScript would be enabling `// @ts-check` at the top of the heavier files (`useDesignEditor`, `server.js`, `useTemplate`, `DesignCanvas`) — the JSDoc annotations are already pretty good. The newly-extracted utility modules in `src/utils/` and `src/constants/` are good first targets — small, mostly pure, well-documented function signatures.
### S7 — Storybook or a component playground
Filerobot is a heavyweight modal, the layers panel has several states (selected / multi-selected / dragging), the slot placeholder has empty / loading / filled looks, and `PreviewModal` has loading/error/success states. A Storybook would speed up iteration on each in isolation.
### S8 — Continue the CSS consolidation pass
S13 set up the foundation: `.visually-hidden`, `.sr-only`, `.btn-reset`, and the shared `fade-up-in` keyframe live in `index.css`, and `App.css`'s old `toast-in` was migrated to use it. The remaining ~13 component stylesheets weren't fully swept — running through them and replacing redundant button-resets and one-off animations with the shared utilities is straightforward follow-on work, just mechanical.
### S9 — Stream the export file instead of buffering
`canvas.toBuffer('image/png')` then `writeFileSync` then `res.json` with a URL the client immediately fetches is three hops. Streaming the PNG directly through `canvas.createPNGStream().pipe(res)` cuts memory and latency for large exports — though it does mean dropping the "saved file URL" model in favor of "Save As" downloads.
---
## Cross-cutting
### S10 — Continue the i18n sweep
S23 set up the scaffold: `src/i18n/messages.js` (flat `en` catalog, ~50 keys covering header, toasts, cart, sidebar tabs, text tab, toolbar, layers, bounds, and preview modal), `src/i18n/t.js` (10-line helper with `{name}` interpolation and missing-key surfacing), and the `App.jsx` toasts + `Header.jsx` button labels are migrated. `Sidebar.jsx`, `TextTab.jsx`, `ElementToolbar.jsx`, `LayersPanel.jsx`, and `PreviewModal.jsx` still have hardcoded strings that should be migrated to the catalog. The pattern is established and the keys are already in the catalog — `t('text.field.font')`, `t('toolbar.delete')`, etc. — so the sweep is mechanical.
Adjacent: B12 (in `BUGS.md`) flags `handleShare`'s `navigator.share` strings, App-level FAB aria-labels, and the export-toast verbiage as the missed cohort that doesn't show up in DOM-text greps.
### S11 — Design token doc
`index.css` defines a sensible token system (`--accent`, `--radius-md`, etc), and the Pawfectly Yours redesign expanded it with brand colors. It's not documented anywhere, so new contributors reach for hex codes instead. Even a one-page `docs/design-tokens.md` would help.
---
## Audit findings (May 2026 sweep)
The following came out of a code-quality pass that explicitly didn't touch user-facing behavior. They're internal-hygiene items: dead code, redundant aliases, magic-number duplication, file size. Each one is mechanical when implemented.
### S12 — `TemplatesTab.jsx` is dead code
**File:** `src/components/sidebar/TemplatesTab.jsx`, `src/components/sidebar/index.js`
The Templates tab was removed from the editor when URL-driven template loading shipped (`?template=X` deep links from the marketing site replaced the picker). The component file is still on disk and the barrel `src/components/sidebar/index.js` still re-exports it:
```js
export { TemplatesTab } from './TemplatesTab';
```
No remaining import statement references it. Bundlers will tree-shake it out of production builds, but it confuses code search and ESLint can't help — it's "exported", just unused. Delete the file and the barrel line. (Verifiable mechanically: `rg -F "TemplatesTab" src/` should return zero hits after removal.)
### S13 — `isPhoto = isImage` is a redundant alias
**File:** `src/components/canvas/ElementToolbar.jsx`
```js
const isImage = element.type === 'image';
const isSticker = element.type === 'sticker';
const isPhoto = isImage; // Type-based gating replaces the previous `!element.emoji` heuristic.
```
The S10 work split `'image'` and `'sticker'` into separate types, so `isPhoto` and `isImage` mean exactly the same thing now. Pick one (the codebase elsewhere prefers `isImage`) and remove the alias. The comment explaining the history can move to `CHANGES.md` if it's worth keeping.
### S14 — Stale `Fix for #N` comment references
**Files:** widespread (App.jsx, DesignCanvas.jsx, TextElement.jsx, ImageElement.jsx, ElementToolbar.jsx, MobileBottomSheet.jsx, etc.)
Many comments refer to issue numbers that lived in the old BUGS.md cycle: `#11`, `#12`, `#15`, `#19`, `#20`, `#21`, `#24`, `#25`, `#27`, `#28-30`, `#31`, `#32`, `#33`, `#35`. Those entries have been removed from `BUGS.md`; the references no longer resolve to anything. The comments themselves still carry value — they explain WHY a piece of code looks the way it does — but the `(Fix for #N)` suffix is now noise.
Two paths:
- Rip the `(Fix for #N)` suffix from comments and let the explanation stand alone.
- Cross-reference `CHANGES.md` instead — `(see CHANGES.md → recent-colors)` or similar.
The first is faster; the second is more discoverable. Either is fine. Avoid leaving the references as-is — they're a small but real "what's this number?" cost on every code review.
Also stale: the App.jsx keyboard-shortcut comment that says "Cmd/Ctrl+A 'select all' is intentionally NOT bound here … makes sense once multi-select lands (S3)". S3 has landed; the comment misrepresents current state.
### S15 — Extract duplicated undo/redo selection-preservation logic
**File:** `src/hooks/useDesignEditor.js`
`undo` and `redo` contain identical "filter the current selection set against the restored elements' ids" blocks. Extracting to a small helper is a readability win and prevents the two paths from drifting:
```js
function preserveSelection(prev, nextElements) {
const liveIds = new Set(nextElements.map((e) => e.id));
const filtered = new Set();
for (const id of prev) if (liveIds.has(id)) filtered.add(id);
return filtered.size === prev.size ? prev : filtered;
}
```
### S16 — Split `App.jsx` into focused hooks
**File:** `src/App.jsx`
The file is now 1100+ lines. Three logically separable concerns each have meaningful state + a useEffect cluster:
- **Keyboard shortcuts** — the keydown effect with all the shortcut branches (undo, redo, duplicate, delete, esc, [/], arrow nudge). Could become `useEditorKeyboardShortcuts({ selectedId, selectedIds, ...callbacks })`.
- **Persistence** — the restore-on-mount effect, the auto-save effect, and `isFirstSaveRef`. Could become `usePersistence({ elements, shirtColorId, ... })`.
- **Toast surface** — `toast` state, `showToast`, `dismissToast`, with a separate `<ToastSurface toast={toast} onDismiss={dismissToast} />` component for the JSX. (The timer mechanics are already factored out via `useTimer`.)
None of these is urgent, but the file's size means anyone adding a new effect has to scan past unrelated state. Splitting would also make it easier to write focused tests (S5) for each concern.
### S17 — Remove `commitHistory` alias
**File:** `src/hooks/useDesignEditor.js`
```js
const commitHistory = useCallback(() => flushPendingChanges(), [flushPendingChanges]);
```
It does literally nothing except provide a second name. Pick one (`commitHistory` reads better at call sites, `flushPendingChanges` reads better in the hook's internals — but having both is unnecessary). Lots of components call `onCommit={commitHistory}`, so changing the public name is the bigger sweep; either decision is fine.
### S18 — `SlotPlaceholder` corner brackets repetition
**File:** `src/components/canvas/SlotPlaceholder.jsx`
Eight `<Line>` elements drawing the four corner brackets, each spelled out with explicit coordinates. A small `corners.map(({x, y, dx, dy}) => <Line ... />)` would cut ~16 lines of repetition without changing the rendered output. Minor readability win.
### S19 — `useBackgroundRemoval.hasModel` is unused
**File:** `src/hooks/useBackgroundRemoval.js`, `src/components/canvas/BackgroundRemovalButton.jsx`
The hook returns `hasModel` (set to true after `loadModel` succeeds). The only consumer, `BackgroundRemovalButton`, destructures it but never reads it. Either:
- Wire it: hide the button until `hasModel` is true, OR show a different label ("Remove Background (model loading...)") when false.
- Drop it from the hook's return.
The first is a feature; the second is dead-code removal.
### S20 — `useTemplate.templateRef` is dead state
**File:** `src/hooks/useTemplate.js`
`templateRef.current` is assigned in `loadTemplate` and `clearTemplate`, but nothing reads it. Looks like a leftover from an earlier iteration where consumers needed an imperative handle. Remove the ref and the assignments.
### S21 — `useTemplate.getSlots` is only used internally
**File:** `src/hooks/useTemplate.js`
`getSlots` is defined as a `useCallback` and exported in the return value, but the only call site is `assignImageToSlot` inside the same hook. No external consumer destructures `getSlots`. Inline `const slots = currentTemplate?.slots || [];` in `assignImageToSlot` and remove from the public API.
### S22 — `MobileBottomSheet.sheetRef` is declared but never read
**File:** `src/components/MobileBottomSheet.jsx`
```js
const sheetRef = useRef(null);
// ...
<div ref={sheetRef} className="mbs__sheet" ... >
```
The ref is attached to the sheet element but never used (no animation reads from it, no scroll-locking targets it specifically). Probably leftover from an earlier interaction model. Remove the ref or document why it's there.
### S23 — Client-side `console.error` calls have no observable destination in production
**Files:** `src/App.jsx` (`handleSlotImageUpload`), `src/hooks/useExport.js`, `src/hooks/useBackgroundRemoval.js`, `src/components/sidebar/UploadTab.jsx`, `src/components/PWAInstall.jsx`
The server got structured logging in S22 (pino + pino-http). The client still uses `console.error` for failures, which is fine in development but invisible in production unless the user opens devtools. Two reasonable directions:
- **Thin client logger** — a small `src/utils/logger.js` exporting `log.error(...)` etc, that today just calls `console.error` but can later post to an error-reporting service (Sentry, Bugsnag).
- **Status quo with explicit acceptance** — document in CHANGES.md that "client errors are devtools-only by design; production users only see toast-level error feedback".
Either is fine; mixing both (some files use `console.error`, others wrap) is the worst outcome.
### S24 — Generalize `withTextCenterPreservation` to a shape-agnostic helper
**Files:** `src/utils/textGeometry.js`, `src/App.jsx` (`handlePhotoEditComplete`)
Both paths preserve the visual center of an element across a dimension change, but the math is duplicated:
- `withTextCenterPreservation` derives height as `fontSize × LINE_HEIGHT_RATIO`, width via `measureTextWidth`, and returns a center-shifted x/y.
- `handlePhotoEditComplete` does the same logic inline using raw width/height: `oldCenterX = (x ?? 0) + oldWidth / 2; newX = oldCenterX - newWidth / 2`.
A shape-agnostic helper `preserveCenter(oldX, oldY, oldW, oldH, newW, newH) → { x, y }` could underlie both. The text path would compose: text-specific bbox computation → preserveCenter. The photo path calls preserveCenter directly. Worth doing alongside a test for both — the math looks easy but the rounding behavior on photos differs from text (photos round at the call site, text returns floats).
### S25 — Generalize blob-URL revocation in element-replacement paths
**File:** `src/App.jsx` (`handlePhotoEditComplete`)
`handlePhotoEditComplete` revokes the previous blob URL after the new image loads. Other element-replacement paths in the future (drag-drop replacement on a slot, "swap photo" feature) would want the same cleanup. The existing `revokeBlobUrl` helper handles the URL primitive; what's missing is a higher-level "replace this element's src and revoke the old one" helper. Could live in `useDesignEditor` as `replaceElementSrc(id, newSrc)`. Not a refactor target until there's a second caller.

235
e2e/crop.spec.js Normal file
View File

@@ -0,0 +1,235 @@
// 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);
});

281
e2e/fixtures/editor.js Normal file
View File

@@ -0,0 +1,281 @@
// 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 <canvas>
// 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
// (`<label htmlFor="tt-text-input">Your message</label>`).
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 <li> 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:
* <h3 className="layers-title">Layers ({elements.length})</h3>
*
* We parse the N out of the title rather than counting <li>
* elements because the panel's empty state doesn't render any
* <li>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 <input> or
* <textarea>, where the handler's early-return skips the
* shortcut.
*/
export async function pressUndo(page) {
const isMac = process.platform === 'darwin';
await page.keyboard.press(isMac ? 'Meta+z' : 'Control+z');
}
export async function pressRedo(page) {
// The handler accepts either Cmd+Shift+Z or Cmd+Y; we use
// Shift+Z because it's the more conventional shortcut and
// covers the same code path.
const isMac = process.platform === 'darwin';
await page.keyboard.press(isMac ? 'Meta+Shift+z' : 'Control+Shift+z');
}

189
e2e/history.spec.js Normal file
View File

@@ -0,0 +1,189 @@
// E2E: undo/redo flow.
//
// What this guards
// ────────────────
// The history subsystem is the most bug-bitten area of the codebase
// (see the May 22 / May 23 Refinements notes). useDesignEditor.test.js
// covers the hook-level behaviour exhaustively, but the keyboard
// shortcuts (Cmd+Z / Cmd+Shift+Z) are attached to the window via a
// global useEffect in App.jsx — that wiring isn't exercised by Vitest.
// This spec drives the actual keystrokes and verifies the right
// things happen.
//
// Two scenarios in particular are worth catching at this layer:
//
// 1. The keyboard handler's input/textarea exclusion. The handler
// bails out early when the focused element is an <input> or
// <textarea>, so the user can type a `z` into the text field
// without accidentally undoing. Vitest can't test this because
// it doesn't render real form elements with focus state.
//
// 2. The HistoryControls buttons disabling/enabling correctly. The
// pills have `disabled` attributes wired to canUndo/canRedo,
// and the visual state of those buttons is what tells the
// user "you can / can't go back further." A bug where the
// buttons stay enabled past the history floor would only be
// visible at the DOM-attribute level.
import { test, expect } from '@playwright/test';
import {
gotoFreshEditor,
addTextViaSidebar,
getElementsCount,
pressUndo,
pressRedo,
} from './fixtures/editor.js';
test.beforeEach(async ({ page }) => {
await gotoFreshEditor(page);
});
test('Cmd/Ctrl+Z removes the most recently added element', async ({ page }) => {
// The headline undo test. Add an element, undo, verify it's gone.
// This single keystroke covers the whole pipeline: the keyboard
// handler in App.jsx fires undo(), useDesignEditor walks the
// history pointer back, setElements applies the previous snapshot,
// LayersPanel re-renders.
await addTextViaSidebar(page, { text: 'Will be undone' });
expect(await getElementsCount(page)).toBe(1);
await pressUndo(page);
// The deleted element is gone; count drops to 0.
expect(await getElementsCount(page)).toBe(0);
});
test('Cmd/Ctrl+Shift+Z restores an undone element', async ({ page }) => {
// Redo after undo. The post-redo state must match the original
// (same text on the same element) — a regression where redo
// restored a different snapshot than what was undone would fail
// the layer-name assertion.
await addTextViaSidebar(page, { text: 'Resurrected' });
await pressUndo(page);
expect(await getElementsCount(page)).toBe(0);
await pressRedo(page);
expect(await getElementsCount(page)).toBe(1);
await expect(page.locator('.layers-item-name', { hasText: 'Resurrected' })).toBeVisible();
});
test('typing `z` in the text field does NOT undo (keyboard handler input gate)', async ({ page }) => {
// The keyboard handler in App.jsx has an explicit early-return:
// if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
// Without this, typing a `z` into the textarea while holding a
// modifier would hijack the keystroke. (Just typing `z` without
// a modifier doesn't trigger the undo branch anyway, but Cmd+Z
// is the danger — except even Cmd+Z is suppressed because the
// user's cursor is in a field where Cmd+Z is the OS-native
// "undo last typed character" shortcut, which the browser
// handles.)
//
// We test the simpler case here: pressing Cmd+Z while focused
// on the textarea should NOT remove the element. The OS-native
// textarea undo may or may not fire — we don't care; we care
// that OUR keyboard handler doesn't fire and remove an element
// from the canvas.
await addTextViaSidebar(page, { text: 'Safe' });
expect(await getElementsCount(page)).toBe(1);
// Focus the textarea by clicking it explicitly. addTextViaSidebar
// leaves focus elsewhere by the time it returns; we have to
// re-focus deterministically.
const textarea = page.getByLabel('Your message');
await textarea.click();
await expect(textarea).toBeFocused();
// Now press Cmd+Z. The app's handler should bail out at the
// tagName check. The element on the canvas stays.
await pressUndo(page);
// Verification: element count is still 1.
expect(await getElementsCount(page)).toBe(1);
});
test('Undo and Redo buttons in the bottom bar reflect history state', async ({ page }) => {
// HistoryControls renders two buttons with aria-label "Undo" /
// "Redo" and a `disabled` attribute wired to canUndo / canRedo.
// The button states are how users (and screen readers) see
// whether stepping further is available.
//
// History initialization model (confirmed by running the suite):
// a fresh editor's history is seeded with the empty snapshot at
// idx=0. canUndo = `idx > 0`, so a fresh editor has canUndo false.
// The FIRST add pushes a new snapshot and advances idx to 1 — at
// which point canUndo flips to true because the user can step
// back to the empty floor. This contradicted my initial mental
// model ("first add gives idx=0") but matches what the app
// actually does; the May 22 cleanup arc relies on that empty
// floor being there as a restoration target.
const undoButton = page.getByRole('button', { name: 'Undo' });
const redoButton = page.getByRole('button', { name: 'Redo' });
// Fresh editor: history=[empty], idx=0.
// canUndo = 0 > 0 = false (we're at the floor)
// canRedo = 0 < length-1=0 = false (nothing to redo)
await expect(undoButton).toBeDisabled();
await expect(redoButton).toBeDisabled();
// After one add: history=[empty, [A]], idx=1.
// canUndo = 1 > 0 = true (can step back to empty)
// canRedo = 1 < length-1=1 = false (we're at the head)
await addTextViaSidebar(page, { text: 'First' });
await expect(undoButton).toBeEnabled();
await expect(redoButton).toBeDisabled();
// After two adds: history=[empty, [A], [A,B]], idx=2.
// canUndo = 2 > 0 = true
// canRedo = 2 < length-1=2 = false
await addTextViaSidebar(page, { text: 'Second' });
await expect(undoButton).toBeEnabled();
await expect(redoButton).toBeDisabled();
// Click Undo: idx 2 → 1. Now we're mid-history with both
// directions available.
// canUndo = 1 > 0 = true
// canRedo = 1 < length-1=2 = true (can re-apply the second add)
await undoButton.click();
await expect(undoButton).toBeEnabled();
await expect(redoButton).toBeEnabled();
// Click Redo: idx 1 → 2. Back to post-two-adds state.
await redoButton.click();
await expect(undoButton).toBeEnabled();
await expect(redoButton).toBeDisabled();
// Finally, undo twice to reach the floor and confirm undo
// disables at idx=0. This is the assertion the first-add test
// ABOVE used to (incorrectly) make against a one-add history;
// the only way to actually get there is to wind all the way
// back to the seeded empty floor.
await undoButton.click(); // idx 2 → 1
await undoButton.click(); // idx 1 → 0 (the floor)
await expect(undoButton).toBeDisabled();
await expect(redoButton).toBeEnabled();
});
test('a new action after undo discards the redo stack', async ({ page }) => {
// Standard editor behaviour: after undo, redo is available. But
// a new edit branches history forward from the current idx,
// discarding anything that was previously to the right of it.
// This is the redo-stack truncation tested in
// useDesignEditor.test.js at the hook level; here we confirm
// it works end-to-end through the real keyboard handler.
await addTextViaSidebar(page, { text: 'A' });
await addTextViaSidebar(page, { text: 'B' });
await pressUndo(page);
// Now redo would restore "B".
await expect(page.getByRole('button', { name: 'Redo' })).toBeEnabled();
// New branch: add a different element. This should truncate the
// redo stack — "B" is gone from history.
await addTextViaSidebar(page, { text: 'C' });
await expect(page.getByRole('button', { name: 'Redo' })).toBeDisabled();
// Undoing now should land on the "just A" state, NOT "A + B".
await pressUndo(page);
expect(await getElementsCount(page)).toBe(1);
await expect(page.locator('.layers-item-name', { hasText: 'A' })).toBeVisible();
// "B" should not be findable — it was truncated when we added C.
await expect(page.locator('.layers-item-name', { hasText: 'B' })).toHaveCount(0);
});

148
e2e/persistence.spec.js Normal file
View File

@@ -0,0 +1,148 @@
// E2E: state persistence across page reloads.
//
// What this guards
// ────────────────
// The persistence layer (src/utils/persistence.js + App.jsx's restore
// effect) is responsible for keeping the user's work alive across
// reloads. The hook-level tests cover the save/load round-trip; the
// utility tests cover the JSON shape. What's NOT covered there: the
// actual restore effect in App.jsx, the timing of replaceElements
// + initializeHistory, and the StrictMode dual-mount we fixed on
// May 23 (which only manifests in dev mode and was the motivation
// for the replaceElements ID-preservation work).
//
// This spec drives a real reload and asserts state survives. It also
// exercises the post-May-23 initializeHistory + replaceElements
// pipeline end-to-end: if a future regression brings back the
// blank-canvas-on-undo bug, the "undo doesn't blank the canvas after
// reload" test below will catch it.
import { test, expect } from '@playwright/test';
import {
gotoFreshEditor,
addTextViaSidebar,
getElementsCount,
pressUndo,
} from './fixtures/editor.js';
test.beforeEach(async ({ page }) => {
await gotoFreshEditor(page);
});
test('elements survive a page reload', async ({ page }) => {
// The basic persistence promise: add elements, reload, find them
// still there.
await addTextViaSidebar(page, { text: 'Surviving text' });
expect(await getElementsCount(page)).toBe(1);
// Reload. Note we use page.reload() rather than gotoFreshEditor —
// we want to PRESERVE state across the reload, not wipe it. The
// localStorage save happens via the auto-save effect in App.jsx.
// That effect skips its first run; subsequent state changes
// trigger savePersistedState. By the time we reload, the addText
// change has been saved.
await page.reload();
// Wait for the editor to remount + restore. We can't use
// gotoFreshEditor's wait helpers directly; just wait for the
// canvas-area and the restored layer to appear.
await page.locator('.canvas-area').waitFor({ state: 'visible' });
await expect(page.locator('.layers-item-name', { hasText: 'Surviving text' })).toBeVisible();
expect(await getElementsCount(page)).toBe(1);
});
test('post-restore undo lands on the restored snapshot, not an empty canvas (May 22 regression)', async ({ page }) => {
// The headline May 22 bug: after restoration, the FIRST user
// action followed by Cmd+Z would walk past the restored snapshot
// and land on an empty canvas. Root cause was initializeHistory
// unconditionally wiping history to [empty], destroying the
// snapshot replaceElements had pushed during restoration.
//
// Post-fix sequence:
// 1. Add element (saved via auto-save).
// 2. Reload — restoration runs, replaceElements pushes the
// restored snapshot at idx 0, initializeHistory is a no-op.
// 3. Add another element — idx 1 (the just-added).
// 4. Cmd+Z — walks idx 1 → 0, restores the snapshot from
// step 2 (one element), NOT an empty canvas.
//
// Pre-fix, step 4 would land on the empty canvas because the
// floor at idx 0 was an empty array (wiped by initializeHistory).
await addTextViaSidebar(page, { text: 'Restored on reload' });
await page.reload();
await page.locator('.canvas-area').waitFor({ state: 'visible' });
await expect(page.locator('.layers-item-name', { hasText: 'Restored on reload' })).toBeVisible();
// Now add a second element. This is the "first user action after
// restoration" the bug was triggered by.
await addTextViaSidebar(page, { text: 'New action' });
expect(await getElementsCount(page)).toBe(2);
// Cmd+Z: must land on the post-restore state (one element), not
// empty. This is the assertion that would fail pre-fix.
await pressUndo(page);
expect(await getElementsCount(page)).toBe(1);
await expect(page.locator('.layers-item-name', { hasText: 'Restored on reload' })).toBeVisible();
await expect(page.locator('.layers-item-name', { hasText: 'New action' })).toHaveCount(0);
});
test('multiple elements all survive a reload', async ({ page }) => {
// Persistence isn't a single-element special case — the entire
// elements array gets saved. We add several with distinct text
// so we can verify each one survives by name.
await addTextViaSidebar(page, { text: 'Alpha' });
await addTextViaSidebar(page, { text: 'Bravo' });
await addTextViaSidebar(page, { text: 'Charlie' });
expect(await getElementsCount(page)).toBe(3);
await page.reload();
await page.locator('.canvas-area').waitFor({ state: 'visible' });
// All three should be in the layers panel.
expect(await getElementsCount(page)).toBe(3);
await expect(page.locator('.layers-item-name', { hasText: 'Alpha' })).toBeVisible();
await expect(page.locator('.layers-item-name', { hasText: 'Bravo' })).toBeVisible();
await expect(page.locator('.layers-item-name', { hasText: 'Charlie' })).toBeVisible();
});
test('shirt color preference survives a reload', async ({ page }) => {
// Non-elements persistence: shirt color, size, recent colors,
// etc. are also saved. Picking a non-default shirt color and
// verifying it survives confirms the preferences slice of the
// save shape works.
//
// Two UI quirks worth knowing for this test:
//
// 1. ShirtOptionsPanel is COLLAPSED by default — it shows only
// the summary chip + price until the user clicks to expand.
// The color radios live in the expanded content with
// `tabIndex={expanded ? 0 : -1}` so they're not even
// focusable when collapsed. We have to expand the panel
// before driving the radios. The collapse state is
// component-local and resets to collapsed on every fresh
// mount, so we have to re-expand after the reload too.
//
// 2. The color picker uses `role="radio"` with `aria-label`
// matching the bare color name (e.g. "Black") and uses
// `aria-checked` for the selected indicator. Earlier I
// guessed at `role="button"` + `aria-pressed`; both were
// wrong. The constants/shirt.js label is the source of
// truth for the accessible name.
const optionsToggle = page.getByRole('button', { name: /shirt options/i });
await optionsToggle.click();
const blackRadio = page.getByRole('radio', { name: 'Black' });
await blackRadio.click();
// Wait for the aria-checked update before reloading so we know
// the click registered and the save effect has had a chance to
// fire.
await expect(blackRadio).toHaveAttribute('aria-checked', 'true');
await page.reload();
await page.locator('.canvas-area').waitFor({ state: 'visible' });
// After reload the panel is collapsed again (state is
// component-local). Expand to inspect the restored selection.
await page.getByRole('button', { name: /shirt options/i }).click();
await expect(page.getByRole('radio', { name: 'Black' })).toHaveAttribute('aria-checked', 'true');
});

138
e2e/text-editing.spec.js Normal file
View File

@@ -0,0 +1,138 @@
// E2E: text element CRUD via the Text tab.
//
// What this guards
// ────────────────
// The most common editing flow in the app: open the Text tab, type a
// message, add it to the canvas, optionally edit or delete it.
// Vitest covers each piece (TextTab's local state, useDesignEditor's
// addElement, LayersPanel rendering) but not the whole pipeline as
// the user experiences it. This spec runs the actual integration.
//
// Why E2E rather than RTL component tests
// ───────────────────────────────────────
// TextTab is leaf-ish but composed with Sidebar, App's selectedElement
// logic, and useDesignEditor — testing it in RTL would require either
// mocking half the app (brittle) or rendering the real App with jsdom
// (which then breaks on Konva, because jsdom doesn't have a real
// canvas). Playwright drives the actual Chromium where everything
// works, including Konva.
//
// Selector strategy
// ─────────────────
// We use accessible queries throughout — getByRole, getByLabel,
// getByText. CSS class selectors would be tighter to the
// implementation and break on any styling refactor; the accessible
// roles describe what the user is looking at, which is stable across
// styling changes. The cost is occasional verbosity (`getByRole('tab',
// { name: 'Text' })` vs `.tab-text`) but the test stays valid as
// styles iterate.
import { test, expect } from '@playwright/test';
import {
gotoFreshEditor,
addTextViaSidebar,
getElementsCount,
} from './fixtures/editor.js';
test.beforeEach(async ({ page }) => {
// Every test starts on the editor with empty state. See
// gotoFreshEditor's docblock for the full reset sequence —
// localStorage + service workers + caches all wiped.
await gotoFreshEditor(page);
});
test('starts with zero elements', async ({ page }) => {
// Empty-state check. The LayersPanel renders a banner instead of
// a title when there are no elements, so getElementsCount returns
// 0 by checking for the title's absence.
expect(await getElementsCount(page)).toBe(0);
// The empty-state message is the canonical "no layers" indicator.
// We assert it's visible to confirm we're really on an empty
// editor — a partially-loaded editor without elements would also
// have a 0 count but no empty-state banner.
await expect(page.getByText('No elements yet')).toBeVisible();
});
test('adds a text element via the sidebar and shows it in the Layers panel', async ({ page }) => {
// The headline text-editing flow. addTextViaSidebar drives the
// Text tab → fill textarea → click Add → wait for the layer count
// to increment. After it returns, we make user-observable
// assertions about what's on the canvas.
await addTextViaSidebar(page, { text: 'Hello world' });
// Count is now 1.
expect(await getElementsCount(page)).toBe(1);
// The LayersPanel row for the new element. LayersPanel uses
// `getName(el)` which returns the text content for text elements,
// so "Hello world" should appear as the row label.
await expect(page.locator('.layers-item-name', { hasText: 'Hello world' })).toBeVisible();
});
test('adding two text elements lists both in the Layers panel, newest on top', async ({ page }) => {
// LayersPanel docblock: "Lists all elements on the canvas in
// render order (last in array = topmost)." The panel renders
// top-down with the most recently added on top, mirroring how
// users mentally stack layers in design tools.
await addTextViaSidebar(page, { text: 'First' });
await addTextViaSidebar(page, { text: 'Second' });
expect(await getElementsCount(page)).toBe(2);
// Order check: read all row names and verify "Second" precedes
// "First". allTextContents resolves to an array in DOM order;
// for a flex column that's top-to-bottom.
const names = await page.locator('.layers-item-name').allTextContents();
expect(names.indexOf('Second')).toBeLessThan(names.indexOf('First'));
});
test('editing the textarea after selecting a text element updates the layer name', async ({ page }) => {
// Bidirectional editing: selecting an existing text element on
// the canvas (or via the layers panel, which is what we do here
// since we don't want to deal with canvas pixel coordinates)
// should put the Text tab into edit mode against that element.
// Typing into the textarea then flows back to update the element's
// text on canvas AND in the layers panel.
await addTextViaSidebar(page, { text: 'Original' });
// Select the layer's row to make it the active element.
await page.locator('.layers-item-main', { hasText: 'Original' }).click();
// The Text tab auto-switches on selection (desktop auto-tab-switch
// effect in App.jsx). The textarea should now contain the
// selected element's text.
const textarea = page.getByLabel('Your message');
await expect(textarea).toHaveValue('Original');
// Edit it. fill() clears + types, mirroring a user selecting all
// and re-typing.
await textarea.fill('Updated');
// The layer name in the panel should reflect the new text. The
// update is debounced (300ms in useDesignEditor.updateElement),
// and the layer name reads from element.text directly, so it
// shows the new value as soon as state propagates.
await expect(page.locator('.layers-item-name', { hasText: 'Updated' })).toBeVisible();
// And the count is still 1 — editing doesn't add a new element.
expect(await getElementsCount(page)).toBe(1);
});
test('deleting a text element from the Layers panel removes it', async ({ page }) => {
await addTextViaSidebar(page, { text: 'Doomed' });
await addTextViaSidebar(page, { text: 'Survivor' });
expect(await getElementsCount(page)).toBe(2);
// Each row has a trash-can delete button as a sibling of the
// main row button. LayersPanel labels it
// `aria-label="Delete ${getName(element)}"`, so we can target
// the specific row's delete button by accessible name.
await page.getByRole('button', { name: 'Delete Doomed' }).click();
expect(await getElementsCount(page)).toBe(1);
// The remaining row is the one we didn't touch.
await expect(page.locator('.layers-item-name', { hasText: 'Survivor' })).toBeVisible();
// And the deleted row is gone.
await expect(page.locator('.layers-item-name', { hasText: 'Doomed' })).toHaveCount(0);
});

View File

@@ -2,8 +2,22 @@ import js from '@eslint/js'
import globals from 'globals' import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks' import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh' import reactRefresh from 'eslint-plugin-react-refresh'
import jsxA11y from 'eslint-plugin-jsx-a11y'
import { defineConfig, globalIgnores } from 'eslint/config' import { defineConfig, globalIgnores } from 'eslint/config'
// ── Lint config (S14) ──────────────────────────────────────────────────────
//
// On top of the React/hooks defaults, jsx-a11y catches the common
// accessibility gaps that creep into icon-button-heavy UIs like this one:
// missing aria-labels on bare-icon buttons, alt-less <img>, click-only
// affordances on non-interactive elements, etc.
//
// We use jsxA11y's `recommended` ruleset rather than `strict` because
// strict adds rules (no-onchange, click-events-have-key-events) that
// produce a high false-positive rate on existing controlled-component
// code without meaningfully improving accessibility. The recommended set
// is the consensus minimum for production React apps.
export default defineConfig([ export default defineConfig([
globalIgnores(['dist']), globalIgnores(['dist']),
{ {
@@ -12,6 +26,7 @@ export default defineConfig([
js.configs.recommended, js.configs.recommended,
reactHooks.configs.flat.recommended, reactHooks.configs.flat.recommended,
reactRefresh.configs.vite, reactRefresh.configs.vite,
jsxA11y.flatConfigs.recommended,
], ],
languageOptions: { languageOptions: {
ecmaVersion: 2020, ecmaVersion: 2020,
@@ -24,6 +39,52 @@ export default defineConfig([
}, },
rules: { rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]', argsIgnorePattern: '^_' }], 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]', argsIgnorePattern: '^_' }],
// jsx-a11y tweaks for this codebase ─────────────────────────────────
//
// The codebase has several `onClick` handlers on Konva-rendered
// shapes (Group, Rect) that don't have a corresponding key handler
// because they're inside a <canvas> and don't take keyboard focus.
// jsx-a11y can't tell that those are Konva nodes, not DOM, so we
// disable the click-events-have-key-events rule globally rather
// than littering eslint-disable comments through canvas code.
'jsx-a11y/click-events-have-key-events': 'off',
'jsx-a11y/no-noninteractive-element-interactions': 'off',
'jsx-a11y/no-static-element-interactions': 'off',
// The label-has-associated-control rule fires on labels that wrap
// a custom <input type="color"> picker (the swatch grid in
// TextTab uses `<label>` as the click target with the color input
// hidden inside). The pattern is correct accessibility-wise — the
// label IS the control's container — but the rule's heuristics
// miss it. We turn it off rather than inverting the structure.
'jsx-a11y/label-has-associated-control': 'off',
},
},
{
// Test files. Pulled into a separate block so we can:
// 1. Add Vitest's global names (describe, it, expect, vi,
// beforeEach, afterEach, etc.) to the lint globals —
// otherwise they trip the no-undef rule.
// 2. Loosen the unused-vars rule for `_` destructured props,
// which test files use heavily when partially-mocking
// hook return shapes.
// The main src/ rules above still apply via the file-pattern
// overlap (this block ADDS to them, doesn't replace them).
files: ['src/**/*.{test,spec}.{js,jsx}', 'src/test/**/*.{js,jsx}'],
languageOptions: {
globals: {
...globals.browser,
describe: 'readonly',
it: 'readonly',
test: 'readonly',
expect: 'readonly',
vi: 'readonly',
beforeAll: 'readonly',
afterAll: 'readonly',
beforeEach: 'readonly',
afterEach: 'readonly',
},
}, },
}, },
{ {

44
fonts/README.md Normal file
View File

@@ -0,0 +1,44 @@
# Server-side font files
This directory is scanned by `server.js` at startup. Each `.ttf` or `.otf` file
is registered with node-canvas via `registerFont()` so that exports render
text in the same fonts the editor previews.
## Filename convention
```
<Family_Name>-<Variant>.ttf
```
- Spaces in family names become underscores in the filename so the file is
portable across operating systems. The server replaces them back to spaces
at registration time.
- The variant is parsed for the substrings `Bold` and `Italic` to set the
`weight` and `style` registration options. `Regular` (or anything else) is
treated as the default weight.
Examples:
| File | Family | Weight | Style |
|---|---|---|---|
| `Roboto-Regular.ttf` | Roboto | normal | normal |
| `Roboto-Bold.ttf` | Roboto | bold | normal |
| `DM_Sans-Regular.ttf` | DM Sans | normal | normal |
| `Open_Sans-BoldItalic.ttf` | Open Sans | bold | italic |
## Populating this directory
```
npm run fetch-fonts
```
This downloads TTFs for the editor's font list from the Fontsource jsDelivr
CDN. Re-running is a no-op for already-present files; pass `--force` to
re-download.
## What if I leave it empty?
The server still starts and exports still work, but every text element will
render in whatever fallback font the host system happens to have for the
requested family. On a stock Alpine container that's a generic sans-serif. On
macOS dev machines, system-installed fonts usually fill in for the common
names but not the more specialized ones.

View File

@@ -3,12 +3,12 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Apparel Designer</title> <meta name="theme-color" content="#fdf2f4" />
<title>Pawfectly Yours — Customize Your Shirt</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&family=Open+Sans:wght@400;600;700&family=Lato:wght@400;700&family=Montserrat:wght@400;600;700&family=Oswald:wght@400;500;700&family=Raleway:wght@400;600;700&family=Poppins:wght@400;500;600;700&family=Roboto+Condensed:wght@400;700&family=Source+Sans+3:wght@400;600;700&family=Roboto+Slab:wght@400;700&family=Merriweather:wght@400;700&family=Ubuntu:wght@400;500;700&family=Playfair+Display:wght@400;600;700&family=Nunito:wght@400;600;700&family=Rubik:wght@400;500;600;700&family=Work+Sans:wght@400;500;600;700&family=Lora:wght@400;500;600;700&family=Fira+Sans:wght@400;500;600;700&family=Barlow:wght@400;500;600;700&family=Bebas+Neue&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Barlow:wght@400;500;600;700&family=Bebas+Neue&family=Caveat:wght@400;600;700&family=DM+Sans:wght@400;500;700&family=Fira+Sans:wght@400;500;600;700&family=Lato:wght@400;700&family=Lora:wght@400;500;600;700&family=Merriweather:wght@400;700&family=Montserrat:wght@400;600;700&family=Nunito:wght@400;500;600;700;800&family=Open+Sans:wght@400;600;700&family=Oswald:wght@400;500;700&family=Pacifico&family=Playfair+Display:wght@400;600;700&family=Poppins:wght@400;500;600;700&family=Raleway:wght@400;600;700&family=Roboto:wght@400;500;700&family=Roboto+Condensed:wght@400;700&family=Roboto+Slab:wght@400;700&family=Rubik:wght@400;500;600;700&family=Source+Sans+3:wght@400;600;700&family=Space+Mono:wght@400;700&family=Ubuntu:wght@400;500;700&family=Work+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

3910
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,40 +5,59 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "concurrently \"vite\" \"DYLD_INSERT_LIBRARIES='' node --watch server.js\"", "dev": "concurrently \"vite\" \"node --watch server.js\"",
"dev:win": "concurrently \"vite\" \"node --watch server.js\"", "dev:win": "concurrently \"vite\" \"node --watch server.js\"",
"build": "vite build", "build": "vite build",
"start": "node server.js", "start": "node server.js",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview" "preview": "vite preview",
"fetch-fonts": "node scripts/fetch-fonts.mjs",
"test": "vitest",
"test:run": "vitest run",
"e2e": "playwright test --ui",
"e2e:run": "playwright test",
"e2e:install": "playwright install chromium"
}, },
"dependencies": { "dependencies": {
"@emotion/is-prop-valid": "^1.4.0",
"@huggingface/transformers": "^3.4.0", "@huggingface/transformers": "^3.4.0",
"canvas": "^2.11.2", "canvas": "^3.1.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.18.2", "express": "^4.18.2",
"express-rate-limit": "^7.4.0",
"konva": "^10.0.0", "konva": "^10.0.0",
"multer": "^1.4.5-lts.1", "multer": "^2.0.0",
"pino": "^9.5.0",
"pino-http": "^10.3.0",
"react": "^19.2.5", "react": "^19.2.5",
"react-dom": "^19.2.5", "react-dom": "^19.2.5",
"react-filerobot-image-editor": "^4.8.1", "react-filerobot-image-editor": "^4.8.1",
"react-konva": "^19.2.3", "react-konva": "^19.2.3",
"react-select": "^5.8.0",
"sharp": "^0.33.2", "sharp": "^0.33.2",
"styled-components": "^6.4.1",
"use-image": "^1.1.1", "use-image": "^1.1.1",
"uuid": "^9.0.1" "uuid": "^9.0.1",
"zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.4", "@eslint/js": "^9.39.4",
"@playwright/test": "^1.50.0",
"@testing-library/jest-dom": "^6.6.0",
"@testing-library/react": "^16.2.0",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"concurrently": "^8.2.0", "concurrently": "^8.2.0",
"eslint": "^9.39.4", "eslint": "^9.39.4",
"eslint-plugin-jsx-a11y": "^6.10.0",
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.5.0", "globals": "^17.5.0",
"jsdom": "^26.0.0",
"vite": "^8.0.9", "vite": "^8.0.9",
"vite-plugin-pwa": "^0.20.5", "vite-plugin-pwa": "^0.20.5",
"vitest": "^3.0.0",
"workbox-window": "^7.1.0" "workbox-window": "^7.1.0"
}, },
"engines": { "engines": {

112
playwright.config.js Normal file
View File

@@ -0,0 +1,112 @@
// Playwright configuration for end-to-end tests.
//
// What this covers vs. Vitest
// ───────────────────────────
// Vitest (in src/**/*.test.js) covers pure functions and the React
// hook-state portion of useDesignEditor — anything testable without
// mounting Konva. Playwright covers what Vitest can't: real Konva
// stages, the actual keyboard handler attached to window, persistence
// across reloads, the multi-component flows where state has to thread
// through App.jsx, the Sidebar, the canvas, and back.
//
// Test layout
// ───────────
// /e2e/
// fixtures/ ← shared setup helpers
// text-editing.spec.js ← adding/editing/deleting text elements
// history.spec.js ← undo/redo across operations
// persistence.spec.js ← state survives page reload
// crop.spec.js ← the headline May 22 bug — crop + Cmd-Z
// must restore the uncropped image
//
// Webserver
// ─────────
// `npm run dev` runs Vite (3000) + the Node server (3001) via
// concurrently. Playwright waits for the Vite URL to be reachable
// before starting tests. The 120-second timeout accommodates cold
// starts on first run (Vite optimizes deps, the Node server's
// dependencies are heavy).
//
// `reuseExistingServer: !CI` means if a dev server is already running
// locally, Playwright connects to that rather than starting a second
// one (which would fail on the port collision). In CI we always start
// a fresh one for hermetic runs.
//
// Browsers
// ────────
// Chromium only. The app's behaviour is engine-dependent in subtle
// places (Konva canvas pixel rounding, native EyeDropper API is
// Chromium-only), so Firefox/WebKit coverage would either flake or
// have to skip large chunks of the suite. Chromium is what the
// dominant user base will hit; adding more engines is a future
// quality decision once Chromium is solid.
import { defineConfig, devices } from '@playwright/test';
const PORT = 3000;
const BASE_URL = `http://localhost:${PORT}`;
export default defineConfig({
testDir: './e2e',
// The dev server is shared across tests in a file; running them in
// parallel within a file would mean simultaneous localStorage
// wipes and weird cross-test state. Within a file, tests run
// sequentially (default). Across files, parallel is fine — each
// file gets a fresh browser context.
fullyParallel: true,
// Hard fail in CI on .only(). Locally, .only() is a useful
// workflow for iterating on a single failing test.
forbidOnly: !!process.env.CI,
// Retry once in CI for flakes (Konva mount timing, font load
// races). Locally, no retries — a flake should fail and be
// investigated, not papered over.
retries: process.env.CI ? 1 : 0,
// CI: single worker for hermetic runs. Local: Playwright picks
// based on cores (typically 50% of available, capped at 8).
workers: process.env.CI ? 1 : undefined,
reporter: process.env.CI ? 'github' : 'list',
use: {
baseURL: BASE_URL,
// Capture trace on first retry so flaky tests in CI leave us
// breadcrumbs (full DOM snapshot, network log, screenshots).
// Tracing is expensive — we don't want it on every run.
trace: 'on-first-retry',
// Screenshot on failure for the same reason. The screenshot is
// attached to the test report automatically.
screenshot: 'only-on-failure',
// Don't record video by default — produces large files and is
// rarely the right tool. Enable per-test if needed.
video: 'off',
// Default timeout for individual actions (click, fill, etc.).
// The app's UI is responsive; 10s is a generous ceiling for
// anything that should be near-instant.
actionTimeout: 10_000,
// Navigation timeout — `goto`/reload waits up to 30s. Cold
// first paint can be slow when Vite is also compiling deps.
navigationTimeout: 30_000,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
webServer: {
command: 'npm run dev',
url: BASE_URL,
// 120s for cold starts (Vite optimizes deps, fonts load, etc).
timeout: 120 * 1000,
// Locally, if a dev server is already up, just connect to it.
// In CI, always spin a fresh one.
reuseExistingServer: !process.env.CI,
// Pipe server output to stdout for debugging when tests fail —
// a 500 from the API or a Vite compile error becomes visible
// in the Playwright output rather than disappearing into the
// background process.
stdout: 'pipe',
stderr: 'pipe',
},
});

64
public/stickers/README.md Normal file
View File

@@ -0,0 +1,64 @@
# Stickers
Drop sticker images into this folder. They'll be picked up automatically by the editor's Stickers tab on the next page load — there's no manifest to update and no constants file to edit.
## Filename convention
```
<category>__<sticker_name>.<ext>
```
The **category** comes before the double underscore. The **sticker name** comes after, with single underscores separating words.
A few examples:
| Filename | Category | Display name |
| -------------------------------- | -------- | ----------------- |
| `hearts__pink_heart.png` | Hearts | Pink Heart |
| `hearts__beating_heart.png` | Hearts | Beating Heart |
| `paws__small_paw_print.png` | Paws | Small Paw Print |
| `bones__dog_bone.png` | Bones | Dog Bone |
| `pets__golden_retriever.png` | Pets | Golden Retriever |
| `stars__sparkle.png` | Stars | Sparkle |
The category and each word in the sticker name are title-cased when displayed (`pink_heart``Pink Heart`). Lowercase your filenames; capitalization is added automatically.
## Supported formats
- `.png` (recommended — transparency supported)
- `.webp` (transparency supported)
- `.jpg` / `.jpeg`
- `.svg`
PNG with transparency is the best choice for print-quality stickers. JPEG is fine for opaque art but doesn't blend onto colored shirts as cleanly.
## Sizing
There's no fixed size requirement. The editor places each sticker at 80×80 design units on add (the user can resize from there), and the print export renders the canvas at 4500×4500 pixels — so the sticker is scaled to roughly 1200×1200 px in the final print.
That math has consequences for source image size:
- **Small sources (under ~100px)** scale up heavily for print. A 47×47 PNG, for example, is upscaled ~25× to reach print resolution, which produces visible pixel-art / bitmap artifacts. If you *want* that look (deliberate retro / pixel sprites), it's fine. If you want crisp print output, source images should be larger.
- **For crisp print output**, aim for at least **600×600 px** sources (matches the print-scale target with room to spare). 1024×1024 is comfortable headroom; beyond ~2048×2048 you're wasting bandwidth without any visible gain.
- **Vector (SVG) sources** sidestep the issue entirely — they scale cleanly to any size. Recommended where the artwork allows it.
The editor preserves aspect ratio on add (the sticker scales to fit an 80×80 box, not stretched to fill it).
## Categories
Categories are derived from filenames — there's no fixed list. Adding a file with a new category prefix automatically adds the category pill to the Stickers tab. Removing the last file in a category removes the pill.
If you want a specific display order for categories, prefix them with a digit (e.g. `1-hearts__pink.png`, `2-stars__sparkle.png`); the leading digit + dash is stripped from the display name but used for sorting. Without prefixes, categories are listed alphabetically.
## A note on file size
Images here are bundled into the Vite `public/` directory and served as static assets. They are NOT processed by the build, so file size translates 1:1 to network cost. If your source images are heavy, run them through `pngquant` or `cwebp` before dropping them in:
```sh
pngquant --quality 75-90 --strip --output optimized.png input.png
cwebp -q 85 input.png -o output.webp
```
## Lazy loading
Sticker thumbnails use `<img loading="lazy">`, so only the stickers visible (or near-visible) in the panel are fetched. A library of 200+ stickers won't block the initial page load.

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Some files were not shown because too many files have changed in this diff Show More