# Apparel Designer — Bug Fixes

This document summarizes the code changes that fix the bugs documented in `BUGS.md`. All twelve critical (C1–C12), seven major (M1–M7), and nine minor (m1–m9) 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. |
| 901–1100 px | Header pill button labels hide; right panel narrows from 420px to 360px. |
| 769–900 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.


