Break editor into a module

This commit is contained in:
khalid@traclabs.com
2026-05-24 08:44:29 -05:00
parent 1d01442a39
commit 070c95e254
119 changed files with 5318 additions and 26124 deletions

321
MIGRATION_RUNBOOK.md Normal file
View File

@@ -0,0 +1,321 @@
# Migration runbook — apparel-designer → goods-editor module
Step-by-step instructions for executing the module extraction. This
document is intentionally minimal-fluff: do each step in order, verify
the marker at each checkpoint, move to the next.
## Prerequisites
Verify both repos exist as siblings:
```
/Users/khalid/Documents/Claude-Desktop/
├── apparel-designer/ (the host app, in working state)
└── goods-editor-module/ (the empty module repo we just scaffolded)
```
## Phase 1 — Module scaffolding (already done)
These were created during the planning session and are ready to use:
```
goods-editor-module/
├── package.json
├── vite.config.js (library mode)
├── vite.server.config.js (Node server entry)
├── vitest.config.js
├── .gitignore
├── README.md
├── src/
│ ├── ApparelDesigner.jsx (~30KB orchestration absorbed from App.jsx)
│ ├── index.js (public API barrel)
│ └── server/
│ ├── index.js
│ └── mountEditorApi.js (Express middleware factory)
├── examples/dev-host/ (standalone dev playground)
└── scripts/
├── release.sh
└── post-migrate-patches.mjs
```
**Verification:** `ls goods-editor-module/src/index.js` should succeed.
## Phase 2 — File migration
Copy all the editor source from the host into the module.
```bash
cd /Users/khalid/Documents/Claude-Desktop
bash apparel-designer/scripts/migrate-to-module.sh
```
**What this does:** Copies utils, hooks, constants, components,
styles, fonts, stickers, tests from `apparel-designer/src/` (and
related directories) into `goods-editor-module/src/`. Source is left
intact in `apparel-designer/` — nothing is deleted yet.
**Verification:** `ls goods-editor-module/src/components/canvas/`
should show DesignCanvas.jsx, ElementToolbar.jsx, etc.
## Phase 3 — Post-copy patches
The copied files import constants like `SHIRT_COLORS` directly. Inside
the module, those need to come from props. Run the patch script:
```bash
cd /Users/khalid/Documents/Claude-Desktop/goods-editor-module
node scripts/post-migrate-patches.mjs
```
**What this does:** Modifies a handful of files (`ShirtOptionsPanel`,
`Sidebar`, `UploadTab`) to accept their data as props instead of
importing constants.
**Verification:** Search the module source for `SHIRT_COLORS` — should
return 0 hits in component files (still present as a `constants/shirt.js`
default that's no longer imported by components).
## Phase 4 — Module install + build
```bash
cd /Users/khalid/Documents/Claude-Desktop/goods-editor-module
npm install
npm run build
```
**Verification:** `ls dist/` should show:
- `goods-editor.es.js`
- `goods-editor.cjs.js`
- `style.css`
- `server/index.js`
- `server/index.cjs`
If the build fails:
- **"Cannot find module 'X'"** — the migration may have missed a file.
Check `goods-editor-module/src/` for the missing path and copy it
from `apparel-designer/src/` manually.
- **"SHIRT_COLORS is not defined"** — patch script didn't run or didn't
fully match. Manually search for the symbol in the offending file
and replace with the prop.
## Phase 5 — Module dev-host smoke test (optional but recommended)
Verify the module works in isolation before wiring it into the host:
```bash
cd /Users/khalid/Documents/Claude-Desktop/goods-editor-module
npm run dev
# open http://localhost:3000 in a browser
```
You should see the editor with the dev host's minimal top bar. If
something's broken it's a module issue; fix here before touching the
host.
The dev host doesn't have an /api/upload endpoint, so uploads will
fail — that's expected. Text, stickers, emoji, and templates work
without a server.
## Phase 6 — Host cutover
Replace the host's monolithic App.jsx + server.js with the new
module-consuming versions:
```bash
cd /Users/khalid/Documents/Claude-Desktop/apparel-designer
bash scripts/cutover-host.sh
```
**What this does:**
1. Renames the monolithic files to `*.monolith.*` (kept for reference)
2. Promotes `App.host.jsx``App.jsx`, `server.host.js``server.js`,
`package.host.json``package.json`
3. Deletes the module-owned source from `src/`
**Verification:** `ls apparel-designer/src/` should show only:
- `App.jsx`
- `App.monolith.jsx` (backup)
- `main.jsx`
- `index.css`
- `components/Header.jsx`, `components/PWAInstall.jsx`,
`components/OfflineIndicator.jsx`
- `styles/Header.css`, `styles/PWAInstall.css` (or however they
organize after cleanup)
(The host's `src/styles/` and `src/components/` may still have
host-specific CSS that wasn't deleted. That's expected — only
module-owned files were removed.)
## Phase 7 — Host install + run
```bash
cd /Users/khalid/Documents/Claude-Desktop/apparel-designer
rm -rf node_modules package-lock.json
npm install
npm run dev
```
The `file:../goods-editor-module` reference in package.json points at
the local module directory. npm install will copy it into
`node_modules/goods-editor/`.
### Important: host-owned constants survive cutover
The `cutover-host.sh` script preserves three files in `src/constants/`:
- `shirt.js` — host's color / size / price catalog
- `templates.js` — host's template definitions
- `products.js` — host's product configuration
These are imported by the new `App.jsx` and passed into
`<ApparelDesigner>` as props. Other constants (fonts, stickers,
elements, etc.) are module-owned and get deleted.
If you customize the host's catalog (different shirt colors, new
templates), you edit those files — not the module.
### Important: host-required native deps for server-side export
`goods-editor/server`'s `mountEditorApi()` accepts the host's `canvas`
and `sharp` modules via options:
```js
import canvas from 'canvas';
import sharp from 'sharp';
import { mountEditorApi } from 'goods-editor/server';
mountEditorApi(app, { canvas, sharp, exportsDir, fontsDir, ... });
```
This injection pattern means:
- The module declares both as **optional peer dependencies** — listed
for tooling visibility but not auto-installed.
- Hosts that mount the server install canvas + sharp in their OWN
`package.json` (as regular deps) and import them at the top of
their `server.js`.
- Hosts that DON'T mount the server (editor-only, preview-only)
don't install them at all. The React side of `goods-editor` doesn't
need them.
Why injection rather than `require()` inside the module: when the
module is consumed via `file:` (local dev) or `git+ssh://` (release),
the module's built server bundle lives in the consuming app's
`node_modules`. A `require('canvas')` from inside that bundle would
resolve from the MODULE'S directory chain, not the host's. The
injection pattern sidesteps this entirely — the host imports canvas
from its own `node_modules` and passes the imported object in.
**System libs:** `canvas` builds native bindings against cairo/pango.
On macOS:
```
brew install pkg-config cairo pango libpng jpeg giflib librsvg pixman
```
If `npm install` fails on the canvas build step, those libs are
usually the missing piece.
If the host doesn't need server-side export (e.g. editor-only flows),
skip the `mountEditorApi(app, ...)` call in `server.js` entirely and
don't install canvas/sharp — the editor still works as a pure React
component.
### Important: the host's `virtual:sticker-manifest` setup stays in place
The module's `src/constants/stickers.js` imports from
`virtual:sticker-manifest` — a Vite virtual module that the HOST'S
`vite.config.js` provides via `stickerManifestPlugin()`. This is
intentional: it makes the host the source-of-truth for which stickers
are available.
**Don't delete `stickerManifestPlugin()` from the host's
`vite.config.js`,** and don't delete `public/stickers/`. The cutover
script leaves both alone. If you accidentally remove the plugin, you
will see this error when running the editor:
```
Failed to resolve import "virtual:sticker-manifest" from
"node_modules/goods-editor/dist/goods-editor.es.js".
```
The fix is to restore the plugin (it's the `stickerManifestPlugin()`
function at the top of `vite.config.js`, plus its inclusion in the
`plugins:` array).
If you want to ship the editor on a host that doesn't use Vite, or
that has a fundamentally different sticker layout, you can pass
stickers in via props on `<ApparelDesigner>` instead and provide a
no-op virtual module that exports an empty `STICKER_FILES` array.
(That extension point doesn't exist yet — file an issue / add a prop
when the second host needs it.)
**Verification:** `http://localhost:3000` should show the editor with
the host's full Header and the editor mounted below it. Functionality
should match the pre-migration version exactly.
## Phase 8 — Final checks
Things to verify in the running app:
- [ ] Can add text from the Text tab
- [ ] Can upload a photo and see it on the canvas
- [ ] Can add a sticker / emoji
- [ ] Cmd+Z undoes; Cmd+Shift+Z redoes
- [ ] Crop an image, Cmd+Z restores pre-crop (the May 22 regression)
- [ ] Save button triggers an export (download via the export pipeline)
- [ ] Reload the page — designs persist via localStorage
- [ ] Production build: `npm run build && NODE_ENV=production npm start`
If everything works:
1. `git add -A && git commit -m "Migrate to goods-editor module"` in both
repos
2. Cut a release of the module:
```bash
cd goods-editor-module
./scripts/release.sh --version=0.1.0-alpha.0
```
3. In the host, swap the dep from `file:` to `git+ssh://` once you've
pushed to Gitea.
## Rollback
If anything goes sideways and you need to undo the cutover:
```bash
cd apparel-designer
mv src/App.jsx src/App.host.jsx
mv server.js server.host.js
mv package.json package.host.json
mv src/App.monolith.jsx src/App.jsx
mv server.monolith.js server.js
mv package.monolith.json package.json
# Restore deleted module-owned files from git:
git checkout -- src/ fonts/ e2e/ playwright.config.js vitest.config.js
rm -rf node_modules package-lock.json
npm install
npm run dev
```
The `goods-editor-module/` repo stays put — rollback only affects the
host. You can iterate on the module separately and try the cutover
again later.
## Done
After successful cutover, future development looks like:
- **Editor changes** (canvas behavior, sidebar UX, hooks, utilities):
edit in `goods-editor-module/`. Test via `npm run dev` (dev host).
When happy, `./scripts/release.sh --version=X.Y.Z` and bump the
host's `package.json`.
- **Host changes** (homepage, marketing, cart, top bar): edit in
`apparel-designer/`. AI tools pointed at this directory only see
the host's ~10 files plus the editor's public API surface, not the
editor's ~100 internal files. That's the win.

View File

@@ -1,235 +0,0 @@
// E2E: crop flow + the May 22 "canvas blanks on Cmd+Z after crop"
// regression.
//
// What this guards
// ────────────────
// The headline bug from the May 22 cleanup arc: cropping an image and
// then pressing Cmd+Z would land on an empty canvas instead of
// restoring the pre-crop state. Root cause was in the initializeHistory
// path — see Refinements_2026-05-22.md for the full story. The unit
// tests cover the math (cropMath) and the history-pointer behaviour
// (useDesignEditor) at the function level; this spec exercises the
// full pipeline end-to-end so a future regression that breaks any
// link in the chain (the Konva stage walk for naturalW/H, the
// updateAndCommit wiring, the keyboard handler firing on undo) gets
// caught.
//
// Why this spec is more complex than the other three
// ──────────────────────────────────────────────────
// Cropping is image-only — ElementToolbar's `allowCrop = isImage &&
// !!onStartCrop` gates the Crop button on type === 'image'. Stickers
// don't get a Crop button (even though App's handleStartCrop accepts
// them). That means this spec can't use a one-click sticker add; it
// needs a real file upload via the Upload Photo tab.
//
// Rather than committing a binary fixture file to the repo (and
// dealing with Git LFS or just bloat), we build the upload payload
// inline as a base64-encoded 1×1 transparent PNG. Playwright's
// `setInputFiles({ name, mimeType, buffer })` accepts an in-memory
// buffer with no file on disk. The PNG is the smallest valid PNG
// the spec can use — 67 bytes — which keeps the upload fast and
// avoids any minimum-dimension validation surprises.
//
// Image-load timing
// ─────────────────
// The Konva Image node loads the src URL asynchronously after the
// element is added to state. handleApplyCrop walks the stage to
// find the image node and read naturalWidth/Height; if those are
// 0 (image not yet decoded), it surfaces the
// "Photo is still loading. Try Apply again in a moment." toast
// and keeps crop mode active. Our helper below clicks Apply, then
// waits for crop mode to exit (Apply button vanishes); if it
// doesn't within a short window, that means the toast fired and
// we should wait + retry. This is the same UX a human user
// follows ("oh, didn't work, let me click again in a second").
import { test, expect } from '@playwright/test';
import {
gotoFreshEditor,
getElementsCount,
pressUndo,
} from './fixtures/editor.js';
// Smallest valid PNG: 1×1 transparent. 67 bytes raw, ~90 base64
// chars. Decoded to a buffer for the upload. The image is too small
// to be visually meaningful but that's fine — the bug we're guarding
// against is history-shaped, not pixel-shaped. Crop math reduces to
// an identity crop on a 1×1 source, which still pushes a real
// history entry that undo must restore from.
const TINY_PNG_BASE64
= 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=';
/**
* Click "Apply crop" and wait for crop mode to exit. If the
* "Photo is still loading" toast appears (because the Konva
* Image node hasn't decoded the src yet), wait briefly and try
* again — up to a small attempt budget.
*
* Success signal: the Apply button stops being visible (because
* the ElementToolbar drops back to its non-crop face).
*
* This mirrors the toast-and-retry guidance in messages.js:
* 'toast.crop-not-ready': 'Photo is still loading. Try Apply
* again in a moment.'
*/
async function applyCropWithRetry(page) {
const applyButton = page.getByRole('button', { name: 'Apply crop' });
// 8 attempts × ~1.5s budget per attempt + intermediate waits =
// up to ~20s for the image to decode. In practice the buffer is
// already in memory so this resolves on the first attempt; the
// retry budget is purely defensive against slow CI environments.
for (let attempt = 0; attempt < 8; attempt++) {
await applyButton.click();
// If crop mode exits within the timeout, Apply succeeded.
// We use `waitFor` on the button's hidden state because
// toBeHidden() polls but doesn't return a boolean.
const exited = await applyButton
.waitFor({ state: 'hidden', timeout: 1500 })
.then(() => true)
.catch(() => false);
if (exited) return;
// Apply button still visible → toast probably fired. Wait
// for the image to finish loading. The toast itself
// auto-dismisses; we just need the underlying load to
// complete before the next click.
await page.waitForTimeout(500);
}
throw new Error('Apply crop did not exit crop mode after 8 attempts');
}
test.beforeEach(async ({ page }) => {
await gotoFreshEditor(page);
});
test('cropping an uploaded image and then Cmd/Ctrl+Z does not blank the canvas (May 22 regression)', async ({ page }) => {
// Step 1 — Switch to the Upload Photo tab. In a fresh editor
// the default tab is Upload, so this is mostly a defensive
// click in case that default changes; either way it makes the
// file input findable.
await page.getByRole('tab', { name: /upload/i }).click();
// Step 2 — Upload the PNG. The file input is hidden via CSS
// (`ut__hidden-input`); Playwright's setInputFiles bypasses
// visibility checks. The upload triggers a POST to /api/upload,
// which is processed by the Node server (Vite proxies the
// /api/* prefix to localhost:3001). The server's multer+sharp
// pipeline writes a preview and original to disk and returns
// their URLs; UploadTab's placeImage then adds the element.
//
// The selector targets `.ut__hidden-input` rather than a bare
// `input[type="file"]` because the page also contains a slot-
// upload hidden input (from the template-slot system) with
// accept="image/*" — a bare query would resolve to two elements
// and trip Playwright's strict mode. The class selector is
// unique to UploadTab.
await page.locator('input.ut__hidden-input').setInputFiles({
name: 'pixel.png',
mimeType: 'image/png',
buffer: Buffer.from(TINY_PNG_BASE64, 'base64'),
});
// Step 3 — Wait for the upload to complete and the element
// to be added. expect.poll handles the upload's async chain
// (server roundtrip + image dimension probe + addElement
// commit). The default expect timeout (5s) is plenty for a
// 67-byte upload; if it ever flakes here, CI is overloaded
// and the test budget should grow rather than this assertion.
await expect.poll(() => getElementsCount(page)).toBe(1);
// Step 4 — Make sure the element is selected so the
// ElementToolbar renders. The upload flow may or may not
// auto-select (depending on App's handleAddImage); clicking
// the layer row to select is defensive and explicit. The
// row's main button has class `.layers-item-main` and
// contains the element's display name (typically "Photo"
// or a filename-derived label for uploads).
await page.locator('.layers-item-main').first().click();
// Step 5 — Click the Crop button. It only renders for
// type==='image' elements (allowCrop guard in
// ElementToolbar). The button's accessible name comes from
// its ToolbarButton's `label` prop: "Crop image".
await page.getByRole('button', { name: /crop image/i }).click();
// Step 6 — The toolbar swapped to its crop-mode face;
// confirm by waiting for the Apply button before we drive it.
await expect(page.getByRole('button', { name: 'Apply crop' })).toBeVisible();
// Step 7 — Apply the crop. We don't modify the crop rect —
// this is an IDENTITY crop, which still pushes a real
// history entry (the element gets a crop attribute set even
// though sx/sy=0 and sWidth/sHeight match naturalW/H). The
// headline bug doesn't depend on a meaningful crop; it
// depends on undo restoring the pre-crop snapshot.
await applyCropWithRetry(page);
// Step 8 — Sanity: still one element on canvas (the crop
// committed without removing the image).
expect(await getElementsCount(page)).toBe(1);
// Step 9 — THE BUG CHECK. Cmd/Ctrl+Z must walk history back
// to the pre-crop snapshot. Pre-fix, this would land on an
// empty canvas (element count → 0); post-fix, the element
// is still there.
await pressUndo(page);
expect(await getElementsCount(page)).toBe(1);
// Step 10 — Double check: the LayersPanel row for the
// image is still visible. A bug variant where the element
// exists in state but is invisible on canvas would slip
// past the count check alone; asserting the panel row
// catches the case where elements is still length 1 but
// populated with garbage.
await expect(page.locator('.layers-item-main')).toHaveCount(1);
});
test('cancelling crop leaves the original element untouched', async ({ page }) => {
// Sanity test for the cancel path. Not a regression check —
// just confirming the crop UI is reversible without going
// through history. If a future change accidentally pushes a
// history entry on Cancel (or worse, applies the crop
// anyway), this test catches it.
await page.getByRole('tab', { name: /upload/i }).click();
await page.locator('input.ut__hidden-input').setInputFiles({
name: 'pixel.png',
mimeType: 'image/png',
buffer: Buffer.from(TINY_PNG_BASE64, 'base64'),
});
await expect.poll(() => getElementsCount(page)).toBe(1);
// History-state baseline. After adding ONE element, history is
// `[empty, [img]]` at idx=1 — the Undo button is ENABLED
// ("step back to the empty floor"). We capture this enabled
// state so we can verify Cancel doesn't ADD another history
// entry on top.
//
// The trip wire isn't "Undo disabled" — it's enabled either
// way. The trip wire is the count of clickable history steps:
// a single Undo click should land us at the empty canvas
// (0 elements). If Cancel had pushed a history entry, Undo
// would walk back to that entry first (still 1 element, just
// a different snapshot) and only the SECOND undo would empty
// the canvas. We test the count-after-one-undo as the
// definitive check.
const undoButton = page.getByRole('button', { name: 'Undo' });
await expect(undoButton).toBeEnabled();
await page.locator('.layers-item-main').first().click();
await page.getByRole('button', { name: /crop image/i }).click();
await expect(page.getByRole('button', { name: 'Apply crop' })).toBeVisible();
// Cancel rather than Apply. The toolbar should drop back to
// its non-crop face; the element stays as it was.
await page.getByRole('button', { name: 'Cancel' }).click();
await expect(page.getByRole('button', { name: 'Apply crop' })).not.toBeVisible();
// Element count unchanged.
expect(await getElementsCount(page)).toBe(1);
// The definitive "Cancel didn't push history" check. ONE undo
// step should reach the empty floor. If Cancel had pushed a
// (no-op) entry, we'd undo to that intermediate state and still
// have 1 element here.
await undoButton.click();
expect(await getElementsCount(page)).toBe(0);
});

View File

@@ -1,281 +0,0 @@
// Shared Playwright helpers for the editor tests.
//
// What's here
// ───────────
// • `gotoFreshEditor(page)` — navigate to the editor with a clean
// localStorage and a freshly-unregistered service worker, so each
// test starts from a known empty canvas regardless of state left
// behind by previous tests or local browsing.
//
// • `addTextViaSidebar(page, { text })` — drive the Text tab to add
// a text element. Used by multiple specs so the pattern lives in
// one place.
//
// • `getElementsCount(page)` — read the LayersPanel's "Layers (N)"
// title to assert how many elements exist on the canvas. The
// panel is the canonical user-visible source of truth for that
// count.
//
// • `pressUndo(page)` / `pressRedo(page)` — Cmd+Z / Cmd+Shift+Z
// wrappers that send the same keystrokes the user would. Modifier
// is meta on macOS, control elsewhere; we detect via the
// userAgent. (The app's keyboard handler accepts either modifier
// so we could hardcode one, but using the platform-correct one
// keeps the tests honest about what real users do.)
//
// Why these live here rather than inline in each spec
// ───────────────────────────────────────────────────
// Three reasons. (1) Several specs need fresh-canvas setup; if it
// drifts inline-in-each-test, "fresh" stops meaning the same thing
// across the suite. (2) The localStorage + service-worker reset is
// non-obvious and easy to skip; centralising it ensures every test
// gets the full reset rather than just the obvious bits. (3) When
// the app's selectors change (e.g. someone renames the LayersPanel
// title), one edit here updates every test rather than touching each
// spec file.
import { expect } from '@playwright/test';
/**
* Navigate to the editor with a fully fresh state.
*
* The order matters:
* 1. goto('/') first so we have a page context to evaluate against.
* Without this, `evaluate` has nowhere to run.
* 2. Clear localStorage AND unregister any service workers AND
* clear caches. The PWA's service worker caches static assets;
* stale caches between test runs can mask real regressions
* (e.g. an app code change that breaks the editor but a cached
* SW returns the old version).
* 3. Reload to apply the wipe — the first goto loaded the app
* against whatever state was there, so step 2's wipe wouldn't
* affect the current page without a reload.
* 4. Wait for the canvas to mount before returning. The Konva
* Stage takes a few frames after the first paint to be ready
* for interaction; clicking buttons before then can race.
*
* The wait targets are:
* • `.canvas-area` — the DOM container is present immediately
* once React mounts.
* • A document-fonts-ready check — without this, text-related
* assertions can race against the woff2 fetches that
* useFontsReady waits for. measureTextWidth returns
* fallback-font values until the real fonts arrive.
*/
export async function gotoFreshEditor(page) {
// Step 1: land on the editor so we have storage access.
await page.goto('/');
// Step 2: wipe persisted state + SW caches.
await page.evaluate(async () => {
try { localStorage.clear(); } catch { /* ignore */ }
try { sessionStorage.clear(); } catch { /* ignore */ }
// Unregister all service workers. The app's PWA registers one
// on first load; without unregistering, a SW from a previous
// test run can serve stale assets.
if ('serviceWorker' in navigator) {
const regs = await navigator.serviceWorker.getRegistrations();
await Promise.all(regs.map((r) => r.unregister()));
}
// Wipe Cache API entries (the SW's storage layer).
if ('caches' in window) {
const keys = await caches.keys();
await Promise.all(keys.map((k) => caches.delete(k)));
}
});
// Step 3: reload to apply the wipe.
await page.reload();
// Step 4: wait for the editor's primary surfaces.
// canvas-area exists once App.jsx renders. The stage's <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');
}

View File

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

View File

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

View File

@@ -1,138 +0,0 @@
// 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

View File

@@ -1,44 +0,0 @@
# 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.

2883
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "apparel-designer",
"version": "1.0.0",
"description": "T-shirt customization editor with background removal, stickers, text, and export",
"description": "Pawfectly Yours storefront — host application for the goods-editor module",
"private": true,
"type": "module",
"scripts": {
@@ -11,42 +11,24 @@
"start": "node server.js",
"lint": "eslint .",
"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"
"test": "echo 'Editor tests live in the goods-editor module. Run them from there.' && exit 0"
},
"dependencies": {
"@emotion/is-prop-valid": "^1.4.0",
"@huggingface/transformers": "^3.4.0",
"canvas": "^3.1.0",
"goods-editor": "git+ssh://git@git.kadil.dev/khalidadil/goods-editor-module.git#v0.1.0-alpha.2",
"canvas": "^3.0.0",
"cors": "^2.8.5",
"express": "^4.18.2",
"express-rate-limit": "^7.4.0",
"konva": "^10.0.0",
"multer": "^2.0.0",
"pino": "^9.5.0",
"pino-http": "^10.3.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-filerobot-image-editor": "^4.8.1",
"react-konva": "^19.2.3",
"react-select": "^5.8.0",
"sharp": "^0.33.2",
"styled-components": "^6.4.1",
"use-image": "^1.1.1",
"uuid": "^9.0.1",
"zod": "^3.23.8"
"uuid": "^9.0.1"
},
"devDependencies": {
"@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-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"concurrently": "^8.2.0",
"eslint": "^9.39.4",
@@ -54,10 +36,8 @@
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.5.0",
"jsdom": "^26.0.0",
"vite": "^8.0.9",
"vite-plugin-pwa": "^1.3.0",
"vitest": "^3.0.0",
"workbox-window": "^7.4.1"
},
"engines": {

72
package.monolith.json Normal file
View File

@@ -0,0 +1,72 @@
{
"name": "apparel-designer",
"version": "1.0.0",
"description": "T-shirt customization editor with background removal, stickers, text, and export",
"private": true,
"type": "module",
"scripts": {
"dev": "concurrently \"vite\" \"node --watch server.js\"",
"dev:win": "concurrently \"vite\" \"node --watch server.js\"",
"build": "vite build",
"start": "node server.js",
"lint": "eslint .",
"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": {
"@emotion/is-prop-valid": "^1.4.0",
"@huggingface/transformers": "^3.4.0",
"canvas": "^3.1.0",
"cors": "^2.8.5",
"express": "^4.18.2",
"express-rate-limit": "^7.4.0",
"konva": "^10.0.0",
"multer": "^2.0.0",
"pino": "^9.5.0",
"pino-http": "^10.3.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-filerobot-image-editor": "^4.8.1",
"react-konva": "^19.2.3",
"react-select": "^5.8.0",
"sharp": "^0.33.2",
"styled-components": "^6.4.1",
"use-image": "^1.1.1",
"uuid": "^9.0.1",
"zod": "^3.23.8"
},
"devDependencies": {
"@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-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"concurrently": "^8.2.0",
"eslint": "^9.39.4",
"eslint-plugin-jsx-a11y": "^6.10.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.5.0",
"jsdom": "^26.0.0",
"vite": "^8.0.9",
"vite-plugin-pwa": "^1.3.0",
"vitest": "^3.0.0",
"workbox-window": "^7.4.1"
},
"engines": {
"node": ">=20.0.0"
},
"overrides": {
"serialize-javascript": "^7.0.3",
"vite-plugin-pwa": {
"vite": "$vite"
}
}
}

View File

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

168
scripts/cutover-host.sh Normal file
View File

@@ -0,0 +1,168 @@
#!/bin/bash
# Host cutover script: switch from monolithic to module-consuming.
#
# Run AFTER:
# 1. ./scripts/migrate-to-module.sh has copied files to goods-editor-module/
# 2. You've cd'd into goods-editor-module/, npm install'd, and npm run build'd
#
# What this does
# ──────────────
# 1. Renames the current monolithic files to *.monolith.* (kept for reference)
# 2. Renames the *.host.* files to their canonical names
# 3. Deletes the module-owned source from src/ (it now lives in the
# module). The deletion is the actual cutover; until this runs, both
# the inline copy and the module import would conflict.
#
# Run from the apparel-designer root:
# bash scripts/cutover-host.sh
set -euo pipefail
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
ROOT_DIR="$( cd "$SCRIPT_DIR/.." &> /dev/null && pwd )"
cd "$ROOT_DIR"
echo "Cutting over apparel-designer to consume goods-editor module..."
echo ""
# Step 1: Backup the current monolithic files.
echo "→ Backing up monolithic files"
[ -f src/App.jsx ] && mv src/App.jsx src/App.monolith.jsx
[ -f server.js ] && mv server.js server.monolith.js
[ -f package.json ] && mv package.json package.monolith.json
echo " src/App.jsx → src/App.monolith.jsx"
echo " server.js → server.monolith.js"
echo " package.json → package.monolith.json"
echo ""
# Step 2: Promote the *.host.* files to canonical names.
echo "→ Promoting host files"
[ -f src/App.host.jsx ] && mv src/App.host.jsx src/App.jsx
[ -f server.host.js ] && mv server.host.js server.js
[ -f package.host.json ] && mv package.host.json package.json
echo " src/App.host.jsx → src/App.jsx"
echo " server.host.js → server.js"
echo " package.host.json → package.json"
echo ""
# Step 3: Delete module-owned source.
#
# Per the architecture decisions, the host keeps:
# • src/constants/shirt.js — host's shirt color/size catalog
# • src/constants/templates.js — host's template definitions
# • src/constants/products.js — host's product config
# • src/styles/Header.css — styles the host's Header.jsx
# • src/styles/PWAInstall.css — styles the host's PWAInstall.jsx
#
# These are referenced by host-owned components and the new
# App.host.jsx. We delete the OTHER files but preserve these.
echo "→ Removing module-owned source directories from src/"
DIRS_TO_DELETE=(
"src/components/canvas"
"src/components/sidebar"
"src/components/panels"
"src/components/editor"
"src/hooks"
"src/utils"
"src/i18n"
"src/test"
)
for dir in "${DIRS_TO_DELETE[@]}"; do
if [ -d "$dir" ]; then
rm -rf "$dir"
echo " rm -rf $dir"
fi
done
# Module-owned constants files (delete individually). The host
# keeps shirt.js, templates.js, and products.js — those carry
# data App.host.jsx imports.
MODULE_OWNED_CONSTANTS=(
"src/constants/fonts.js"
"src/constants/stickers.js"
"src/constants/elements.js"
"src/constants/imageFilters.js"
"src/constants/textMetrics.js"
"src/constants/transformer.js"
)
for file in "${MODULE_OWNED_CONSTANTS[@]}"; do
if [ -f "$file" ]; then
rm "$file"
echo " rm $file"
fi
done
# Module-owned CSS files (delete individually). The host keeps
# Header.css and PWAInstall.css — those style host-owned components.
MODULE_OWNED_CSS=(
"src/styles/CanvasHint.css"
"src/styles/DesignCanvas.css"
"src/styles/ElementToolbar.css"
"src/styles/LayersPanel.css"
"src/styles/MobileBottomSheet.css"
"src/styles/ModelSilhouette.css"
"src/styles/PhotoPreEditor.css"
"src/styles/PreviewModal.css"
"src/styles/PropertiesPanel.css"
"src/styles/ShirtOptionsPanel.css"
"src/styles/Sidebar.css"
"src/styles/StickersTab.css"
"src/styles/TShirtSVG.css"
"src/styles/TemplatesTab.css"
"src/styles/TextTab.css"
"src/styles/UploadTab.css"
"src/styles/ZoomControls.css"
"src/styles/editor-shell.css"
)
for file in "${MODULE_OWNED_CSS[@]}"; do
if [ -f "$file" ]; then
rm "$file"
echo " rm $file"
fi
done
FILES_TO_DELETE=(
"src/components/MobileBottomSheet.jsx"
"src/App.css"
)
for file in "${FILES_TO_DELETE[@]}"; do
if [ -f "$file" ]; then
rm "$file"
echo " rm $file"
fi
done
# E2E directory — tests live in the module now.
if [ -d "e2e" ]; then
rm -rf e2e
echo " rm -rf e2e"
fi
[ -f "playwright.config.js" ] && rm playwright.config.js && echo " rm playwright.config.js"
[ -f "vitest.config.js" ] && rm vitest.config.js && echo " rm vitest.config.js"
# Fonts moved to the module too. Note that `public/stickers/`
# stays in place — the host's vite.config.js still has the
# `stickerManifestPlugin` that reads from `public/stickers/`
# and exposes the `virtual:sticker-manifest` import that
# goods-editor/src/constants/stickers.js consumes. Don't delete
# either the public/stickers/ directory OR the plugin from
# vite.config.js.
if [ -d "fonts" ]; then
rm -rf fonts
echo " rm -rf fonts"
fi
echo ""
echo "Cutover complete. Next steps:"
echo " 1. rm -rf node_modules package-lock.json"
echo " 2. npm install"
echo " 3. npm run dev"
echo ""
echo "If something's broken, restore the monolith with:"
echo " mv src/App.monolith.jsx src/App.jsx"
echo " mv server.monolith.js server.js"
echo " mv package.monolith.json package.json"
echo " # (Then npm install to restore old deps; src/ won't have the"
echo " # module-owned files anymore, but you can restore those from"
echo " # the goods-editor-module/ copy or from git.)"

View File

@@ -0,0 +1,161 @@
#!/bin/bash
# Migration script: copy files from apparel-designer into goods-editor-module.
#
# Why this exists
# ───────────────
# The module-extraction refactor moves ~80 files from an existing app
# into a new module repo. Most files copy verbatim — no surgery needed.
# Doing this via shell is dramatically faster than reading/writing each
# file through an AI tool.
#
# What this script does
# ─────────────────────
# 1. Copies utils, hooks, constants, i18n, styles, test setup, scripts
# 2. Copies the canvas / sidebar / panels / editor / mobile components
# 3. Copies the fonts/ directory and public/stickers/
# 4. Copies all test files (Vitest *.test.js + e2e/)
# 5. Reports what was moved
#
# What this script does NOT do
# ────────────────────────────
# - It does not copy App.jsx, main.jsx, index.html (host-side files)
# - It does not copy Header.jsx, PWAInstall.jsx, OfflineIndicator.jsx (host-side)
# - It does not copy server.js (refactored separately into the module's
# server/ directory)
# - It does not run npm install or build anything
# - It does not delete anything from the source — the apparel-designer
# project is left intact until you've verified the module works.
#
# Run from the parent directory of both repos:
# cd /Users/khalid/Documents/Claude-Desktop
# bash apparel-designer/scripts/migrate-to-module.sh
set -euo pipefail
# Resolve paths relative to this script's location so it doesn't matter
# where you invoke it from.
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
SOURCE_DIR="$( cd "$SCRIPT_DIR/.." &> /dev/null && pwd )"
PARENT_DIR="$( cd "$SOURCE_DIR/.." &> /dev/null && pwd )"
DEST_DIR="$PARENT_DIR/goods-editor-module"
if [ ! -d "$DEST_DIR" ]; then
echo "Error: goods-editor-module not found at $DEST_DIR"
echo "Expected layout:"
echo " $PARENT_DIR/"
echo " apparel-designer/ (source, where this script lives)"
echo " goods-editor-module/ (destination)"
exit 1
fi
echo "Source: $SOURCE_DIR"
echo "Destination: $DEST_DIR"
echo ""
# Helper: copy a directory's contents, preserving structure.
# Usage: copy_dir <source-relative> <dest-relative>
copy_dir() {
local src="$SOURCE_DIR/$1"
local dst="$DEST_DIR/$2"
if [ ! -d "$src" ]; then
echo " skip (missing): $1"
return
fi
mkdir -p "$dst"
cp -R "$src/." "$dst/"
local count
count=$(find "$dst" -type f | wc -l | tr -d ' ')
echo " $1$2 ($count files)"
}
# Helper: copy a single file.
# Usage: copy_file <source-relative> <dest-relative>
copy_file() {
local src="$SOURCE_DIR/$1"
local dst="$DEST_DIR/$2"
if [ ! -f "$src" ]; then
echo " skip (missing): $1"
return
fi
mkdir -p "$(dirname "$dst")"
cp "$src" "$dst"
echo " $1$2"
}
echo "=== Phase 1: Utilities, hooks, constants, i18n, styles ==="
copy_dir "src/utils" "src/utils"
copy_dir "src/hooks" "src/hooks"
copy_dir "src/constants" "src/constants"
copy_dir "src/i18n" "src/i18n"
copy_dir "src/styles" "src/styles"
copy_dir "src/test" "src/test"
echo ""
echo "=== Phase 2: Canvas / sidebar / panels / editor components ==="
copy_dir "src/components/canvas" "src/components/canvas"
copy_dir "src/components/sidebar" "src/components/sidebar"
copy_dir "src/components/panels" "src/components/panels"
copy_dir "src/components/editor" "src/components/editor"
copy_file "src/components/MobileBottomSheet.jsx" "src/components/MobileBottomSheet.jsx"
echo ""
echo "=== Phase 3: App.css (canvas-area styles, etc.) ==="
# App.css contains styles used by DesignCanvas / the canvas-area frame.
# These are module concerns even though the file is named "App.css".
# We copy it into src/styles/ with a clearer name.
copy_file "src/App.css" "src/styles/editor-shell.css"
echo ""
echo "=== Phase 4: Fonts and the font-fetch script ==="
copy_dir "fonts" "fonts"
if [ -f "$SOURCE_DIR/scripts/fetch-fonts.mjs" ]; then
mkdir -p "$DEST_DIR/scripts"
cp "$SOURCE_DIR/scripts/fetch-fonts.mjs" "$DEST_DIR/scripts/fetch-fonts.mjs"
echo " scripts/fetch-fonts.mjs → scripts/fetch-fonts.mjs"
fi
echo ""
echo "=== Phase 5: Default sticker library ==="
# The module ships defaults; host can extend/replace via props.
if [ -d "$SOURCE_DIR/public/stickers" ]; then
mkdir -p "$DEST_DIR/src/assets/stickers"
cp -R "$SOURCE_DIR/public/stickers/." "$DEST_DIR/src/assets/stickers/"
count=$(find "$DEST_DIR/src/assets/stickers" -type f | wc -l | tr -d ' ')
echo " public/stickers → src/assets/stickers ($count files)"
fi
echo ""
echo "=== Phase 6: Playwright E2E tests ==="
if [ -d "$SOURCE_DIR/e2e" ]; then
cp -R "$SOURCE_DIR/e2e" "$DEST_DIR/e2e"
count=$(find "$DEST_DIR/e2e" -type f | wc -l | tr -d ' ')
echo " e2e/ → e2e/ ($count files)"
fi
if [ -f "$SOURCE_DIR/playwright.config.js" ]; then
cp "$SOURCE_DIR/playwright.config.js" "$DEST_DIR/playwright.config.js"
echo " playwright.config.js → playwright.config.js"
fi
echo ""
echo "=== Phase 7: ESLint config ==="
if [ -f "$SOURCE_DIR/eslint.config.js" ]; then
cp "$SOURCE_DIR/eslint.config.js" "$DEST_DIR/eslint.config.js"
echo " eslint.config.js → eslint.config.js"
fi
echo ""
echo "=== Done. Summary ==="
echo ""
echo "Files in $DEST_DIR:"
find "$DEST_DIR/src" -type f 2>/dev/null | wc -l | xargs echo " src/ files:"
find "$DEST_DIR/e2e" -type f 2>/dev/null | wc -l | xargs echo " e2e/ files:"
find "$DEST_DIR/fonts" -type f 2>/dev/null | wc -l | xargs echo " fonts/ files:"
echo ""
echo "Next steps:"
echo " 1. cd $DEST_DIR"
echo " 2. npm install"
echo " 3. The surgical-new-files (index.js, ApparelDesigner.jsx, server,"
echo " dev-host, release script, README) are already in place."
echo " 4. npm run build # builds the library to dist/"
echo " 5. npm run dev # runs the example dev host"
echo ""

982
server.js

File diff suppressed because it is too large Load Diff

1103
server.monolith.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,453 +0,0 @@
/* ──────────────────────────────────────────────────────────────────────────
* App layout — Pawfectly Yours
*
* Top-level structure:
* .editor-shell
* .ph-header (top, full width — defined in Header.css)
* .editor-body (flex row)
* .canvas-area (flex: 1)
* .canvas-area__stage (the Konva canvas frame, fills space)
* .canvas-area__bottom-bar (absolute, pinned to bottom)
* .canvas-area__toolbar (left of the bar; desktop, when selected)
* .canvas-area__right-stack (right cluster, stacked vertically)
* .canvas-area__history (undo/redo pill, on top)
* .canvas-area__zoom (zoom+snap pill, below)
* .right-panel-wrap (fixed width, desktop only)
*
* On mobile the right panel becomes a bottom-sheet modal, the element
* toolbar is pinned to the TOP of the canvas area (via .mobile-toolbar-wrap,
* NOT via .canvas-area__bottom-bar), .canvas-area__bottom-bar flips to
* static positioning to flow below the stage with the history + zoom
* pills centered, and a FAB toggles the sheet.
* ────────────────────────────────────────────────────────────────────────── */
.editor-shell {
/* Definite viewport height — see the #root comment in index.css for
* why this is `height` and not `min-height`. Removed the prior
* `flex: 1` because with #root now `height: 100vh`, editor-shell
* being its only child means a height-of-100vh matches naturally;
* `flex: 1` + `min-height: 100vh` together used to coexist as
* "grow to fill #root, but at least 100vh," which let the box stretch
* past 100vh when content pushed for more. */
display: flex;
flex-direction: column;
height: 100vh;
width: 100%;
overflow: hidden;
}
.editor-body {
flex: 1;
display: flex;
align-items: stretch;
gap: 0.75rem;
padding: 0 0.75rem 0.75rem;
min-height: 0;
overflow: hidden;
}
/* ── Canvas area ─────────────────────────────────────────────────────────── */
.canvas-area {
position: relative;
flex: 1;
display: flex;
align-items: stretch;
justify-content: center;
min-width: 0;
min-height: 0;
}
.canvas-area__stage {
flex: 1;
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-height: 0;
min-width: 0;
}
/* Element toolbar + zoom/snap controls sit inside a shared bottom bar
* (.canvas-area__bottom-bar) that's pinned to the bottom of canvas-area.
* Bundling them into a single absolute-positioned flex row — instead
* of having each one independently `position: absolute` — means they
* can't collide as the canvas-area narrows: the previous implementation
* had the toolbar at left:1rem and zoom at right:1rem, and once
* canvas-area dipped below ~880px (toolbar 380 + zoom ~150 + gaps) they
* overlapped. The bar's flex layout keeps them spaced cleanly and on a
* single horizontal line until the narrow-screen media query flips
* them to stacked.
*
* pointer-events trick: the bar visually spans the full canvas width,
* but the user must be able to click through the empty middle to drag
* elements near the bottom of the canvas. Setting `pointer-events: none`
* on the bar and re-enabling it on the toolbar / zoom children gives us
* that pass-through. */
.canvas-area__bottom-bar {
position: absolute;
left: 1rem;
right: 1rem;
bottom: 1rem;
z-index: 10;
display: flex;
align-items: flex-end;
gap: 0.75rem;
pointer-events: none;
}
.canvas-area__toolbar {
width: min(100%, 380px);
pointer-events: auto;
}
/* History pill (undo/redo) and zoom pill share a vertical stack
* wrapper on the right side of the bottom bar, with history sitting
* ABOVE zoom. The previous layout had them side-by-side in a single
* flex row — which read as "undo/redo is a secondary affordance
* attached to zoom". Stacking gives undo/redo its own visual weight
* (the more important of the two for editing flow) and is the
* standard hierarchy used by Figma / Sketch / Adobe Express.
*
* `margin-left: auto` lives on the stack wrapper, not the children,
* so the whole cluster gets pushed to the right edge whether the
* left-side toolbar is present or not. `align-items: flex-end`
* keeps both pills right-aligned within the column even if they
* differ in width (history is 2 buttons, zoom is 4 — zoom is
* naturally wider). The narrow-screen and mobile breakpoints below
* flatten this back to a row to avoid eating extra vertical space
* when the bar is already in column mode.
*
* pointer-events: none on the wrapper, auto on the children — same
* pass-through pattern as the parent bottom bar. The wrapper has no
* pixels of its own; the pills inside accept the clicks. */
.canvas-area__right-stack {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-left: auto;
align-items: flex-end;
pointer-events: none;
}
.canvas-area__history {
pointer-events: auto;
}
.canvas-area__zoom {
pointer-events: auto;
}
/* ── Right panel ─────────────────────────────────────────────────────────── */
.right-panel-wrap {
width: var(--right-panel-width);
flex-shrink: 0;
background: var(--brand-cream);
border: 1px solid var(--border);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-sm);
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 0;
}
/* ── Export toast ────────────────────────────────────────────────────────── */
/* Floats above the canvas in the top-left corner so it doesn't compete with
* the toolbar (bottom-left) or zoom controls (bottom-right). */
.export-toast-wrap {
position: absolute;
left: 1rem;
top: 1rem;
z-index: 12;
pointer-events: none;
/* When the canvas hint is shown it occupies top-left too; the hint hides
* itself once an element is selected, and the export toast only appears
* during/after a save action — they rarely overlap visually. */
}
.export-toast {
display: inline-flex;
align-items: center;
gap: 0.6rem;
padding: 0.65rem 0.9rem;
border-radius: var(--radius-md);
background: #fff;
border: 1px solid var(--border);
box-shadow: var(--shadow-md);
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
pointer-events: auto;
/* Shared keyframe in index.css — see S13 utility section. */
animation: fade-up-in 0.18s ease-out;
}
.export-toast--progress {
border-color: var(--brand-pink);
color: var(--brand-pink-strong);
}
.export-toast--success {
border-color: #bbf7d0;
background: #f0fdf4;
color: #15803d;
}
.export-toast--error {
border-color: #fecaca;
background: #fef2f2;
color: #b91c1c;
}
.export-toast__link {
color: inherit;
font-weight: 700;
text-decoration: underline;
text-underline-offset: 2px;
}
.export-toast__close {
background: transparent;
border: none;
font-size: 14px;
cursor: pointer;
color: inherit;
padding: 0 0 0 0.25rem;
line-height: 1;
}
/* ── App-wide toast (replaces alert() calls) ─────────────────────────────── */
/* Centered at the top of the canvas area, distinct from the export-toast
* which sits in the top-left. Auto-dismisses after a few seconds; the user
* can also dismiss manually with the close button. Three kinds matching
* the export-toast color language so the warning vocabulary stays
* consistent: success = green, error = red, info = brand pink. */
.app-toast-wrap {
position: absolute;
top: 1rem;
left: 50%;
transform: translateX(-50%);
z-index: 13;
pointer-events: none;
max-width: calc(100% - 2rem);
}
.app-toast {
display: inline-flex;
align-items: center;
gap: 0.6rem;
padding: 0.65rem 0.9rem;
border-radius: var(--radius-md);
background: #fff;
border: 1px solid var(--border);
box-shadow: var(--shadow-md);
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
pointer-events: auto;
animation: app-toast-in 0.18s ease-out;
max-width: 100%;
}
.app-toast > span {
/* Wrap long messages (e.g. the share-fallback URL toast) instead of
* letting them stretch the toast off-screen. */
word-break: break-word;
overflow-wrap: anywhere;
}
@keyframes app-toast-in {
from { opacity: 0; transform: translateX(-50%) translateY(-4px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
.app-toast--success { border-color: #bbf7d0; background: #f0fdf4; color: #15803d; }
.app-toast--error { border-color: #fecaca; background: #fef2f2; color: #b91c1c; }
.app-toast--info { border-color: var(--brand-pink); background: var(--brand-pink-soft); color: var(--brand-pink-strong); }
.app-toast__close {
background: transparent;
border: none;
font-size: 14px;
cursor: pointer;
color: inherit;
padding: 0 0 0 0.25rem;
line-height: 1;
}
@media (max-width: 768px) {
.app-toast-wrap {
top: calc(var(--header-height-mobile) + 0.6rem);
width: calc(100% - 1rem);
}
.app-toast {
width: 100%;
justify-content: center;
}
}
/* ── FAB (mobile only) ───────────────────────────────────────────────────── */
.app-fab {
position: fixed;
right: 1rem;
bottom: calc(1rem + env(safe-area-inset-bottom, 0px));
z-index: 700;
width: 56px;
height: 56px;
border-radius: 999px;
border: none;
background: var(--brand-pink);
color: #fff;
display: inline-flex;
align-items: center;
justify-content: center;
box-shadow: 0 10px 22px -6px rgba(236, 72, 153, 0.6), 0 4px 8px rgba(0, 0, 0, 0.1);
transition: transform 0.12s ease, background 0.12s ease;
}
.app-fab:hover {
background: var(--brand-pink-strong);
}
.app-fab:active {
transform: scale(0.94);
}
/* ── Mobile element toolbar wrapper ──────────────────────────────────────── */
.mobile-toolbar-wrap {
position: fixed;
left: 50%;
top: calc(var(--header-height-mobile) + 0.5rem);
transform: translateX(-50%);
z-index: 600;
width: calc(100vw - 1.5rem);
max-width: 380px;
pointer-events: auto;
}
/* ── Responsive ──────────────────────────────────────────────────────────── */
/* Tablet — narrower right panel */
@media (max-width: 1100px) {
.right-panel-wrap {
width: var(--right-panel-width-md);
}
}
/* Below the tablet breakpoint where the right panel might crowd the canvas:
* keep the bottom bar pinned to the bottom of canvas-area (still snapped
* to the screen bottom), but flip it to column direction so the toolbar
* and zoom stack instead of competing for horizontal room. The toolbar's
* 380px max width plus the zoom widget plus gaps adds up to roughly 540px;
* canvas-area drops below that around 880px viewport width once the right
* panel takes its share, which is what this breakpoint catches. */
@media (max-width: 900px) and (min-width: 769px) {
.canvas-area__bottom-bar {
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.canvas-area__toolbar {
width: 100%;
max-width: 380px;
}
/* Flatten the right-stack to a horizontal row at this breakpoint.
* The parent bar is already column-direction, so a vertically-
* stacked right-stack inside that would push the bottom bar tall
* enough to compete with the canvas for vertical space. A row
* keeps history + zoom on the same line, centered along with the
* toolbar that sits above them. margin-left: auto becomes a no-op
* in column-direction so we reset it for clarity. */
.canvas-area__right-stack {
flex-direction: row;
align-items: center;
margin-left: 0;
}
.canvas-area__history,
.canvas-area__zoom {
margin-left: 0;
}
}
/* Mobile — bottom sheet handles the right panel */
@media (max-width: 768px) {
.editor-body {
padding: 0 0.5rem 0.5rem;
gap: 0.5rem;
}
.canvas-area {
flex-direction: column;
}
.canvas-area__stage {
flex: 1;
}
/* Mobile bottom bar — pulled out of absolute positioning so it
* participates in canvas-area's flex column. Sits below the stage,
* centered horizontally, with the bottom margin clearing the FAB.
* History + zoom pills sit side-by-side, centered as a pair.
* Reset the pointer-events:none / margin-left:auto from the desktop
* defaults so clicks land normally and centering works. */
.canvas-area__bottom-bar {
position: static;
align-self: center;
margin-bottom: calc(4.5rem + env(safe-area-inset-bottom, 0px));
margin-top: 0.4rem;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 0.5rem;
pointer-events: auto;
}
/* Element toolbar is rendered via .mobile-toolbar-wrap on mobile,
* not via the bottom bar. */
.canvas-area__toolbar {
display: none;
}
/* Same flatten as the 900px breakpoint above — mobile bottom bar
* is a horizontal row, so the right-stack inside follows suit
* rather than stacking vertically. */
.canvas-area__right-stack {
flex-direction: row;
align-items: center;
margin-left: 0;
}
.canvas-area__history,
.canvas-area__zoom {
margin-left: 0;
}
.export-toast-wrap {
left: 0.5rem;
right: 0.5rem;
top: calc(var(--header-height-mobile) + 0.6rem);
}
.export-toast {
width: 100%;
justify-content: center;
}
}
/* Tighten on tiny screens */
@media (max-width: 480px) {
.editor-body {
padding: 0 0.4rem 0.4rem;
}
.app-fab {
width: 52px;
height: 52px;
}
}

File diff suppressed because it is too large Load Diff

2086
src/App.monolith.jsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,34 +1,75 @@
import '../styles/Header.css';
import { t } from '../i18n/t';
import '../styles/Header.css';
/**
* Pawfectly Yours top bar.
*
* Layout:
* [logo + tagline] [page title] [clear] [preview] [download] [Save] [Share] [cart]
* [logo + tagline] [page title] [preview] [download] [Share] [cart]
*
* On mobile (<768px) collapses to:
* [back arrow] [page title] [clear] [preview] [download] [menu]
* [logo] [cart]
*
* Undo/redo moved out of the header into the canvas-area bottom bar
* (Change 19) — see HistoryControls.jsx. The header was getting
* crowded with seven distinct actions; undo/redo were the most
* obviously "editor view" rather than "ship the design" group, so
* they pair more naturally with the zoom controls now.
* What's here vs what's gone
* ──────────────────────────
* Buttons removed during the module-split cleanup (May 2026):
*
* • Save — was redundant with Download (both fired the same export
* pipeline). The module's onSave callback prop on <ApparelDesigner>
* remains as the right integration point for real "commit this
* design" semantics when there's a backend to commit to.
*
* • Clear — clearing the canvas is editor state, not host chrome.
* Re-introducing it requires the module to expose a clear()
* method on its editor ref and a canClear callback so the host
* can disable the button when there's nothing to clear. Until
* that lands, removing the button is better than leaving a
* non-functional one in the chrome.
*
* • Menu — opened the host's mobile bottom sheet, which now lives
* inside the module. The module owns its own mobile UI; the
* host had nothing left to put behind a menu.
*
* Preview / Download are still here because they're host-surfaced
* controls that drive the editor via the editorRef.preview() and
* editorRef.testDownload() imperative API. They're disabled during
* an in-flight export (`isExporting`).
*
* i18n
* ────
* All visible text comes from the host's message catalog via t().
* Catalog lives in src/i18n/messages.js — host-owned, separate from
* the editor module's internal catalog.
*/
export function Header({
cartCount = 0,
onSave, onShare, onOpenCart, onOpenMobileMenu,
onShare, onOpenCart,
onTestDownload, isExporting = false,
onClear, canClear = false,
onPreview,
}) {
// App name is reused for both the logo and the aria-label, so we
// resolve it once and interpolate into the aria-label below.
const appName = t('header.app-name');
// Cart aria has two distinct keys for singular vs plural so the
// entire phrase translates as a unit ("Cart, 1 item" / "Cart,
// 3 items"). See pluralization note in messages.js.
const cartAria = t(
cartCount === 1 ? 'header.cart-aria.singular' : 'header.cart-aria.plural',
{ count: cartCount },
);
return (
<header className="ph-header" role="banner">
<a className="ph-logo" href="#" onClick={(e) => e.preventDefault()} aria-label={`${t('header.app-name')} home`}>
<a
className="ph-logo"
href="#"
onClick={(e) => e.preventDefault()}
aria-label={t('header.logo-home-aria', { app: appName })}
>
<span className="ph-logo__mark" aria-hidden="true">🐾</span>
<span className="ph-logo__wordmark">
<span className="ph-logo__name">{t('header.app-name')}</span>
<span className="ph-logo__name">{appName}</span>
<span className="ph-logo__tagline">{t('header.app-tagline')}</span>
</span>
</a>
@@ -38,30 +79,10 @@ export function Header({
</h1>
<div className="ph-actions">
{/* Clear canvas — wipes all elements + active template. The
* actual confirm flow lives in App.jsx (two-tap with a toast
* confirmation). The button is dimmed when there's nothing to
* clear so the action's affordance matches its effect. (Fix
* for #35.) */}
<button
type="button"
className="ph-iconbtn"
onClick={onClear}
disabled={!canClear}
aria-label={t('header.clear')}
title={t('header.clear-tooltip')}
>
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 6h18" />
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<path d="M6 6l1 14a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-14" />
</svg>
</button>
{/* Print preview — server-renders the design at print resolution
* and shows it in a modal so the user can verify what will
* actually print before adding to cart. Disabled while another
* export is in flight. (Fix for #33.) */}
* export is in flight. */}
<button
type="button"
className="ph-iconbtn"
@@ -76,12 +97,10 @@ export function Header({
</svg>
</button>
{/* Download — dedicated print-test button. exportDesign already
* auto-downloads after the server returns, so this is functionally
* a sibling of Save with explicit "give me the file" semantics.
* Kept small (icon-only) so it reads as a utility/testing affordance,
* not a primary CTA competing with Save. Disabled while an export
* is in flight so rapid clicks don't fire parallel POSTs. */}
{/* Download — dedicated print-test button. Same export pipeline
* as Preview but auto-downloads instead of opening the modal.
* Disabled while an export is in flight so rapid clicks don't
* fire parallel POSTs. */}
<button
type="button"
className="ph-iconbtn"
@@ -97,13 +116,9 @@ export function Header({
</svg>
</button>
<button type="button" className="ph-pillbtn ph-pillbtn--filled" onClick={onSave}>
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor" aria-hidden="true">
<path d="M12 21s-7-4.5-9.3-9A5.4 5.4 0 0 1 12 5.5 5.4 5.4 0 0 1 21.3 12C19 16.5 12 21 12 21z" />
</svg>
<span>{t('header.save')}</span>
</button>
{/* Share — opens the OS share sheet (or copies URL to clipboard
* as a fallback). Handler lives in App.jsx with access to the
* toast surface for "link copied" / "copy failed" feedback. */}
<button type="button" className="ph-pillbtn ph-pillbtn--ghost" onClick={onShare}>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="18" cy="5" r="2.5" />
@@ -115,7 +130,7 @@ export function Header({
<span>{t('header.share')}</span>
</button>
<button type="button" className="ph-cart" onClick={onOpenCart} aria-label={t('header.cart-aria', { count: cartCount, plural: cartCount === 1 ? 'item' : 'items' })}>
<button type="button" className="ph-cart" onClick={onOpenCart} aria-label={cartAria}>
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="9" cy="20" r="1.6" />
<circle cx="17" cy="20" r="1.6" />
@@ -123,19 +138,6 @@ export function Header({
</svg>
{cartCount > 0 && <span className="ph-cart__badge">{cartCount}</span>}
</button>
<button
type="button"
className="ph-iconbtn ph-iconbtn--menu"
onClick={onOpenMobileMenu}
aria-label={t('header.menu')}
>
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" aria-hidden="true">
<path d="M4 7h16" />
<path d="M4 12h16" />
<path d="M4 17h16" />
</svg>
</button>
</div>
</header>
);

View File

@@ -1,78 +0,0 @@
import { useEffect, useRef } from 'react';
import '../styles/MobileBottomSheet.css';
/**
* Mobile bottom sheet.
*
* Slides up from below to cover most of the viewport. Backdrop dismisses on
* tap. Drag handle at the top is decorative + clickable to close. ESC also
* closes. Body scroll is locked while open so the user doesn't accidentally
* pan the page underneath.
*
* Animation is CSS transform-based so it stays on the GPU and doesn't trigger
* layout work — important on low-end devices where slide animations can chug
* if they go through paint.
*
* Compact mode
* ────────────
* When `compact` is true, the sheet's max-height shrinks to ~55vh instead of
* 78vh. The use case is text editing on mobile: the user types in the sheet
* and watches the result render on the canvas above. With the full-height
* sheet, the canvas is hidden behind the sheet and the user can't see what
* they're typing. Compact mode leaves enough viewport above the sheet for
* the canvas to remain visible.
*/
export function MobileBottomSheet({ open, onClose, children, compact = false }) {
const sheetRef = useRef(null);
// Lock body scroll while open. We intentionally toggle a class on <body>
// rather than mutating style.overflow directly — that way other sources of
// scroll-lock (e.g. the photo-editor modal) don't stomp each other.
useEffect(() => {
if (!open) return;
document.body.classList.add('has-bottom-sheet');
return () => document.body.classList.remove('has-bottom-sheet');
}, [open]);
// ESC to dismiss.
useEffect(() => {
if (!open) return;
const onKey = (e) => { if (e.key === 'Escape') onClose?.(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, onClose]);
return (
<div
className={`mbs${open ? ' is-open' : ''}${compact ? ' is-compact' : ''}`}
aria-hidden={!open}
>
<button
type="button"
className="mbs__backdrop"
aria-label="Close"
onClick={onClose}
tabIndex={open ? 0 : -1}
/>
<div
ref={sheetRef}
className="mbs__sheet"
role="dialog"
aria-modal="true"
aria-label="Customization options"
>
<button
type="button"
className="mbs__handle"
onClick={onClose}
aria-label="Close"
>
<span className="mbs__handle-grip" aria-hidden="true" />
</button>
<div className="mbs__content">
{children}
</div>
</div>
</div>
);
}

View File

@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react';
import { t } from '../i18n/t';
export function OfflineIndicator() {
const [isOffline, setIsOffline] = useState(!navigator.onLine);
@@ -12,5 +13,5 @@ export function OfflineIndicator() {
}, []);
if (!isOffline) return null;
return <div className="offline-indicator"><span></span><span>You're offline changes are saved locally</span></div>;
return <div className="offline-indicator"><span></span><span>{t('offline.message')}</span></div>;
}

View File

@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react';
import { useRegisterSW } from 'virtual:pwa-register/react';
import { t } from '../i18n/t';
import '../styles/PWAInstall.css';
export function PWAInstall() {
@@ -51,18 +52,18 @@ export function PWAInstall() {
<>
{showInstall && (
<div className="pwa-install-banner">
<p>Install Apparel Designer for offline access!</p>
<p>{t('pwa.install-message')}</p>
<div className="pwa-install-actions">
<button onClick={handleInstall} className="install-btn">Install</button>
<button onClick={() => setShowInstall(false)} className="dismiss-btn">Later</button>
<button onClick={handleInstall} className="install-btn">{t('pwa.install')}</button>
<button onClick={() => setShowInstall(false)} className="dismiss-btn">{t('pwa.dismiss')}</button>
</div>
</div>
)}
{needRefresh && (
<div className="pwa-update-banner">
<span>🔄 New version available!</span>
<button onClick={handleUpdate} className="refresh-btn">Refresh</button>
<button onClick={() => setNeedRefresh(false)} className="close-btn"></button>
<span>🔄 {t('pwa.update-message')}</span>
<button onClick={handleUpdate} className="refresh-btn">{t('pwa.refresh')}</button>
<button onClick={() => setNeedRefresh(false)} className="close-btn" aria-label={t('pwa.close')}></button>
</div>
)}
</>

82
src/components/Toast.jsx Normal file
View File

@@ -0,0 +1,82 @@
import { t } from '../i18n/t';
import '../styles/Toast.css';
/**
* Toast — host-level notification surface.
*
* Why this exists
* ───────────────
* The host occasionally needs to tell the user something: "link
* copied", "upload failed", "add something to your design first."
* Before the module split there was an in-App toast system that did
* this; it got stripped along with the editor surface during the
* split, and host operations have been silently throwing or
* console.logging ever since. This restores the surface but scopes
* it strictly to host concerns \u2014 the editor module has its own
* internal toast catalog for editor concerns (crop blocked, etc.)
* and that is deliberately not shared.
*
* Architecture
* ────────────
* State + scheduling live in App.jsx (showToast / dismissToast); this
* file is just the renderer. App owns the state because:
* \u2022 Multiple host components could need to toast eventually
* (currently only App's own handlers do).
* \u2022 Lifting matches the existing host pattern (cart, isExporting,
* etc. all live in App) and avoids introducing a Context for a
* surface this small.
* \u2022 Timer lifecycle is co-located with the state it modifies,
* which keeps the cancel-on-unmount + re-schedule-cancels-prior
* semantics straightforward.
*
* If the toast surface grows (more callers, error boundaries that
* need to surface from anywhere in the tree), refactor to a
* ToastContext + useToast() hook. For now, prop-passing the
* showToast callback to anything outside App that needs it is the
* simpler path.
*
* Toast shape
* ───────────
* { message: string, kind: 'info' | 'success' | 'error', id: number }
*
* The `id` field is a freshly-stamped Date.now() per call \u2014 used as
* a React key so that a new toast retriggers the fade-in animation
* even when the previous toast is still on screen.
*
* Rendering null
* ──────────────
* Renders null when toast is null so the consumer can always include
* <Toast toast={toast} onDismiss={dismissToast} /> in their JSX
* without an outer conditional. Simpler caller, same DOM.
*/
export function Toast({ toast, onDismiss }) {
if (!toast) return null;
return (
// Wrap exists so the inner pill can use a fade-in animation that
// includes a horizontal translateX(-50%) for centering \u2014 if the
// animation were on the pill itself, the centering math would
// collide with the keyframe transforms. The wrap holds the
// positioning, the pill holds the visual chrome.
<div className="app-toast-wrap" key={toast.id}>
<div
className={`app-toast app-toast--${toast.kind}`}
role="status"
// 'polite' so screen readers announce the message after the
// current utterance finishes \u2014 'assertive' would interrupt,
// which is too aggressive for "link copied" type messages.
aria-live="polite"
>
<span className="app-toast__message">{toast.message}</span>
<button
type="button"
className="app-toast__close"
onClick={onDismiss}
aria-label={t('toast.dismiss')}
>
</button>
</div>
</div>
);
}

View File

@@ -1,188 +0,0 @@
import { useBackgroundRemoval } from '../../hooks/useBackgroundRemoval';
/**
* "Remove background" control for the floating element toolbar.
*
* Lives under canvas/ rather than sidebar/ because it's rendered inside
* ElementToolbar (a canvas-area component) and operates on the selected
* canvas element.
*
* Render variants
* ───────────────
* • compact (default) — renders as a `.el-toolbar__btn`-shaped button
* that fits alongside Flip H, Flip V, and Crop in the toolbar's
* action row. Vertical layout: icon glyph on top, small label
* below (label hidden on mobile via the same media query as the
* other action buttons). While the model is loading or the
* background is being removed, the button shows a spinner glyph
* and is disabled.
* • full — the original wide pill-shaped button. Currently unused
* by callers but kept around in case a future surface (e.g. a
* dedicated photo-actions panel) wants the more prominent
* treatment. Pass `variant="full"` to opt in.
*
* Why one component with a variant prop and not two
* ─────────────────────────────────────────────────
* The hook plumbing (useBackgroundRemoval) and the post-removal
* geometry math (mapping the alpha bbox from source pixel space back
* into canvas units, accounting for rotation) is the same regardless
* of how the button is rendered. Splitting into two components would
* mean duplicating that logic or extracting it into a hook anyway;
* one component with a presentational switch keeps things compact.
*/
export function BackgroundRemovalButton({
selectedElement,
onUpdate,
variant = 'compact',
}) {
const { loading, progress, removeBackground } = useBackgroundRemoval();
const handleRemoveBackground = async () => {
if (!selectedElement || selectedElement.type !== 'image') return;
// removeBackground handles model loading internally if needed.
const result = await removeBackground(selectedElement.src);
if (!result) return;
const { url, bbox } = result;
// Defensive bail — a zero-area bbox would produce NaN aspect
// below. removeBackground shouldn't return one in practice (an
// empty mask means everything was transparent already), but if it
// does we skip the update rather than landing garbage geometry in
// state.
if (bbox.sw <= 0 || bbox.sh <= 0) return;
// Scale the bg-removed subject to occupy the ORIGINAL element's
// on-canvas bounding box, preserving the subject's aspect ratio
// (letterboxing along whichever axis is needed). Previously we
// tightened the bbox to wrap just the subject pixels, which made
// the element visually shrink after bg removal — a 200×100 photo
// of a pet on a busy background would collapse to a 60×40 pet,
// forcing the user to manually resize it back up if they wanted
// the subject at the same scale.
//
// Letterbox-fit logic is the same shape as `handleResetCrop`'s
// "resized" branch in App.jsx:
//
// subjectAspect > originalAspect : subject is relatively
// wider → fill the box's WIDTH, let the height shrink to
// match the subject's aspect.
//
// subjectAspect ≤ originalAspect : subject is relatively
// taller (or square) → fill HEIGHT, let width shrink.
//
// The new (smaller-on-at-least-one-axis) bbox is then centered
// inside the original bbox so the subject sits where the user
// last saw it, not anchored to a corner.
const oldW = selectedElement.width || 100;
const oldH = selectedElement.height || 100;
const subjectAspect = bbox.sw / bbox.sh;
const originalAspect = oldW / oldH;
let newW;
let newH;
if (subjectAspect > originalAspect) {
newW = oldW;
newH = oldW / subjectAspect;
} else {
newH = oldH;
newW = oldH * subjectAspect;
}
// Center the new bbox inside the original. Konva rotates around
// the element's (x, y) origin, not its visual center, so we
// compute the centered offset in LOCAL (unrotated) coords first,
// then rotate it into canvas coords to find the new top-left.
// This preserves the visual center of the element through the bg
// removal even when the element was rotated beforehand. Flip is
// not specially handled; centering means the subject lands in
// the same spot regardless of flip state.
const rad = ((selectedElement.rotation || 0) * Math.PI) / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
const localOffsetX = (oldW - newW) / 2;
const localOffsetY = (oldH - newH) / 2;
const newX = (selectedElement.x || 0) + localOffsetX * cos - localOffsetY * sin;
const newY = (selectedElement.y || 0) + localOffsetX * sin + localOffsetY * cos;
onUpdate(selectedElement.id, {
src: url,
x: newX,
y: newY,
width: newW,
height: newH,
// The bg-removed result is already cropped to its visible region,
// so any prior `crop` (e.g. from a slot template) no longer maps.
crop: undefined,
// Pre/post-crop tracking from in-app crops also no longer applies
// — the image source has changed, so those dimensions reference
// an image that's gone. Clearing here mirrors the cleanup in
// `handlePhotoEditComplete` over in App.jsx.
preCrop: undefined,
postCrop: undefined,
bgRemoved: true,
});
};
if (!selectedElement || selectedElement.type !== 'image') return null;
// Compact: matches `.el-toolbar__btn` so it sits cleanly next to
// Flip H / Flip V / Crop in the action row. SVG glyph is a small
// "magic wand" — same metaphor as the original ✨ emoji but
// rendered as a proper icon so it inherits currentColor for
// hover/active states. While loading, the button shows a spinner
// and disables itself; progress (0-100) is included in the
// tooltip / aria-label so screen-reader users get a status read.
if (variant === 'compact') {
const label = loading
? (progress > 0 ? `Removing background (${progress}%)` : 'Removing background\u2026')
: 'Remove background';
return (
<button
type="button"
onClick={handleRemoveBackground}
disabled={loading}
className={`el-toolbar__btn${loading ? ' el-toolbar__btn--busy' : ''}`}
aria-label={label}
title={label}
>
{loading ? (
<span className="el-toolbar__spinner" aria-hidden="true" />
) : (
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
{/* Magic wand + sparkles — read as "make the background go
away" without committing to any one literal "background"
glyph. Matches the previous ✨ emoji metaphor. */}
<path d="M15 4V2" />
<path d="M15 16v-2" />
<path d="M8 9h2" />
<path d="M20 9h2" />
<path d="M17.8 11.8L19 13" />
<path d="M15 9h.01" />
<path d="M17.8 6.2L19 5" />
<path d="M3 21l9-9" />
<path d="M12.2 6.2L11 5" />
</svg>
)}
<span className="el-toolbar__btn-label">{loading ? 'Working\u2026' : 'Remove BG'}</span>
</button>
);
}
// Full (legacy) — wide pill button. Kept for completeness; no
// current caller uses it. Same logic, different chrome.
return (
<div className="bg-removal-container">
<button className="bg-removal-btn" onClick={handleRemoveBackground} disabled={loading}>
{loading ? (
<>
<div className="spinner-small" />
{progress > 0 ? `Loading: ${progress}%` : 'Removing Background...'}
</>
) : (
<> Remove Background</>
)}
</button>
</div>
);
}

View File

@@ -1,19 +0,0 @@
import '../../styles/CanvasHint.css';
/**
* Small rounded badge ("Canvas — Click and drag to move") shown in the
* top-left of the canvas area. Mirrors the screenshot's onboarding hint.
*/
export function CanvasHint() {
return (
<div className="canvas-hint" aria-hidden="true">
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor">
<path d="M5 3l8 18 2-7 7-2L5 3z" />
</svg>
<div className="canvas-hint__text">
<strong>Canvas</strong>
<span>Click and drag to move</span>
</div>
</div>
);
}

View File

@@ -1,283 +0,0 @@
import { useEffect, useRef, useCallback } from 'react';
import { Layer, Rect, Line, Transformer } from 'react-konva';
/**
* On-canvas crop overlay.
*
* Mounted by DesignCanvas when App's `cropping` state targets an image
* element. Renders into its OWN dedicated Layer above the elements
* layer — keeping it out of the elements layer means there's zero
* z-order or hit-test ambiguity with image / text nodes or the shared
* multi-select Transformer that lives down there. The Layer carries
* the same `x`/`y` offset as the elements layer so the crop Rect's
* x/y are in canonical 0..CANVAS_SIZE design space.
*
* Contents (in z-order, bottom up):
*
* 1. Four dim rects covering the parts of the IMAGE ELEMENT outside
* the current crop selection — the user sees what will be kept
* (bright) vs what will be discarded (dimmed).
*
* 2. Rule-of-thirds guide lines inside the crop rect.
*
* 3. The crop rect itself — transparent fill, pink stroke. NOT
* currently draggable; see the inline IMPORTANT note on the Rect
* below for the reason and a sketch of how interior-drag could
* be added back safely.
*
* 4. Konva Transformer attached to the rect. All 8 anchors enabled
* so the user can resize from corners or edges (including
* shrinking each side independently via the middle-edge anchors).
* `keepRatio` off — crop is about choosing composition, not
* preserving aspect.
*
* Coordinate-system note (the bug that took several iterations to find)
* ────────────────────────────────────────────────────────────────────
* Konva's Transformer calls `boundBoxFunc(oldBox, newBox)` with boxes
* in ABSOLUTE STAGE COORDS, NOT in the layer-local coord system of
* the attached node. This is undocumented in the prose of the Konva
* docs (the docs just say "format: {x, y, width, height, rotation}"
* without specifying the coord system), but it's empirically confirmed
* by inspecting the box values during a drag: a Rect at layer-local
* (86, 71) inside a Layer offset by (162, 216) produces newBox.x = 248.
*
* The element's x/y/width/height in `props.element` are in layer-local
* design coords (App's state, same space as `cropping.rect`). To
* compare newBox against the element's bounds, we must first convert
* newBox from absolute to layer-local by subtracting layerX/layerY.
* Earlier iterations skipped that conversion, so every drag failed
* the "is the new box inside the element?" check by ~LAYER_OFFSET_X
* units, and the Transformer rejected every newBox by returning
* oldBox each frame — visible symptom: anchors didn't move on drag.
*
* Why Konva primitives instead of a dedicated crop library
* ─────────────────────────────────────────────────────────
* The editor is already 100% Konva. Bringing in something like
* react-image-crop or cropperjs would mean rendering the image in
* the DOM during crop mode (those libs operate on <img>) and then
* synchronizing the crop result back to the Konva element — two
* rendering paths for the same element. Konva's own Rect + Transformer
* give us the exact UI with zero extra code or sync, and the result
* (a `crop` attr on Konva.Image) is already part of the schema.
*/
export function CropOverlay({
element,
cropRect,
onCropRectChange,
layerX,
layerY,
}) {
const rectRef = useRef(null);
const trRef = useRef(null);
// Wire the Transformer to the rect after both have mounted. Pure
// useEffect pattern — runs once after the first render commits
// both nodes to the Konva tree. Empty deps because re-running
// would call nodes([rect]) again with the same node (no-op).
useEffect(() => {
const transformer = trRef.current;
const shape = rectRef.current;
if (!transformer || !shape) return;
transformer.nodes([shape]);
transformer.getLayer()?.batchDraw();
}, []);
// Transform bound — keep the rect inside the element's bbox while
// resizing via the anchors.
//
// newBox is in ABSOLUTE STAGE COORDS (see the coordinate-system
// note in the module docblock). We convert to layer-local by
// subtracting the layer offset, then compare against the element's
// layer-local bounds.
//
// EPS of 5 is generous to handle:
// • Sub-pixel artifacts in Konva's continuous transform math.
// • The 1.5px stroke half-width that, even with ignoreStroke on
// the Transformer, can produce a fraction of a unit's
// discrepancy between the Rect's bbox and its content area.
const boundBoxFunc = useCallback((oldBox, newBox) => {
const EPS = 5;
const MIN_CROP = 12;
if (newBox.width < MIN_CROP || newBox.height < MIN_CROP) return oldBox;
// Convert from absolute stage coords to layer-local design coords.
// The element's x/y/width/height are in the same layer-local space.
const localX = newBox.x - layerX;
const localY = newBox.y - layerY;
const localRight = localX + newBox.width;
const localBottom = localY + newBox.height;
if (
localX < element.x - EPS
|| localY < element.y - EPS
|| localRight > element.x + element.width + EPS
|| localBottom > element.y + element.height + EPS
) return oldBox;
return newBox;
}, [element.x, element.y, element.width, element.height, layerX, layerY]);
// Commit geometry changes back up to App. Called on transform-end
// (continuous updates would be over-eager — the overlay is purely
// visual until the user clicks Apply). Bakes the Transformer's
// scale into width/height so subsequent transforms start from a
// clean ±1 scale — same pattern as ImageElement.
//
// node.x() and node.y() return layer-local coords, which is what
// App's cropping.rect state expects.
const commitGeometry = useCallback(() => {
const node = rectRef.current;
if (!node) return;
const sx = node.scaleX();
const sy = node.scaleY();
const w = Math.max(12, node.width() * Math.abs(sx));
const h = Math.max(12, node.height() * Math.abs(sy));
node.scaleX(1);
node.scaleY(1);
node.width(w);
node.height(h);
onCropRectChange({
x: node.x(),
y: node.y(),
width: w,
height: h,
});
}, [onCropRectChange]);
// Geometry shortcuts for the dim mask + guides.
const ex = element.x;
const ey = element.y;
const ew = element.width;
const eh = element.height;
const cx = cropRect.x;
const cy = cropRect.y;
const cw = cropRect.width;
const ch = cropRect.height;
const MASK_FILL = 'rgba(15, 23, 42, 0.55)'; // slate-900 at low opacity
return (
<Layer x={layerX} y={layerY}>
{/* Dim mask — top strip. */}
<Rect
x={ex}
y={ey}
width={ew}
height={Math.max(0, cy - ey)}
fill={MASK_FILL}
listening={false}
perfectDrawEnabled={false}
/>
{/* Dim mask — bottom strip. */}
<Rect
x={ex}
y={cy + ch}
width={ew}
height={Math.max(0, (ey + eh) - (cy + ch))}
fill={MASK_FILL}
listening={false}
perfectDrawEnabled={false}
/>
{/* Dim mask — left strip. */}
<Rect
x={ex}
y={cy}
width={Math.max(0, cx - ex)}
height={ch}
fill={MASK_FILL}
listening={false}
perfectDrawEnabled={false}
/>
{/* Dim mask — right strip. */}
<Rect
x={cx + cw}
y={cy}
width={Math.max(0, (ex + ew) - (cx + cw))}
height={ch}
fill={MASK_FILL}
listening={false}
perfectDrawEnabled={false}
/>
{/* Rule-of-thirds guide lines. */}
<Line
points={[cx + cw / 3, cy, cx + cw / 3, cy + ch]}
stroke="rgba(255, 255, 255, 0.55)"
strokeWidth={1}
dash={[4, 4]}
listening={false}
perfectDrawEnabled={false}
/>
<Line
points={[cx + (cw * 2) / 3, cy, cx + (cw * 2) / 3, cy + ch]}
stroke="rgba(255, 255, 255, 0.55)"
strokeWidth={1}
dash={[4, 4]}
listening={false}
perfectDrawEnabled={false}
/>
<Line
points={[cx, cy + ch / 3, cx + cw, cy + ch / 3]}
stroke="rgba(255, 255, 255, 0.55)"
strokeWidth={1}
dash={[4, 4]}
listening={false}
perfectDrawEnabled={false}
/>
<Line
points={[cx, cy + (ch * 2) / 3, cx + cw, cy + (ch * 2) / 3]}
stroke="rgba(255, 255, 255, 0.55)"
strokeWidth={1}
dash={[4, 4]}
listening={false}
perfectDrawEnabled={false}
/>
{/* The crop rect itself. Transparent fill + pink stroke.
*
* IMPORTANT — the Rect is intentionally NOT draggable.
*
* Earlier iterations had `draggable={true}` plus a generous
* `hitStrokeWidth={20}` so the user could grab the rect's
* border to translate the whole crop region. That combination
* overlapped the rect's hit area with the Transformer's anchor
* hit areas at the corners; Konva's hit-test gave inconsistent
* results between anchor-resize and rect-drag.
*
* For now the user shrinks the crop region by dragging each
* edge inward via its middle-edge anchor. Translating the
* whole crop would need a separate inner Rect with its own
* listening / draggable, sized to NOT overlap the anchor
* positions — worthwhile as a follow-up if users miss the
* interior-drag affordance. */}
<Rect
ref={rectRef}
x={cropRect.x}
y={cropRect.y}
width={cropRect.width}
height={cropRect.height}
fill="rgba(0, 0, 0, 0.01)"
stroke="#ec4899"
strokeWidth={1.5}
onTransformEnd={commitGeometry}
/>
{/* Transformer — 8 anchors (default), no rotation, no keepRatio.
* `ignoreStroke` keeps the anchor positions tracking the rect's
* content geometry rather than the stroke-inflated bbox; without
* it, the anchors render 0.75px outside each edge and the
* resize feel is slightly off. */}
<Transformer
ref={trRef}
rotateEnabled={false}
keepRatio={false}
boundBoxFunc={boundBoxFunc}
anchorFill="#fff"
anchorStroke="#ec4899"
anchorSize={12}
anchorCornerRadius={2}
borderEnabled={false}
ignoreStroke
/>
</Layer>
);
}

View File

@@ -1,996 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import Konva from 'konva';
/**
* Position / transform debug overlay.
*
* What this is
* ────────────
* A developer-only HUD that reports the selected element's live
* coordinate state in a fixed bottom-left panel. Built originally
* for diagnosing a rotation-pivot bug where bbox-clamping in
* `constrainTransform` operated on a different coord system than
* the wrapper bounds it was comparing against (stage vs design),
* producing per-frame drift on rotation gestures near canvas edges.
* The bug is fixed; the overlay is kept as a permanent diagnostic
* surface since rotation/clamp/offset interactions are the most
* likely re-failure area as the editor evolves and a future
* regression will be much faster to diagnose if the readouts are
* already wired up.
*
* What it shows
* ─────────────
* Four sections, all updated on requestAnimationFrame while mounted:
*
* • element (state) — React state for the selected element. The
* numbers fed BACK into the next render. If state and node
* disagree about position/rotation, the next paint will jump.
*
* • node (live konva) — the live Konva node's attrs. node.x/y is
* the pivot position in layer-local coords (with always-center
* offset, this equals element.x + W/2, element.y + H/2 by
* construction). Offset should be glued to (W/2, H/2).
*
* • derived — the comparison section. `visual TL (konva)` is the
* ground truth: where Konva's own getClientRect says the visible
* top-left lands. `simple formula` is what unflipReportedXY
* computes when committing position changes. `rot-aware` is
* what the formula would be if you naively rotated the offset
* vector before subtracting (kept around as a foil to remind
* you the simple formula is the round-trip-correct one, even
* though it doesn't match visual TL in any obvious way).
*
* • last gesture — summary of the most recent drag/transform. Most
* useful field is `bound calls (N clamped)`: if N > 0 during a
* gesture that should have been free, the wrapper clamp is
* interfering and the user will see drift.
*
* Activation
* ──────────
* Two ways to turn it on, neither requiring a code edit:
*
* • URL: append `?debug=1` to the editor URL.
* • LocalStorage: `localStorage.setItem('paw_debug_overlay', '1')`,
* then reload.
*
* The URL path also persists to localStorage so subsequent reloads
* keep it on without re-appending the param. The on-panel ×
* button clears both and reloads.
*
* The entire overlay component is gated behind a `DEBUG_OVERLAY_ENABLED`
* constant in App.jsx (read once at module load). When the flag is
* off, the conditional render short-circuits and this file is dead
* code from the user's perspective; the bundler will not tree-shake
* the import itself, but the component never mounts and the only
* runtime cost is the import being parsed.
*
* UX
* ───
* Fixed bottom-left, semi-transparent dark panel with mono-font
* readouts. A small "×" closes it for the rest of the session
* (clears the URL param via history.replaceState and the
* localStorage key, then reloads). A "copy" button next to the
* close emits a markdown-formatted snapshot to the clipboard for
* paste-into-chat diagnostics. The panel doesn't intercept canvas
* pointer events outside its own box, so it never gets in the way
* of an interaction.
*
* Performance
* ──────────
* Polls the Konva node every animation frame while mounted. That's
* fine for a developer overlay — adds one rAF per frame on top of
* Konva's own rendering, well below the budget — but it's the reason
* the component is opt-in. We deliberately don't subscribe to Konva
* events for the rAF poll, because we want the readouts to update
* during continuous transforms, when no React state changes are
* firing. (Gesture event subscriptions DO exist, separately, to
* capture transform start/move/end snapshots for the "last gesture"
* summary.)
*/
export function DebugOverlay({ element, stageContainerRef }) {
// Live readouts. Updated on each rAF tick while mounted; null when
// there's no element selected or no node found.
const [readout, setReadout] = useState(null);
const rafRef = useRef(null);
// Gesture-tracking state. Captured by listeners on the Konva node's
// transformstart / transform / transformend events. Refs (not
// state) because we want the per-frame `transform` handler to be
// allocation-light — only the React state we render from updates
// when there's meaningful new info.
//
// Shape:
// gestureRef.current = {
// startedAt: Date.now() at transformstart
// startState: { element snapshot, node snapshot, visual TL }
// firstFrame: same shape, captured on FIRST transform event
// (i.e. the very first frame after start)
// lastFrame: same shape, captured on EVERY transform event
// endState: captured on transformend
// boundRejects: number of times constrainTransform returned
// oldBox during this gesture (i.e. clamped)
// boundCalls: total number of constrainTransform calls
// }
//
// The overlay displays the latest completed gesture's delta plus a
// running count of bound-rejects on the active gesture. That's
// enough to distinguish "first-frame jump" from "clamp-induced
// jump" from "transform-end round-trip jump".
const gestureRef = useRef(null);
const [gestureSummary, setGestureSummary] = useState(null);
// Hook the active Konva node's transform events. Re-runs whenever
// the selected element changes (so we re-attach to the new node).
// We use a separate effect from the rAF poll so the event
// subscription doesn't churn when the rAF restarts.
useEffect(() => {
if (!element) return undefined;
const stageContainer = stageContainerRef?.current;
if (!stageContainer) return undefined;
// Resolve the node. If it isn't mounted yet (e.g. a freshly-added
// element that hasn't hit Konva's render pass), retry on the next
// frame. The retry loop is bounded by the effect's cleanup.
let cancelled = false;
let retryRaf = null;
let attachedNode = null;
const snapshot = (node) => {
// Best-effort snapshot of everything we care about. Returns null
// if the node has vanished (e.g. element deleted mid-gesture);
// callers tolerate null.
if (!node) return null;
const layer = node.getLayer?.();
let clientRect = null;
try {
clientRect = layer
? node.getClientRect({ relativeTo: layer })
: node.getClientRect();
} catch { /* ignore */ }
return {
t: Date.now(),
elementX: element.x ?? 0,
elementY: element.y ?? 0,
elementRotation: element.rotation ?? 0,
nodeX: node.x(),
nodeY: node.y(),
nodeRotation: node.rotation(),
offsetX: node.offsetX(),
offsetY: node.offsetY(),
visualX: clientRect?.x,
visualY: clientRect?.y,
visualW: clientRect?.width,
visualH: clientRect?.height,
};
};
const onStart = () => {
gestureRef.current = {
startedAt: Date.now(),
startState: snapshot(attachedNode),
firstFrame: null,
lastFrame: null,
endState: null,
boundRejects: 0,
boundCalls: 0,
};
};
const onTransform = () => {
const g = gestureRef.current;
if (!g) return;
const snap = snapshot(attachedNode);
if (!g.firstFrame) g.firstFrame = snap;
g.lastFrame = snap;
};
const onEnd = () => {
const g = gestureRef.current;
if (!g) return;
g.endState = snapshot(attachedNode);
// Materialize a frozen summary for the overlay to render. The
// ref-tracked gestureRef gets reset on the next gesture's start.
setGestureSummary({
durationMs: g.endState ? g.endState.t - g.startedAt : null,
boundCalls: g.boundCalls,
boundRejects: g.boundRejects,
startState: g.startState,
firstFrame: g.firstFrame,
endState: g.endState,
});
};
const tryAttach = () => {
if (cancelled) return;
const stage = (() => {
if (Konva && Array.isArray(Konva.stages)) {
for (const s of Konva.stages) {
try { if (s.container() === stageContainer) return s; } catch { /* ignore */ }
}
}
return null;
})();
const node = stage?.findOne('.' + element.id);
if (!node) {
retryRaf = requestAnimationFrame(tryAttach);
return;
}
attachedNode = node;
// Namespace events with .pawdbg so we can off() them cleanly
// without disturbing anything else Konva or the app has
// attached.
node.on('transformstart.pawdbg', onStart);
node.on('transform.pawdbg', onTransform);
node.on('transformend.pawdbg', onEnd);
// Drag events too — the same "jump" symptom could appear on
// drag if the bound function clamps an early frame.
node.on('dragstart.pawdbg', onStart);
node.on('dragmove.pawdbg', onTransform);
node.on('dragend.pawdbg', onEnd);
};
tryAttach();
return () => {
cancelled = true;
if (retryRaf != null) cancelAnimationFrame(retryRaf);
if (attachedNode) {
try {
attachedNode.off('transformstart.pawdbg');
attachedNode.off('transform.pawdbg');
attachedNode.off('transformend.pawdbg');
attachedNode.off('dragstart.pawdbg');
attachedNode.off('dragmove.pawdbg');
attachedNode.off('dragend.pawdbg');
} catch { /* ignore */ }
}
};
}, [element, stageContainerRef]);
// Bound-function instrumentation. We expose a global hook the
// production canvasDragBound / constrainTransform can call into IF
// the debug overlay has set window.__pawDebugBound. DesignCanvas
// checks the global before each bound-call increment, so the
// instrumentation is a no-op when the overlay isn't mounted.
//
// The hook surface is intentionally minimal: a single boolean
// `rejected` argument indicating whether constrainTransform
// modified or reverted the user-proposed box. Per-call payloads
// were tried during the original diagnostic session but added
// allocation pressure to the bound hot path with little additional
// diagnostic value over the count-of-clamps. If a future
// investigation needs more detail it can be re-added — the
// existing hook is a stable insertion point.
//
// Why a global rather than React context: the bound functions live
// inside DesignCanvas's useCallback with an empty dep array (Konva
// binds them by identity at mount time, and changing identity would
// force a re-attach). Reading the latest hook ref through a global
// avoids both the re-bind cost and the gymnastics of passing a ref
// through props.
useEffect(() => {
if (typeof window === 'undefined') return undefined;
window.__pawDebugBound = {
noteBoundCall: (rejected) => {
const g = gestureRef.current;
if (!g) return;
g.boundCalls += 1;
if (rejected) g.boundRejects += 1;
},
};
return () => {
delete window.__pawDebugBound;
};
}, []);
useEffect(() => {
let cancelled = false;
// Resolve the Konva stage owning our container. Same pattern used
// by TextEditAffordance and the crop handler in App.jsx.
const resolveStage = () => {
const container = stageContainerRef?.current;
if (!container) return null;
if (Konva && Array.isArray(Konva.stages)) {
for (const s of Konva.stages) {
try { if (s.container() === container) return s; } catch { /* ignore */ }
}
}
const canvas = container.querySelector?.('canvas');
return canvas?._konvaNode?.getStage?.() ?? null;
};
const tick = () => {
if (cancelled) return;
if (!element) {
setReadout(null);
rafRef.current = requestAnimationFrame(tick);
return;
}
const stage = resolveStage();
const node = stage?.findOne('.' + element.id);
if (!node) {
// No node — but we still have an element. Element data alone
// is useful; mark the node-derived fields as missing.
setReadout({
elementId: element.id,
element: pickElementFields(element),
node: null,
derived: null,
});
rafRef.current = requestAnimationFrame(tick);
return;
}
// Node-reported values. All of these come straight from Konva's
// attrs — node.x() / .y() / .rotation() / .offsetX() / .offsetY()
// / .scaleX() / .scaleY() / .width() / .height() are accessors.
const nodeX = node.x();
const nodeY = node.y();
const nodeW = node.width?.() ?? 0;
const nodeH = node.height?.() ?? 0;
const rotDeg = node.rotation();
const offsetX = node.offsetX();
const offsetY = node.offsetY();
const scaleX = node.scaleX();
const scaleY = node.scaleY();
// Stage-coord visual AABB. Definitive "where is this element on
// screen" — Konva computes this from the full transform chain.
//
// IMPORTANT: relativeTo here is the NODE'S OWN LAYER, not the
// stage. The layer is offset by LAYER_OFFSET_X/Y in stage coords
// (set in DesignCanvas via `<Layer x={LAYER_OFFSET_X} ... />`),
// while node.x() reports layer-LOCAL coords. Asking for the
// client rect relative to the stage would add the layer offset
// back in and we'd be comparing two different coord systems.
// relativeTo the layer keeps everything in the same
// (~design-coord) space as node.x/y, snap bbox attrs, and
// element.x/y.
let clientRect = null;
try {
const layer = node.getLayer?.();
clientRect = layer
? node.getClientRect({ relativeTo: layer })
: node.getClientRect();
} catch { /* ignore */ }
// Derived: what the CURRENT unflipReportedXY would write back
// to element.x/y if a drag/transform ended right now. The
// production code path is unflipReportedXY(node, width, height,
// flipX, flipY); since the helper now ignores flip flags, this
// simplifies to (nodeX - W/2, nodeY - H/2) using whichever width
// the onTransformEnd handler reads (the freshly-computed
// newWidth, not node.width()).
//
// For diagnostic purposes we report the SIMPLE formula's output
// using element.width as the size source (matches the rotation
// case which doesn't change W/H, just the rotation angle).
const simpleReportedX = nodeX - (element.width ?? nodeW) / 2;
const simpleReportedY = nodeY - (element.height ?? nodeH) / 2;
// CORRECT formula: rotate the offset vector by the node's
// rotation before subtracting. For a node with offsetX/Y at
// (W/2, H/2) and rotation R, the visual top-left in stage
// coords is:
// visualX = nodeX - (W/2)·cos(R) + (H/2)·sin(R)
// visualY = nodeY - (W/2)·sin(R) - (H/2)·cos(R)
// Equivalent to: rotate the local offset point (W/2, H/2)
// by R, then subtract from nodeX/Y to get where (0,0) in
// local space lands. That (0,0) is the visual top-left of
// an axis-aligned bbox spanning (0,0) → (W,H) in local space.
//
// The clientRect.x/y values above are what Konva itself says
// the visual top-left is, so if our formula is right these
// two should match to within float noise. Comparing them is
// the single most useful check for "is the rotation-aware
// formula correct?"
const rad = (rotDeg * Math.PI) / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
const W = element.width ?? nodeW;
const H = element.height ?? nodeH;
const correctReportedX = nodeX - (W / 2) * cos + (H / 2) * sin;
const correctReportedY = nodeY - (W / 2) * sin - (H / 2) * cos;
// Round-trip predictions: if we write {simpleReportedX} into
// element.x, next render will draw the node at renderX =
// element.x + W/2 = nodeX. So the node's position is
// preserved — but its bbox is NOT, because the rotation pivot
// moved between the old and new state. The visible top-left
// jumps by (correctReportedX - simpleReportedX, ...) on the
// next paint. That delta is what the user sees as "jumping."
const simpleDeltaX = simpleReportedX - correctReportedX;
const simpleDeltaY = simpleReportedY - correctReportedY;
const predictedJumpPx = Math.hypot(simpleDeltaX, simpleDeltaY);
setReadout({
elementId: element.id,
element: pickElementFields(element),
node: {
x: nodeX,
y: nodeY,
width: nodeW,
height: nodeH,
rotation: rotDeg,
offsetX,
offsetY,
scaleX,
scaleY,
},
derived: {
clientRect,
simpleReportedX,
simpleReportedY,
correctReportedX,
correctReportedY,
simpleDeltaX,
simpleDeltaY,
predictedJumpPx,
},
});
rafRef.current = requestAnimationFrame(tick);
};
rafRef.current = requestAnimationFrame(tick);
return () => {
cancelled = true;
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
};
}, [element, stageContainerRef]);
const handleDismiss = () => {
try {
localStorage.removeItem('paw_debug_overlay');
} catch { /* ignore */ }
try {
const url = new URL(window.location.href);
url.searchParams.delete('debug');
window.history.replaceState({}, '', url.toString());
} catch { /* ignore */ }
// Force a reload so the App-level gate re-evaluates and unmounts
// this component cleanly. Simpler than threading a callback up.
window.location.reload();
};
// "Just copied" flash state for the copy button. Flips true on a
// successful clipboard write, back to false ~1.2s later via a
// timeout. The button reads its label from this so the user sees
// immediate feedback that the click landed — clipboard writes are
// invisible otherwise. Stored as state (not a ref) so the label
// re-renders.
const [justCopied, setJustCopied] = useState(false);
const copyTimeoutRef = useRef(null);
useEffect(() => () => {
// Cleanup on unmount: the timeout would harmlessly fire after
// unmount and setState into a stale tree if we didn't clear it.
if (copyTimeoutRef.current != null) clearTimeout(copyTimeoutRef.current);
}, []);
const handleCopy = async () => {
// Build the markdown payload from the current readout +
// gestureSummary. Both can be null (e.g. nothing selected, or no
// gesture has happened yet); the formatter tolerates that and
// omits sections rather than emitting empty tables.
const text = formatReadoutForClipboard(readout, gestureSummary);
if (!text) return;
// Two-tier write: modern Clipboard API first, falling back to the
// legacy execCommand path if the modern one is unavailable (older
// browsers, insecure contexts, some embedded webviews). The
// fallback uses a hidden textarea + selectAll + execCommand which
// is the documented compat technique. Either way we flip the
// "just copied" flash on success.
let ok = false;
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
ok = true;
}
} catch { /* fall through to fallback */ }
if (!ok) {
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.left = '-9999px';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
ok = document.execCommand('copy');
document.body.removeChild(ta);
} catch { /* ignore */ }
}
if (ok) {
setJustCopied(true);
if (copyTimeoutRef.current != null) clearTimeout(copyTimeoutRef.current);
copyTimeoutRef.current = setTimeout(() => setJustCopied(false), 1200);
}
};
// Panel styles inline. The overlay is a developer tool; not worth
// adding a CSS file we'd want to delete when the bug is fixed.
const panelStyle = {
position: 'fixed',
left: '0.5rem',
bottom: '0.5rem',
zIndex: 9999,
background: 'rgba(15, 23, 42, 0.92)',
color: '#e2e8f0',
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
fontSize: '11px',
lineHeight: 1.45,
padding: '0.6rem 0.75rem',
borderRadius: '8px',
maxWidth: '320px',
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.35)',
pointerEvents: 'auto',
userSelect: 'text',
};
const headerStyle = {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '0.4rem',
fontWeight: 600,
color: '#f59e0b',
fontSize: '12px',
};
const dismissBtnStyle = {
background: 'transparent',
border: 'none',
color: '#94a3b8',
fontSize: '14px',
cursor: 'pointer',
padding: 0,
lineHeight: 1,
};
// Copy button. Slightly larger hit target than the dismiss button
// because it's the action the user reaches for most often during
// a debug session, and uses a subtle pink tint when not flashing
// so it reads as the primary affordance on this panel. When
// `justCopied` is true, the button briefly swaps its label to
// "copied!" with a green tint as confirmation.
const copyBtnStyle = {
background: 'transparent',
border: '1px solid rgba(148, 163, 184, 0.35)',
color: justCopied ? '#86efac' : '#cbd5e1',
fontSize: '10px',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.04em',
cursor: 'pointer',
padding: '0.15rem 0.4rem',
borderRadius: '4px',
lineHeight: 1.3,
transition: 'color 0.15s ease, border-color 0.15s ease',
borderColor: justCopied ? 'rgba(134, 239, 172, 0.55)' : 'rgba(148, 163, 184, 0.35)',
};
const headerActionsStyle = {
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
};
const sectionStyle = {
borderTop: '1px solid rgba(148, 163, 184, 0.18)',
paddingTop: '0.35rem',
marginTop: '0.35rem',
};
const sectionTitle = {
color: '#94a3b8',
fontSize: '10px',
textTransform: 'uppercase',
letterSpacing: '0.04em',
marginBottom: '0.15rem',
};
const highlightDelta = (val) => {
// Color-code the delta so a glance tells you whether it's a real
// discrepancy or float noise. Threshold at 0.1px — anything below
// that is rounding, anything above is the bug.
const abs = Math.abs(val);
if (abs < 0.1) return '#94a3b8';
if (abs < 2) return '#fbbf24';
return '#f87171';
};
return (
<div style={panelStyle} role="region" aria-label="Debug overlay">
<div style={headerStyle}>
<span>🐛 element debug</span>
<div style={headerActionsStyle}>
<button
type="button"
onClick={handleCopy}
style={copyBtnStyle}
aria-label="Copy debug data to clipboard"
title="Copy as markdown"
>
{justCopied ? 'copied!' : 'copy'}
</button>
<button
type="button"
onClick={handleDismiss}
style={dismissBtnStyle}
aria-label="Close debug overlay"
title="Close (clears flag + reloads)"
>
×
</button>
</div>
</div>
{!readout && (
<div style={{ color: '#94a3b8' }}>No element selected.</div>
)}
{readout && (
<>
<div>
<span style={{ color: '#94a3b8' }}>id: </span>
<span>{readout.elementId.slice(0, 12)}</span>
</div>
<div style={sectionStyle}>
<div style={sectionTitle}>element (state)</div>
<Row label="x" value={readout.element.x} />
<Row label="y" value={readout.element.y} />
<Row label="w" value={readout.element.width} />
<Row label="h" value={readout.element.height} />
<Row label="rot°" value={readout.element.rotation} />
<Row label="flip" value={`${readout.element.flipX ? 'X' : '·'}${readout.element.flipY ? 'Y' : '·'}`} mono />
</div>
{readout.node && (
<div style={sectionStyle}>
<div style={sectionTitle}>node (live konva)</div>
<Row label="x" value={readout.node.x} />
<Row label="y" value={readout.node.y} />
<Row label="off" value={`${fmt(readout.node.offsetX)}, ${fmt(readout.node.offsetY)}`} mono />
<Row label="scale" value={`${fmt(readout.node.scaleX)}, ${fmt(readout.node.scaleY)}`} mono />
<Row label="rot°" value={readout.node.rotation} />
</div>
)}
{readout.derived && (
<div style={sectionStyle}>
<div style={sectionTitle}>derived</div>
{readout.derived.clientRect && (
<Row
label="visual TL (konva)"
value={`${fmt(readout.derived.clientRect.x)}, ${fmt(readout.derived.clientRect.y)}`}
mono
/>
)}
<Row
label="simple formula"
value={`${fmt(readout.derived.simpleReportedX)}, ${fmt(readout.derived.simpleReportedY)}`}
mono
hint="node.x - W/2 / node.y - H/2"
/>
<Row
label="rot-aware"
value={`${fmt(readout.derived.correctReportedX)}, ${fmt(readout.derived.correctReportedY)}`}
mono
hint="subtracts rotated offset vector"
/>
<Row
label="Δ x,y"
value={`${fmt(readout.derived.simpleDeltaX)}, ${fmt(readout.derived.simpleDeltaY)}`}
mono
color={highlightDelta(readout.derived.predictedJumpPx)}
/>
<Row
label="predicted jump"
value={`${fmt(readout.derived.predictedJumpPx)}px`}
mono
color={highlightDelta(readout.derived.predictedJumpPx)}
hint="how far the bbox will visibly jump on transform-end"
/>
</div>
)}
{gestureSummary && (
<div style={sectionStyle}>
<div style={sectionTitle}>last gesture</div>
<Row
label="duration"
value={gestureSummary.durationMs != null ? `${gestureSummary.durationMs}ms` : '—'}
mono
/>
<Row
label="bound calls"
value={`${gestureSummary.boundCalls} (${gestureSummary.boundRejects} clamped)`}
mono
color={gestureSummary.boundRejects > 0 ? '#fbbf24' : undefined}
hint="how many times canvasDragBound/constrainTransform fired; clamped = returned oldBox or a slid box"
/>
{gestureSummary.startState && gestureSummary.firstFrame && (
<>
<Row
label="first-frame Δ visual"
value={fmtDeltaPair(
gestureSummary.firstFrame.visualX,
gestureSummary.startState.visualX,
gestureSummary.firstFrame.visualY,
gestureSummary.startState.visualY,
)}
mono
color={highlightDelta(
hypotPair(
gestureSummary.firstFrame.visualX,
gestureSummary.startState.visualX,
gestureSummary.firstFrame.visualY,
gestureSummary.startState.visualY,
),
)}
hint="how far visual TL moved between gesture-start and FIRST frame; big = jump on grab"
/>
<Row
label="first-frame Δ rot"
value={fmtDelta(
gestureSummary.firstFrame.nodeRotation,
gestureSummary.startState.nodeRotation,
)}
mono
hint="how much rotation changed between gesture-start and FIRST frame"
/>
</>
)}
{gestureSummary.startState && gestureSummary.endState && (
<>
<Row
label="total Δ visual"
value={fmtDeltaPair(
gestureSummary.endState.visualX,
gestureSummary.startState.visualX,
gestureSummary.endState.visualY,
gestureSummary.startState.visualY,
)}
mono
hint="visual TL drift from start to end of gesture"
/>
<Row
label="total Δ rot"
value={fmtDelta(
gestureSummary.endState.nodeRotation,
gestureSummary.startState.nodeRotation,
)}
mono
/>
</>
)}
</div>
)}
</>
)}
</div>
);
}
/* Helpers ───────────────────────────────────────────────────────────── */
function pickElementFields(el) {
return {
x: el.x ?? 0,
y: el.y ?? 0,
width: el.width ?? el.fontSize ?? 0,
height: el.height ?? el.fontSize ?? 0,
rotation: el.rotation ?? 0,
flipX: !!el.flipX,
flipY: !!el.flipY,
};
}
function fmt(n) {
if (n == null || Number.isNaN(n)) return '—';
// Two decimal places — enough resolution to spot sub-pixel drift
// without making numbers visually noisy. Negative zero collapses
// to plain "0.00".
const v = Math.round(n * 100) / 100;
return Object.is(v, -0) ? '0.00' : v.toFixed(2);
}
function fmtDelta(after, before) {
if (after == null || before == null) return '—';
const d = after - before;
const sign = d > 0 ? '+' : '';
return `${sign}${fmt(d)}`;
}
function fmtDeltaPair(ax, bx, ay, by) {
return `${fmtDelta(ax, bx)}, ${fmtDelta(ay, by)}`;
}
function hypotPair(ax, bx, ay, by) {
if (ax == null || bx == null || ay == null || by == null) return 0;
return Math.hypot(ax - bx, ay - by);
}
/**
* Serialize the current readout + gesture summary into a markdown
* payload suitable for pasting into chat (issue tracker, doc, etc.).
*
* Format choices
* ──────────────
* One markdown table per logical section, with a small header
* preamble carrying timestamp + element id. Tables render cleanly
* in most chat clients (this one included) and remain readable as
* plain text if the renderer doesn't pick them up.
*
* Numbers go through the same `fmt` helper used by the on-screen
* rows so the clipboard contents match what the user sees in the
* panel character-for-character (two decimals, signed deltas, em-
* dash for missing values). No surprise reformat between visual
* and pasted data.
*
* Each section is independently optional — if there's no node, the
* "node (live konva)" table is omitted entirely rather than rendered
* with em-dashes everywhere. If no gesture has happened yet, the
* "last gesture" section is omitted. The header line is always
* present so a paste with just the element-state table is still
* self-describing.
*
* Returns the empty string when there's nothing meaningful to copy
* (no readout at all); the caller bails before writing to the
* clipboard in that case so we don't replace whatever the user had
* there before with empty text.
*/
function formatReadoutForClipboard(readout, gestureSummary) {
if (!readout) return '';
const lines = [];
const ts = new Date().toISOString().replace('T', ' ').slice(0, 19);
lines.push(`### element debug — ${ts}`);
lines.push(`**id**: \`${readout.elementId}\``);
lines.push('');
// element (state)
lines.push('**element (state)**');
lines.push('');
lines.push('| field | value |');
lines.push('|---|---|');
lines.push(`| x | ${fmt(readout.element.x)} |`);
lines.push(`| y | ${fmt(readout.element.y)} |`);
lines.push(`| w | ${fmt(readout.element.width)} |`);
lines.push(`| h | ${fmt(readout.element.height)} |`);
lines.push(`| rot° | ${fmt(readout.element.rotation)} |`);
lines.push(`| flip | ${readout.element.flipX ? 'X' : '·'}${readout.element.flipY ? 'Y' : '·'} |`);
lines.push('');
// node (live konva)
if (readout.node) {
lines.push('**node (live konva)**');
lines.push('');
lines.push('| field | value |');
lines.push('|---|---|');
lines.push(`| x | ${fmt(readout.node.x)} |`);
lines.push(`| y | ${fmt(readout.node.y)} |`);
lines.push(`| offset | ${fmt(readout.node.offsetX)}, ${fmt(readout.node.offsetY)} |`);
lines.push(`| scale | ${fmt(readout.node.scaleX)}, ${fmt(readout.node.scaleY)} |`);
lines.push(`| rot° | ${fmt(readout.node.rotation)} |`);
lines.push('');
}
// derived
if (readout.derived) {
lines.push('**derived**');
lines.push('');
lines.push('| field | value |');
lines.push('|---|---|');
if (readout.derived.clientRect) {
lines.push(`| visual TL (konva) | ${fmt(readout.derived.clientRect.x)}, ${fmt(readout.derived.clientRect.y)} |`);
}
lines.push(`| simple formula | ${fmt(readout.derived.simpleReportedX)}, ${fmt(readout.derived.simpleReportedY)} |`);
lines.push(`| rot-aware | ${fmt(readout.derived.correctReportedX)}, ${fmt(readout.derived.correctReportedY)} |`);
lines.push(`| Δ x,y | ${fmt(readout.derived.simpleDeltaX)}, ${fmt(readout.derived.simpleDeltaY)} |`);
lines.push(`| predicted jump | ${fmt(readout.derived.predictedJumpPx)}px |`);
lines.push('');
}
// last gesture
if (gestureSummary) {
lines.push('**last gesture**');
lines.push('');
lines.push('| field | value |');
lines.push('|---|---|');
lines.push(`| duration | ${gestureSummary.durationMs != null ? `${gestureSummary.durationMs}ms` : '—'} |`);
lines.push(`| bound calls | ${gestureSummary.boundCalls} (${gestureSummary.boundRejects} clamped) |`);
if (gestureSummary.startState && gestureSummary.firstFrame) {
lines.push(`| first-frame Δ visual | ${fmtDeltaPair(
gestureSummary.firstFrame.visualX,
gestureSummary.startState.visualX,
gestureSummary.firstFrame.visualY,
gestureSummary.startState.visualY,
)} |`);
lines.push(`| first-frame Δ rot | ${fmtDelta(
gestureSummary.firstFrame.nodeRotation,
gestureSummary.startState.nodeRotation,
)} |`);
}
if (gestureSummary.startState && gestureSummary.endState) {
lines.push(`| total Δ visual | ${fmtDeltaPair(
gestureSummary.endState.visualX,
gestureSummary.startState.visualX,
gestureSummary.endState.visualY,
gestureSummary.startState.visualY,
)} |`);
lines.push(`| total Δ rot | ${fmtDelta(
gestureSummary.endState.nodeRotation,
gestureSummary.startState.nodeRotation,
)} |`);
}
lines.push('');
}
// Trim the trailing blank line so the payload ends without an
// awkward extra newline when pasted into a tight text field.
while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
return lines.join('\n');
}
function Row({ label, value, mono, hint, color }) {
const rowStyle = {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'baseline',
gap: '0.5rem',
};
const labelStyle = {
color: '#94a3b8',
fontSize: '10.5px',
};
const valueStyle = {
fontFamily: mono ? 'inherit' : undefined,
color: color ?? '#e2e8f0',
fontVariantNumeric: 'tabular-nums',
};
return (
<div style={rowStyle} title={hint || undefined}>
<span style={labelStyle}>{label}</span>
<span style={valueStyle}>{typeof value === 'number' ? fmt(value) : value}</span>
</div>
);
}
/**
* Read the debug-flag state from URL and localStorage. Either source
* is enough to enable. Called once at App mount; the result drives
* conditional rendering of <DebugOverlay /> for the rest of the session
* (a reload re-reads, so toggles take effect on next page load).
*
* Defensive try/catches because SSR and storage-disabled environments
* (private windows in some browsers) throw on access.
*/
export function readDebugFlag() {
try {
if (typeof window !== 'undefined') {
const url = new URL(window.location.href);
const param = url.searchParams.get('debug');
if (param === '1' || param === 'true') {
// Persist so subsequent reloads keep it on without the URL.
try { localStorage.setItem('paw_debug_overlay', '1'); } catch { /* ignore */ }
return true;
}
}
} catch { /* ignore */ }
try {
if (typeof localStorage !== 'undefined') {
return localStorage.getItem('paw_debug_overlay') === '1';
}
} catch { /* ignore */ }
return false;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,70 +0,0 @@
// Reuses the visual treatment defined in ZoomControls.css (same pill
// chrome — white rounded container, brand-pink hover, disabled state).
// We import that CSS rather than copy the rules to keep the two
// pills perfectly in sync; if the pill chrome is ever refined in
// ZoomControls.css the same look applies here automatically.
//
// Future cleanup: extract `.zoom-controls` / `.zoom-controls__btn` to a
// shared `.pill-toolbar` block in a neutral file and migrate both
// components. Not done now because the rename touches multiple
// components and isn't blocking — the import-reuse is enough to keep
// these visually identical in the meantime.
import '../../styles/ZoomControls.css';
import { t } from '../../i18n/t';
/**
* Undo / Redo controls.
*
* Lives in the canvas-area's bottom bar alongside the zoom/snap pill,
* so all canvas-level "editor view" actions cluster together. Moved
* out of the Header in Change 19 — the Header had become crowded
* with seven distinct actions (undo, redo, clear, preview, download,
* Save, Share, cart) and the undo/redo pair was the most obviously
* "editor view" rather than "ship the design" group.
*
* Each button is disabled when its action isn't available (no
* history past this point in either direction). The icons match
* what was previously in the Header: a curved arrow turning back
* left for undo, the same arrow mirrored for redo.
*
* i18n: the strings live under `header.undo` / `header.redo` keys
* historically. We continue to use them here even though the buttons
* have moved out of the header — they're functional labels not
* location-specific, and renaming them would mean updating every
* locale file. The keys are flat strings, the namespace prefix is
* just punctuation.
*/
export function HistoryControls({ canUndo, onUndo, canRedo, onRedo }) {
return (
<div className="zoom-controls" role="toolbar" aria-label="Edit history">
<button
type="button"
className="zoom-controls__btn"
onClick={onUndo}
disabled={!canUndo}
aria-label={t('header.undo')}
title={t('header.undo-tooltip')}
>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
{/* Curved arrow turning back to the left — universal undo glyph. */}
<path d="M9 14L4 9l5-5" />
<path d="M4 9h11a5 5 0 0 1 5 5v0a5 5 0 0 1-5 5h-4" />
</svg>
</button>
<button
type="button"
className="zoom-controls__btn"
onClick={onRedo}
disabled={!canRedo}
aria-label={t('header.redo')}
title={t('header.redo-tooltip')}
>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
{/* Mirror of the undo glyph. */}
<path d="M15 14l5-5-5-5" />
<path d="M20 9H9a5 5 0 0 0-5 5v0a5 5 0 0 0 5 5h4" />
</svg>
</button>
</div>
);
}

View File

@@ -1,374 +0,0 @@
import { useEffect, useRef, memo } from 'react';
import { Image, Rect } from 'react-konva';
import Konva from 'konva';
import useImage from 'use-image';
import { getFilterPreset } from '../../constants/imageFilters';
import { MIN_ELEMENT_SIZE } from '../../constants/elements';
import { unflipReportedXY } from '../../utils/flipOffset';
// Per-element Transformer rendering was moved to DesignCanvas's dedicated
// transformer layer (see DesignCanvas.jsx). This component now renders
// the image + its out-of-bounds warning; selection chrome lives one
// layer above so resize / rotate handles always draw on top of later
// elements in z-order. Imports for Transformer / rotation snap / corner
// anchors / transformer visual style were dropped along with the JSX
// that consumed them.
//
// MIN_ELEMENT_SIZE stays imported — onTransformEnd still applies it
// as a defensive floor when committing the final width/height to
// state. DesignCanvas's bound function clamps DURING the transform
// (so the user can't drag handles past the floor), but the commit
// here is the last line of defence: any rounding, gesture
// interruption, or future bound-function bug that lets a sub-floor
// value slip through still gets clamped before it lands in state.
function URLImage({ src, innerRef, ...props }) {
const [img] = useImage(src, 'anonymous');
return <Image image={img} ref={innerRef} {...props} />;
}
/**
* Konva Image wrapper.
*
* ── Flipping ─────────────────────────────────────────────────────────────
* Horizontal/vertical flip is implemented as `scaleX/scaleY = ±1` on the node
* combined with a matching `offsetX/offsetY` so the flip happens in-place
* (around the bounding-box center) instead of around the (x, y) origin (which
* would visually "shoot off" the element).
*
* The user can still resize via the transformer corner handles. Konva applies
* the transform as a multiplier on `scaleX`, so a flipped element being
* resized to 1.5× lands at scaleX = -1.5. In `onTransformEnd` we collapse
* that back into width/height (taking absolute value of the scale) and
* preserve the flip state on the element. The transformer never *changes*
* the flip — only the explicit Flip H / Flip V buttons do.
*
* ── Opacity ──────────────────────────────────────────────────────────────
* Pass-through to Konva's native `opacity` prop on the node.
*
* ── Lock ─────────────────────────────────────────────────────────────────
* When `locked` is true, the element can't be dragged or transformed. The
* transformer still renders a border (so the user knows it's selected) but
* with no anchors or rotate handle. Toggle via the lock button in the
* floating element toolbar.
*
* ── Filters ──────────────────────────────────────────────────────────────
* `filter` is a preset id (see constants/imageFilters.js). Applying a Konva
* filter requires the node to be cached, so we attach a useEffect that
* caches+filters once the underlying image has loaded and re-runs whenever
* the filter or the image changes. Server-side render does NOT currently
* apply filters; this is an editor-only visual at this time.
*
* ── Rotation snap ────────────────────────────────────────────────────────
* When `shiftHeld` is true, the transformer snaps to 15° increments. The
* shift state is tracked at the App level so all selected elements get
* the snap behavior simultaneously.
*/
export const ImageElement = memo(function ImageElement({
id: _id,
x = 0,
y = 0,
width = 100,
height = 100,
rotation = 0,
src,
crop,
opacity = 1,
flipX = false,
flipY = false,
locked = false,
filter = 'none',
// Brightness / contrast adjustments (range matches Konva.Filters):
// brightness: -1 (fully dark) … 0 (no change) … +1 (fully bright)
// contrast: -100 (fully flat) … 0 (no change) … +100 (max contrast)
// Default 0 = identity; both compose with the preset `filter` so a
// user can pick "Sepia" and then nudge brightness up, etc.
brightness = 0,
contrast = 0,
isSelected: _isSelected,
// Change 3 — when true, render a warning-yellow Rect behind the
// image so the user can see WHICH element is out of bounds, not just
// that *something* is. Computed at the DesignCanvas level via the
// same `isElementOutOfBounds` math that drives the bottom-chip count.
isOutOfBounds = false,
shiftHeld: _shiftHeld = false,
onSelect,
onUpdate,
onCommit,
dragBoundFunc,
transformBoundFunc: _transformBoundFunc,
}) {
const shapeRef = useRef(null);
// Filter application. Konva requires the node to be cached for filters
// to take effect, and re-cached whenever the input pixels (image, size)
// or the filter list change. We do this in a useEffect that also handles
// the initial async image load — the underlying `useImage` inside
// URLImage updates the node's `image` attr asynchronously, so we listen
// for the `imageChange` event on the node and re-cache then.
//
// Brightness and contrast stack on top of the preset filter — Konva's
// `filters()` accepts an array and runs them in order, so a Sepia preset
// with brightness +0.2 gives a brightened sepia. Brightness 0 and
// contrast 0 are identity values, but applying the filter is still
// cheap (the no-op multiplier), so we keep them in the chain whenever
// the user has touched the sliders rather than special-casing.
useEffect(() => {
const node = shapeRef.current;
if (!node) return;
const applyFilter = () => {
const preset = getFilterPreset(filter);
// Lookup order: customFn (e.g. Filerobot's Kelvin) takes precedence,
// then a string name resolved against Konva.Filters, else no filter.
const presetFn = preset.customFn ?? (preset.konva ? Konva.Filters[preset.konva] : null);
const filters = [];
if (presetFn) filters.push(presetFn);
// Add Brighten / Contrast only when the user has actually moved
// the slider off zero. Avoids cache+filter overhead for the
// common "untouched" case while still composing cleanly with
// the preset when active.
if (brightness !== 0) filters.push(Konva.Filters.Brighten);
if (contrast !== 0) filters.push(Konva.Filters.Contrast);
if (filters.length > 0) {
node.cache();
node.filters(filters);
} else {
node.filters([]);
node.clearCache();
}
node.getLayer()?.batchDraw();
};
// Apply once for the current state.
applyFilter();
// Re-apply when the image itself loads/changes — Konva's `image` attr
// is set async by `useImage` and the cache is dimension-sensitive,
// so an early `cache()` call before the image arrives produces an
// empty filtered layer. The `imageChange` event fires whenever the
// attr is reassigned.
node.on('imageChange.filterapply', applyFilter);
return () => {
node.off('imageChange.filterapply');
};
}, [filter, brightness, contrast, src, width, height]);
// Always-center offset for rotation + flip-in-place.
//
// Konva rotates a node around its local origin (the point at
// `offsetX, offsetY`). Setting offset to the bbox CENTER means:
//
// • Rotation pivots around the center — what users expect from
// a rotate handle. Previously the offset was zero for
// non-flipped elements, which put the pivot at the top-left;
// on touch devices the rotate handle had inherent sub-pixel
// contact offset, and that small offset rotated around the
// top-left visibly swung the whole bbox sideways on the first
// frame of rotation. Desktop didn't see it because mouse-down
// is pixel-precise (first frame stays at ~0° rotation). The
// center pivot fixes the mobile feel without changing desktop.
//
// • Flipping (scaleX/Y = -1) reflects around the local origin,
// so a center-aligned offset gives in-place flip for free.
// This used to be the only reason offset was set; we now set
// it unconditionally and get correct rotation as a bonus.
//
// Because the offset shifts the node's local origin, the rendered
// top-left lands at `(x - offsetX, y - offsetY)`. To keep the
// user-supplied (element.x, element.y) as the visual top-left,
// renderX/renderY are computed by ADDING the offset back to the
// stored position.
//
// Migration note for existing saved designs
// ───────────────────────────────────────────
// For non-flipped, non-rotated elements: identical render — the
// bbox is axis-aligned, so the center-pivot draws at the same
// screen location as the old top-left-pivot.
// For non-flipped, ROTATED elements saved before this change:
// their stored (x, y) was the location the OLD top-left pivot
// ended up at AFTER the rotation. Now we re-interpret (x, y) as
// the pivot of a CENTER-rotated element, so the visible bbox will
// shift on first load. The drift is bounded (the bbox stays the
// same size; only its position moves) and the user can re-position
// by dragging. No automatic migration: the editor doesn't have a
// persistence version field, and the rotated-saved-design case is
// rare enough that adding heuristic detection isn't worth the
// complexity.
// For FLIPPED rotated elements: no visual change. The offset was
// already center in the old code (that was the original purpose),
// so the rotation behavior is unchanged for them.
const offsetX = width / 2;
const offsetY = height / 2;
const renderX = x + offsetX;
const renderY = y + offsetY;
// Bbox in PIVOT-RELATIVE local coords, exposed as custom Konva
// attrs that DesignCanvas's canvasDragBound reads.
//
// canvasDragBound computes rotated corners with the formula
// (x·cos - y·sin, x·sin + y·cos), which rotates the (xMin, yMin)
// to (xMax, yMax) rectangle around the ORIGIN (0, 0) of the
// node-local frame. With offset at the bbox center, the local
// origin IS the rotation pivot — and the bbox itself spans from
// (-W/2, -H/2) to (+W/2, +H/2) relative to that pivot. Without
// setting these explicit attrs, the function would fall back to
// its `?? 0` / `?? this.width()` defaults (correct only for the
// old offset=0 convention) and the clamping would be off by
// half a bbox.
//
// The values are scalar so they can be read by Konva's attrs
// system without React intermediation — same pattern TextElement
// already uses for visible-ink bbox.
const snapBboxMinX = -width / 2;
const snapBboxMinY = -height / 2;
const snapBboxMaxX = width / 2;
const snapBboxMaxY = height / 2;
return (
<>
{/* Out-of-bounds warning background (Change 3). Rendered BEFORE
the image so it sits behind. Uses the same render-time
position / rotation / flip math as the image itself, so the
highlight follows the element through every transform. We
intentionally don't include the warning rect in the
Transformer's nodes() — it's purely decorative and shouldn't
add to the selection bbox. The yellow is the classic CSS
`gold` family at low opacity so it reads as a warning without
drowning the underlying artwork.
Same always-center offset as the image below so the warning
tracks the image identically through rotation. Previously
the warning's offset was conditional on flip and could drift
relative to the image when the offset conventions diverged;
mirroring the image's render math here keeps them locked. */}
{isOutOfBounds && (
<Rect
x={renderX}
y={renderY}
width={width}
height={height}
offsetX={offsetX}
offsetY={offsetY}
scaleX={flipX ? -1 : 1}
scaleY={flipY ? -1 : 1}
rotation={rotation}
fill="#fde68a"
stroke="#f59e0b"
strokeWidth={1.5}
opacity={0.65}
listening={false}
perfectDrawEnabled={false}
/>
)}
<URLImage
innerRef={shapeRef}
/* Konva `name` attr = element id. Lets DesignCanvas locate the
* underlying Konva node via `layer.findOne('.' + id)` for two
* features:
* 1. Marquee drag-select reads each node's getClientRect to
* test intersection with the marquee bbox.
* 2. Shared multi-element Transformer wires `transformer.nodes`
* to the actual Konva nodes whenever selectedIds changes.
* Using `name` rather than `id` because Konva's `id()` is reserved
* for its own internal id-uniqueness machinery, while `name` is
* effectively a free-form className with `.name`-selector support. */
name={_id}
x={renderX}
y={renderY}
width={width}
height={height}
offsetX={offsetX}
offsetY={offsetY}
scaleX={flipX ? -1 : 1}
scaleY={flipY ? -1 : 1}
rotation={rotation}
opacity={opacity}
/* Pivot-relative bbox attrs read by canvasDragBound. See the
* docblock above the snapBbox* declarations for the full
* rationale; in short, they describe the visible rect in the
* post-offset local frame so the clamp math lines up with the
* rotated AABB at the wrapper boundary. */
_pawSnapBboxMinX={snapBboxMinX}
_pawSnapBboxMinY={snapBboxMinY}
_pawSnapBboxMaxX={snapBboxMaxX}
_pawSnapBboxMaxY={snapBboxMaxY}
/* Konva reads `brightness` / `contrast` off the node's attrs
* during filter application these props set the values that
* Konva.Filters.Brighten and Konva.Filters.Contrast consume. */
brightness={brightness}
contrast={contrast}
src={src}
crop={
crop
? { x: crop.sx, y: crop.sy, width: crop.sWidth, height: crop.sHeight }
: undefined
}
draggable={!locked}
dragBoundFunc={dragBoundFunc}
onClick={(e) => {
e.cancelBubble = true;
onSelect?.();
}}
onTap={(e) => {
e.cancelBubble = true;
onSelect?.();
}}
/* Bug 6 fix select on press, not just on click-up. See
* TextElement.jsx for the full rationale: click-then-drag
* without an intermediate mouseup never fires onClick, so the
* pre-fix flow left the element unselected once the user
* started dragging immediately and the DesignCanvas-level
* Transformer (which attaches to the selected node) never
* mounted handles around it. */
onMouseDown={(e) => {
e.cancelBubble = true;
onSelect?.();
}}
onTouchStart={(e) => {
e.cancelBubble = true;
onSelect?.();
}}
onDragEnd={(e) => {
// Convert back to top-left-origin coords if we were drawing flipped.
const { x: reportedX, y: reportedY } = unflipReportedXY(e.target, width, height, flipX, flipY);
onUpdate({ x: reportedX, y: reportedY });
onCommit?.();
}}
onTransformEnd={() => {
const node = shapeRef.current;
if (!node) return;
// The transformer multiplies into scaleX/scaleY. We absorb the
// *magnitude* into width/height and keep the *sign* (flip state)
// separate by reading element.flipX/flipY explicitly.
const sx = node.scaleX();
const sy = node.scaleY();
const newWidth = Math.max(MIN_ELEMENT_SIZE, node.width() * Math.abs(sx));
const newHeight = Math.max(MIN_ELEMENT_SIZE, node.height() * Math.abs(sy));
// Reset scale to ±1 (the flip-state base) so subsequent transforms
// start fresh.
node.scaleX(flipX ? -1 : 1);
node.scaleY(flipY ? -1 : 1);
// Convert back to top-left-origin coords using the freshly-resolved
// dimensions, not the stale prop values — a shrink-then-flip would
// otherwise offset by the wrong half-bbox.
const { x: reportedX, y: reportedY } = unflipReportedXY(node, newWidth, newHeight, flipX, flipY);
onUpdate({
x: reportedX,
y: reportedY,
width: newWidth,
height: newHeight,
rotation: node.rotation(),
});
onCommit?.();
}}
/>
{/* Per-element Transformer removed (bug 8 fix). Selection chrome
now lives in DesignCanvas's dedicated transformer layer,
which renders ABOVE the elements layer so resize / rotate
handles always draw on top of every element regardless of
z-order. The MIN_ELEMENT_SIZE floor and keepRatio / corner-
anchor configuration that used to live here moved with it. */}
</>
);
});

View File

@@ -1,128 +0,0 @@
import { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
/**
* Inline text editor (S2).
*
* Renders a transparent <textarea> overlay positioned exactly over a
* canvas text element so the user can edit the text in place rather than
* jumping to the side panel. Activated by double-clicking a text element.
*
* Coordinates
* ───────────
* The Konva Stage uses a CSS transform (`scale(s)`) to fit the design
* canvas into whatever DOM area is available. The text node's
* `getClientRect({ relativeTo: stage })` returns coordinates in the
* Stage's INTERNAL space (the canonical 300×300 design coords). To
* place the textarea over the text on screen, we multiply those by the
* stage's CSS scale factor and offset by the stage container's
* page-level position.
*
* pageX = stageBox.left + rect.x × cssScale
* pageY = stageBox.top + rect.y × cssScale
*
* Rotation is intentionally NOT supported inline — rotated text would
* need a CSS transform on the textarea, plus pointer events get weird.
* For rotated/arc text, the side panel remains the editing surface;
* double-click still selects the text, just doesn't open the inline
* editor.
*
* Lifecycle
* ─────────
* Mount → focus the textarea, select all so the user can immediately
* type to replace.
* Input → onChange streams every keystroke to onUpdate (the same
* updateSelectedElement path the side-panel textarea uses, with
* center-preservation already applied by the App-level wrapper).
* Dismiss → onCommit + onClose, fired on blur OR Escape.
*
* Enter/Return creates a newline (textarea default). Cmd/Ctrl+Enter
* dismisses, mirroring most chat-UI conventions for "done editing".
*/
export function InlineTextEditor({ element, rect, onUpdate, onCommit, onClose }) {
const textareaRef = useRef(null);
// Focus + select-all on mount, so the user can immediately type to
// replace the existing text. Running once via empty deps; the
// textarea is keyed by element.id at the call site so a different
// element triggers a remount.
useEffect(() => {
const ta = textareaRef.current;
if (!ta) return;
ta.focus();
ta.select();
}, []);
if (!element || !rect) return null;
const handleChange = (e) => {
onUpdate?.({ text: e.target.value });
};
const handleKeyDown = (e) => {
// Escape dismisses without committing the in-flight value (the
// updates already streamed via onUpdate, and history will commit
// them at onClose anyway). Cmd/Ctrl+Enter is the explicit-commit
// shortcut. Plain Enter inserts a newline as expected.
if (e.key === 'Escape') {
e.preventDefault();
onClose?.();
return;
}
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault();
onCommit?.();
onClose?.();
}
// Stop propagation so global shortcuts (Delete, Backspace, [, ])
// don't fire while the user is editing.
e.stopPropagation();
};
const handleBlur = () => {
onCommit?.();
onClose?.();
};
// Match the rendered text's typography so what the user types lines
// up visually with what they'll see when they're done editing. The
// textarea sits on top of the text node, so any mismatch reads as
// "the editor is in a different font than my text" — confusing.
const fontSize = (element.fontSize || 24) * (rect.scale || 1);
const style = {
position: 'fixed',
left: `${rect.left}px`,
top: `${rect.top}px`,
width: `${rect.width}px`,
height: `${rect.height}px`,
fontSize: `${fontSize}px`,
fontFamily: element.fontFamily || 'DM Sans',
color: element.fill || '#0f172a',
background: 'rgba(255, 255, 255, 0.92)',
border: '2px solid #ec4899',
borderRadius: '4px',
outline: 'none',
padding: '2px 4px',
margin: 0,
resize: 'none',
overflow: 'hidden',
lineHeight: 1.2,
textAlign: 'center',
boxSizing: 'border-box',
boxShadow: '0 0 0 4px rgba(236, 72, 153, 0.18)',
zIndex: 1000,
};
return createPortal(
<textarea
ref={textareaRef}
value={element.text ?? ''}
onChange={handleChange}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
style={style}
aria-label="Edit text inline"
/>,
document.body,
);
}

View File

@@ -1,128 +0,0 @@
import '../../styles/ModelSilhouette.css';
/**
* Stylized silhouette of a person wearing a t-shirt. Rendered behind the
* shirt SVG when "Preview on Model" is enabled, so the user can see what
* their design looks like worn rather than as a flat mockup.
*
* The shape is intentionally simple: head + neck + shoulders + arms. The
* shirt SVG sits on top, hiding the torso area. The result reads as
* "a person wearing this shirt" without needing a full photographic model.
*
* viewBox matches the shirt SVG (480×540) so the two layers register cleanly
* when stacked in the same wrapper.
*/
export function ModelSilhouette() {
return (
<svg
className="model-silhouette"
viewBox="0 0 480 540"
role="img"
aria-label="Model preview"
preserveAspectRatio="xMidYMid meet"
>
<defs>
<linearGradient id="model-skin" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#f5d4b8" />
<stop offset="100%" stopColor="#e8b894" />
</linearGradient>
<linearGradient id="model-hair" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#3d2817" />
<stop offset="100%" stopColor="#2a1a0e" />
</linearGradient>
<radialGradient id="model-bg" cx="50%" cy="40%" r="60%">
<stop offset="0%" stopColor="rgba(253, 226, 234, 0.7)" />
<stop offset="100%" stopColor="rgba(253, 226, 234, 0.0)" />
</radialGradient>
</defs>
{/* Soft halo behind the model so it visually recedes from the canvas frame */}
<rect x="0" y="0" width="480" height="540" fill="url(#model-bg)" />
{/* Hair — covers the top/sides of the head, gives a silhouette feel */}
<path
d="
M 195 25
Q 175 35 168 65
Q 162 92 175 110
L 200 90
Q 240 70 280 90
L 305 110
Q 318 92 312 65
Q 305 35 285 25
Q 240 5 195 25
Z
"
fill="url(#model-hair)"
/>
{/* Head — slightly narrower than the hair so the hair frames it */}
<ellipse cx="240" cy="68" rx="44" ry="52" fill="url(#model-skin)" />
{/* Subtle shadow under the chin where the neck begins */}
<ellipse cx="240" cy="118" rx="22" ry="6" fill="rgba(31, 29, 35, 0.08)" />
{/* Neck — short, slightly tapered */}
<path
d="
M 220 110
Q 218 130 215 145
L 265 145
Q 262 130 260 110
Z
"
fill="url(#model-skin)"
/>
{/* Shoulders & upper arms — the shirt SVG paints on top so we only need
the parts that peek out. We extend the arms slightly past the shirt
sleeves on both sides. */}
<path
d="
M 60 145
Q 100 138 150 145
L 170 200
Q 130 220 95 220
Q 70 215 55 195
Z
"
fill="url(#model-skin)"
/>
<path
d="
M 420 145
Q 380 138 330 145
L 310 200
Q 350 220 385 220
Q 410 215 425 195
Z
"
fill="url(#model-skin)"
/>
{/* Forearms peeking out below the shirt sleeves */}
<path
d="
M 95 220
Q 75 280 70 360
Q 78 380 95 378
Q 110 360 115 295
Q 112 250 110 220
Z
"
fill="url(#model-skin)"
/>
<path
d="
M 385 220
Q 405 280 410 360
Q 402 380 385 378
Q 370 360 365 295
Q 368 250 370 220
Z
"
fill="url(#model-skin)"
/>
</svg>
);
}

View File

@@ -1,103 +0,0 @@
import { Group, Rect, Text, Line } from 'react-konva';
/**
* Placeholder for an empty template slot.
*
* Rendered as a dashed sky-blue rectangle with corner brackets and a
* camera glyph + label. Visible only when the slot is unfilled
* (`isEmpty`); once an element is assigned to the slot, the layer above
* draws the element and the placeholder returns null.
*
* Interactivity
* ─────────────
* When an `onClick` handler is provided, the placeholder's background
* rect listens for clicks and the Group fires onClick/onTap. The
* decorative children (dashed-border rect, label text, corner brackets)
* stay `listening={false}` so the visible click target is just the
* background fill — no flicker between brackets and rect.
*
* Hovering over an interactive slot flips the stage cursor to `pointer`
* so the affordance reads as clickable. Konva doesn't have a built-in
* cursor prop for shapes; setting it via the stage container is the
* standard pattern.
*
* Layer ordering
* ──────────────
* The parent `DesignCanvas` renders this layer BEFORE the user-elements
* layer so that a user-placed element drawn over a slot visually
* obscures the dashed outline and intercepts clicks first. That matches
* user expectation: when you've put something into a slot region, you
* want to interact with what you put there, not the slot underneath.
*/
export function SlotPlaceholder({ slot, isEmpty = true, isLoading = false, onClick }) {
const { bounds, label } = slot;
const { x, y, width, height } = bounds;
if (!isEmpty) return null;
// While loading, the slot is non-interactive and shows a spinner-style
// overlay instead of the camera glyph. Suppressing onClick prevents
// the user from re-triggering the picker mid-upload, and the dimmed
// fill + "Loading…" label communicate that the click registered.
// (S25.)
const isInteractive = typeof onClick === 'function' && !isLoading;
const handleMouseEnter = (e) => {
const stage = e.target.getStage();
if (stage) stage.container().style.cursor = 'pointer';
};
const handleMouseLeave = (e) => {
const stage = e.target.getStage();
if (stage) stage.container().style.cursor = 'default';
};
return (
<Group
name={`slot-placeholder-${slot.id}`}
onClick={isInteractive ? onClick : undefined}
onTap={isInteractive ? onClick : undefined}
onMouseEnter={isInteractive ? handleMouseEnter : undefined}
onMouseLeave={isInteractive ? handleMouseLeave : undefined}
>
{/* Dashed border — decorative, doesn't intercept clicks. */}
<Rect x={x} y={y} width={width} height={height} stroke="#94a3b8" strokeWidth={2} dash={[8, 4]} cornerRadius={4} listening={false} />
{/* Soft-fill background — the click target. Listens only when an
onClick handler is present, so non-interactive slots don't
shadow the Stage's deselect-on-empty-canvas behavior. The fill
deepens slightly while loading to communicate the "working…"
state visually. */}
<Rect
x={x} y={y} width={width} height={height}
fill={isLoading ? 'rgba(56, 189, 248, 0.18)' : 'rgba(148, 163, 184, 0.1)'}
listening={isInteractive}
/>
{isLoading ? (
<>
<Text text="…" x={x + width / 2} y={y + height / 2 - 20} fontSize={28} align="center" offsetX={10} fill="#0284c7" listening={false} />
<Text text="Loading…" x={x + width / 2} y={y + height / 2 + 10} fontSize={11} fontFamily="DM Sans" fill="#0369a1" align="center" offsetX={width / 2} listening={false} />
</>
) : (
<>
<Text text="📷" x={x + width / 2} y={y + height / 2 - 20} fontSize={24} align="center" offsetX={12} listening={false} />
<Text text={label || 'Drop image here'} x={x + width / 2} y={y + height / 2 + 10} fontSize={11} fontFamily="DM Sans" fill="#64748b" align="center" offsetX={width / 2} listening={false} />
</>
)}
<Line points={[x, y, x + 20, y]} stroke="#38bdf8" strokeWidth={3} lineCap="round" listening={false} />
<Line points={[x, y, x, y + 20]} stroke="#38bdf8" strokeWidth={3} lineCap="round" listening={false} />
<Line points={[x + width, y, x + width - 20, y]} stroke="#38bdf8" strokeWidth={3} lineCap="round" listening={false} />
<Line points={[x + width, y, x + width, y + 20]} stroke="#38bdf8" strokeWidth={3} lineCap="round" listening={false} />
<Line points={[x, y + height, x + 20, y + height]} stroke="#38bdf8" strokeWidth={3} lineCap="round" listening={false} />
<Line points={[x, y + height, x, y + height - 20]} stroke="#38bdf8" strokeWidth={3} lineCap="round" listening={false} />
<Line points={[x + width, y + height, x + width - 20, y + height]} stroke="#38bdf8" strokeWidth={3} lineCap="round" listening={false} />
<Line points={[x + width, y + height, x + width, y + height - 20]} stroke="#38bdf8" strokeWidth={3} lineCap="round" listening={false} />
</Group>
);
}
export function SlotBoundsGuide({ slot }) {
const { bounds, id } = slot;
return (
<Group name={`slot-bounds-${id}`} listening={false}>
<Rect x={bounds.x} y={bounds.y} width={bounds.width} height={bounds.height} stroke="rgba(56, 189, 248, 0.3)" strokeWidth={1} dash={[4, 4]} cornerRadius={2} />
</Group>
);
}

View File

@@ -1,149 +0,0 @@
import '../../styles/TShirtSVG.css';
/**
* Pure-SVG t-shirt mockup. The path is a stylized tee silhouette (collar +
* sleeves + body). Color is the user-selected garment color from the Shirt
* Options panel. The print-zone rectangle in the chest area indicates where
* the design overlay will land — it's visual-only (the actual design overlay
* sits in the Konva stage on top of this).
*
* Sized via `viewBox` so the SVG scales fluidly with its container.
*/
export function TShirtSVG({ size = 360, color = '#ffffff', showPrintZone = true }) {
// Compute a complementary stroke shade so the silhouette stays visible on
// light AND dark fills. We darken the fill ~12% in HSL space — close enough
// for our small palette without pulling in a color library.
const stroke = darken(color, 0.12);
const isDark = perceptualLuminance(color) < 0.45;
return (
<svg
className="tshirt-svg"
viewBox="40 50 400 470"
role="img"
aria-label="T-shirt preview"
preserveAspectRatio="xMidYMid meet"
>
{/* Soft drop shadow under the shirt — kept subtle so the canvas remains the focus. */}
<defs>
<filter id="tshirt-shadow" x="-10%" y="-10%" width="120%" height="120%">
<feGaussianBlur in="SourceAlpha" stdDeviation="6" />
<feOffset dx="0" dy="6" result="offset" />
<feComponentTransfer>
<feFuncA type="linear" slope="0.18" />
</feComponentTransfer>
<feMerge>
<feMergeNode />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
{/* Shirt silhouette ---------------------------------------------------- */}
<g filter="url(#tshirt-shadow)">
<path
d="
M 110 90
L 170 60
Q 192 84 240 84
Q 288 84 310 60
L 370 90
L 430 130
L 400 200
L 360 180
L 360 480
Q 360 500 340 500
L 140 500
Q 120 500 120 480
L 120 180
L 80 200
L 50 130
Z
"
fill={color}
stroke={stroke}
strokeWidth="2"
strokeLinejoin="round"
/>
{/* Collar */}
<path
d="
M 200 78
Q 240 110 280 78
"
fill="none"
stroke={stroke}
strokeWidth="2.5"
strokeLinecap="round"
/>
</g>
{/* Print zone — corner brackets, not a closed rectangle, so the design
overlay isn't visually competing with a hard frame. */}
{showPrintZone && (
<g
className="tshirt-svg__print-zone"
stroke={isDark ? 'rgba(255,255,255,0.55)' : 'rgba(31, 29, 35, 0.18)'}
strokeWidth="1.5"
strokeLinecap="round"
fill="none"
>
{/* Corners of a 220×220 print zone centered in the chest */}
{(() => {
const x = 130;
const y = 180;
const w = 220;
const h = 220;
const c = 14;
return (
<>
<path d={`M ${x} ${y + c} L ${x} ${y} L ${x + c} ${y}`} />
<path d={`M ${x + w - c} ${y} L ${x + w} ${y} L ${x + w} ${y + c}`} />
<path d={`M ${x + w} ${y + h - c} L ${x + w} ${y + h} L ${x + w - c} ${y + h}`} />
<path d={`M ${x + c} ${y + h} L ${x} ${y + h} L ${x} ${y + h - c}`} />
</>
);
})()}
</g>
)}
</svg>
);
}
/* ────────────────────────────────────────────────────────────────────────── */
/* Tiny color helpers — local to this file to avoid pulling in a dependency. */
/* ────────────────────────────────────────────────────────────────────────── */
function parseHex(hex) {
const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex);
if (!m) return [255, 255, 255];
const v = m[1];
if (v.length === 3) {
return [
parseInt(v[0] + v[0], 16),
parseInt(v[1] + v[1], 16),
parseInt(v[2] + v[2], 16),
];
}
return [
parseInt(v.slice(0, 2), 16),
parseInt(v.slice(2, 4), 16),
parseInt(v.slice(4, 6), 16),
];
}
function toHex(r, g, b) {
const clamp = (v) => Math.max(0, Math.min(255, Math.round(v)));
return `#${[r, g, b].map((v) => clamp(v).toString(16).padStart(2, '0')).join('')}`;
}
function darken(hex, amount = 0.1) {
const [r, g, b] = parseHex(hex);
return toHex(r * (1 - amount), g * (1 - amount), b * (1 - amount));
}
function perceptualLuminance(hex) {
const [r, g, b] = parseHex(hex);
// Rec. 601 luma — perceptually weighted, fast.
return (0.299 * r + 0.587 * g + 0.114 * b) / 255;
}

View File

@@ -1,37 +0,0 @@
import { Group, Image as KonvaImage, Rect, Text as KonvaText } from 'react-konva';
import useImage from 'use-image';
function TemplateImage({ src, x, y, width, height, opacity = 1, listening = false }) {
const [img] = useImage(src, 'anonymous');
return <KonvaImage image={img} x={x} y={y} width={width} height={height} opacity={opacity} listening={listening} />;
}
function TemplateText({ text, x, y, fontSize, fontFamily, fill, rotation = 0 }) {
return <KonvaText text={text} x={x} y={y} fontSize={fontSize} fontFamily={fontFamily} fill={fill} rotation={rotation} listening={false} />;
}
export function TemplateLayer({ template, canvasSize = 300 }) {
if (!template) return null;
const { background, overlay } = template;
return (
<Group name="template-layer">
{background && (
<Group name="template-background">
{background.type === 'color' ? (
<Rect x={0} y={0} width={canvasSize} height={canvasSize} fill={background.color} listening={false} />
) : background.type === 'image' ? (
<TemplateImage src={background.src} x={0} y={0} width={canvasSize} height={canvasSize} listening={false} />
) : null}
</Group>
)}
{overlay && overlay.map((el, index) => {
if (el.nonPrintable) return null;
const key = `overlay-${index}`;
if (el.type === 'image') return <TemplateImage key={key} src={el.src} x={el.x || 0} y={el.y || 0} width={el.width || 100} height={el.height || 100} opacity={el.opacity} listening={false} />;
if (el.type === 'text') return <TemplateText key={key} text={el.text} x={el.x || 0} y={el.y || 0} fontSize={el.fontSize} fontFamily={el.fontFamily} fill={el.fill} rotation={el.rotation} />;
return null;
})}
</Group>
);
}

View File

@@ -1,286 +0,0 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import Konva from 'konva';
import { getNodePageRect } from '../../utils/konvaCoords';
/**
* Pencil overlay for editing text — MOBILE ONLY in the current flow.
*
* The desktop editing model has been reverted: selecting a text
* element on the canvas auto-switches the right rail to the Text
* tab, with edits flowing live to the selected element. No pencil
* affordance is rendered on desktop.
*
* On mobile, the right-rail-equivalent (the bottom sheet) is hidden
* by default — auto-opening it on every text selection would be
* disruptive (the sheet covers most of the canvas). So mobile keeps
* an explicit gesture: tap the on-canvas pencil next to a selected
* text element to open the sheet on the Text tab in edit mode. This
* is the same component that used to be desktop-only; the gate in
* App.jsx flipped from `!isMobile` to `isMobile`.
*
* The pencil renders a 40×40 button positioned just outside the
* top-right corner of the selected text node's bounding box. 40px
* is comfortably above the WCAG / iOS minimum touch target (44pt /
* ~44px logical px) for the small canvas sizes typical on mobile;
* users with thumb-sized text targets can still hit the pencil
* without colliding with the text itself.
*
* Coordinate plumbing: stage's outer CSS scale is applied to the
* node's internal getClientRect to get a page-pixel rect. We listen
* for window resize and scroll so the button stays glued when the
* user pans the page or resizes.
*
* Tap handler fires `onStartEdit({ id, rect })` — App.jsx's
* handleStartTextEdit — which sets editingTextElementId, flips the
* sidebar's active tab to Text, and opens the bottom sheet.
*/
export function TextEditAffordance({
element,
stageContainerRef,
onStartEdit,
}) {
// Position state. null until we've measured the node at least once;
// hidden until then so the button doesn't flash at (0, 0) on first
// render before the layout effect runs. We also reset to null when
// the element id changes so the pencil doesn't briefly linger at the
// PREVIOUS element's position while waiting for the next measure to
// resolve — see the elementRef sync below for why that mattered.
const [pos, setPos] = useState(null);
// Ref to the latest element so the rAF-throttled measure (called from
// window resize/scroll listeners) can read it without re-binding on
// every render — same pattern as DesignCanvas's snapEnabledRef.
//
// IMPORTANT — sync in render body, not in a useEffect.
// ─────────────────────────────────────────────────────────────────────────────
// Previously this was `useEffect(() => { elementRef.current = element; }, [element])`,
// which runs AFTER the layout effect below. So on an element-prop
// change (user clicks a different text element), the sequence was:
//
// 1. Render with new `element` prop.
// 2. useLayoutEffect fires → calls measure().
// 3. measure() reads elementRef.current → STILL the OLD element.
// 4. measure() looks up the OLD node, sets `pos` to its rect.
// 5. useEffect fires → finally updates elementRef.current to new
// element. But measure() has already run with stale data.
//
// Result: the pencil stayed glued to the previously-selected text
// until something else (resize, scroll, prop unrelated to selection)
// forced a re-measure.
//
// Fix: assign during render. By the time useLayoutEffect runs,
// elementRef.current already points at the new element. Assignment
// to a ref's `.current` during render is safe — it doesn't trigger
// re-renders, and React's StrictMode double-invocation is also
// tolerant (the assignment is idempotent).
const elementRef = useRef(element);
elementRef.current = element;
// Track the previous element id so we can clear the visible position
// when the selection changes. Without this, the old pos lingered
// visibly for one paint while the layout effect's measure() ran —
// measure() does its work synchronously, but if the new node hasn't
// mounted yet (rare but possible during a tab switch + selection
// change in the same batch), the bail-out path leaves pos at its
// last value. Forcing null on id change makes the pencil disappear
// until the new position is known, which is less visually startling
// than seeing it pop from one element to another.
const prevIdRef = useRef(element?.id);
if (prevIdRef.current !== element?.id) {
prevIdRef.current = element?.id;
if (pos !== null) {
// Schedule a clear; using setPos inside render is illegal, so
// gate via a microtask. The layout effect below will re-measure
// and set the new position on the same render tick, so this
// clear is only visible if measure() fails to resolve a node.
// Using queueMicrotask rather than setPos directly avoids the
// "Cannot update during render" warning while keeping the
// clear in the same event loop tick.
queueMicrotask(() => {
// Re-check: another render may have already populated pos
// for the new element before this microtask runs.
if (prevIdRef.current === element?.id) {
setPos((curr) => (curr && curr._forId !== element?.id ? null : curr));
}
});
}
}
// Measure: find the Konva node by `name` attr (= element id), compute
// its page-pixel rect, and update `pos`. Bails to null when the
// element is gone or the stage isn't ready.
const measure = () => {
const el = elementRef.current;
// Read the container fresh each measure. The ref is set by
// DesignCanvas's Stage ref callback on mount, which fires AFTER
// App's first render — so on the very first paint stageContainerRef
// may still be null. The window-level event listeners + the
// element-prop-driven effect both eventually re-call measure once
// it's populated, so we just bail until then.
const container = stageContainerRef?.current ?? null;
if (!el || !container) {
setPos((prev) => (prev === null ? prev : null));
return;
}
// `container` is the Konva Stage's outer DOM element. Konva attaches
// the underlying Stage instance via the `_konva` field on the
// container's children — but the safer cross-version path is the
// public `Konva.Stage.container()` reverse: we walk down to find a
// canvas, then read its parent's `__konvaNode` (set on creation).
// Instead of relying on private fields, we look up the Stage via
// the `Konva.stages` registry: it's documented and stable.
let stage = null;
// Konva exposes a static `Konva.stages` array of every Stage
// currently mounted on the page. Walking it is O(stages) — in a
// single-canvas app there's exactly one Stage, so this is
// O(1) in practice. The fallback `_konvaNode` backref on the
// canvas DOM element handles versions where the stages array
// shape changes.
if (Konva && Array.isArray(Konva.stages)) {
for (const s of Konva.stages) {
try { if (s.container() === container) { stage = s; break; } } catch { /* ignore */ }
}
}
if (!stage) {
// Fallback: the parent's first canvas child has a `_konvaNode`
// backref on most versions. Try that before giving up.
const canvas = container.querySelector('canvas');
stage = canvas?._konvaNode?.getStage?.() ?? null;
}
if (!stage) {
setPos((prev) => (prev === null ? prev : null));
return;
}
const node = stage.findOne('.' + el.id);
if (!node) {
setPos((prev) => (prev === null ? prev : null));
return;
}
const rect = getNodePageRect(node);
if (!rect) {
setPos((prev) => (prev === null ? prev : null));
return;
}
// Tag the rect with the element id it was measured for. The
// render-body element-id-change guard above uses this so a stale
// microtask-scheduled clear can't wipe a position that's already
// been recomputed for the new element.
rect._forId = el.id;
setPos(rect);
};
// Re-measure whenever the selected element's identity OR any prop
// that affects its rendered bbox changes — fontSize, text, fontFamily,
// x, y, rotation, arc. The dep list intentionally lists each one
// rather than the whole element object because Konva nodes mutate
// their attrs in place during transforms; the wrapping element prop
// is recreated by useDesignEditor on every update, so depending on
// it directly would also work, but enumerating gives a clearer
// signal of what actually drives the re-measure.
useLayoutEffect(() => {
measure();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
element?.id,
element?.x, element?.y,
element?.text, element?.fontFamily, element?.fontSize,
element?.rotation, element?.arc,
element?.flipX, element?.flipY,
stageContainerRef,
]);
// Window-level events that can move the stage on screen without
// changing element props. Re-measure rAF-throttled so a fast scroll
// or resize doesn't drop hundreds of setState calls into React.
useEffect(() => {
let raf = null;
const onUpdate = () => {
if (raf !== null) return;
raf = requestAnimationFrame(() => {
raf = null;
measure();
});
};
window.addEventListener('resize', onUpdate);
window.addEventListener('scroll', onUpdate, true); // capture: catch nested scroll containers too
return () => {
if (raf !== null) cancelAnimationFrame(raf);
window.removeEventListener('resize', onUpdate);
window.removeEventListener('scroll', onUpdate, true);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (!element || element.type !== 'text' || !pos) return null;
// Anchor: just outside the top-right corner of the bbox, offset
// slightly into the empty space so the button doesn't overlap the
// selection border. 40px button sized for comfortable mobile
// tapping (above the ~44pt iOS touch-target minimum once CSS
// pixel-density scaling is considered), 6px gap from the right
// edge so the button doesn't visually crowd the bbox border.
const BTN = 40;
const GAP = 6;
const left = pos.left + pos.width + GAP;
const top = pos.top - GAP;
const handleClick = (e) => {
e.stopPropagation();
onStartEdit?.({ id: element.id, rect: pos });
};
// The button uses position: fixed so it tracks the viewport directly;
// any ancestor's transform or overflow won't affect placement.
const style = {
position: 'fixed',
left: `${left}px`,
top: `${top}px`,
width: `${BTN}px`,
height: `${BTN}px`,
border: '1.5px solid #ec4899',
background: '#fff',
color: '#ec4899',
borderRadius: '50%',
boxShadow: '0 2px 8px rgba(236, 72, 153, 0.32)',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
padding: 0,
// z-index sits between the FAB (700) and the mobile bottom
// sheet (800) so the pencil floats above the canvas chrome but
// is OCCLUDED by the bottom sheet when it opens. Previously 999
// — high enough to draw over the sheet, which meant when the
// user opened the "add item" sheet with a text selected, the
// pencil bled through the sheet's content area and looked like
// it belonged to whatever element it happened to overlap. The
// canvas itself has no z-index of its own (regular flow), so
// a value of 750 stays above all canvas-area chrome (zoom
// controls at z-index 10, mobile toolbar at 600, FAB at 700)
// while letting the sheet (800) cover it.
zIndex: 750,
};
return createPortal(
<button
type="button"
style={style}
onMouseDown={(e) => e.stopPropagation()}
onClick={handleClick}
aria-label="Edit text"
title="Edit text"
>
{/* Pencil glyph — same path as the mobile-toolbar Edit text
button so the two surfaces agree visually. Sized to fill
~half the button diameter (20px in a 40px button) so the
icon reads clearly at touch-target distances. */}
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M12 20h9" />
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
</svg>
</button>,
document.body,
);
}

View File

@@ -1,557 +0,0 @@
import { useEffect, useRef, memo } from 'react';
import { Group, Text, TextPath, Rect } from 'react-konva';
import { measureTextMetrics } from '../../utils/textMetrics';
import { computeTextVisibleBbox } from '../../utils/textGeometry';
import { useFontsReady } from '../../hooks/useFontsReady';
import { LINE_HEIGHT_RATIO, MIN_FONT_SIZE, DEFAULT_BODY_FONT } from '../../constants/textMetrics';
import { unflipReportedXY } from '../../utils/flipOffset';
// Per-element Transformer rendering was moved to DesignCanvas's dedicated
// transformer layer (see DesignCanvas.jsx). This component now renders
// ONLY the text node + its out-of-bounds warning; selection chrome lives
// one layer above. Imports for Transformer / rotation snap / corner
// anchors / transformer visual style are dropped along with the JSX
// that consumed them.
//
// Outline-on-silhouette rendering
// ───────────────────────────────
// Konva's default render order for Text / TextPath is fill-then-stroke,
// which paints each glyph's stroke ON TOP of the fill. When characters
// overlap (script faces with tight kerning — Pacifico is the obvious
// example in our palette, but Caveat does it too), each character's
// stroke draws across the previous character's fill, leaving visible
// outline lines slicing through the interior of the word.
//
// Fix is render-order based: stroke first at 2× the user's configured
// width, fill on top. The fill then covers (a) the inner half of the
// stroke and (b) any portion of any glyph's stroke that's inside the
// union of glyph fills. Net visible outline = the outer silhouette of
// the whole word.
//
// Two routes implement this depending on whether the text is arc'd:
//
// • Flat (arc = 0): one Konva.Text node with `fillAfterStrokeEnabled`.
// Konva.Text strokes/fills a whole line in ONE strokeText/fillText
// call, so the trick lands natively on a single node.
//
// • Arc'd (arc ≠ 0): two TextPath nodes, stroke-only below + fill-only
// above. Konva.TextPath draws glyphs ONE AT A TIME with per-glyph
// transforms along the path — the trick can't span across glyphs
// in one node, so we split the work into two nodes. The fill node's
// fill operations cover (a) and (b) above for every glyph.
//
// Either way, the user's configured `strokeWidth` becomes the visible
// outer half of the stroke; the actual stroke passed to Konva is 2×
// that value (the inner half gets eaten by the fill).
//
// Live transforms must keep stroke + fill in lockstep, so both children
// live inside a Konva.Group that owns the element's transform attrs.
// The Transformer in DesignCanvas attaches to the Group via
// `findOne('.' + id)`; Group-level scale/rotation propagates to all
// children, so the stroke and fill stay aligned every frame.
/**
* Konva Text wrapper.
*
* Mirrors ImageElement's flip / opacity handling. See ImageElement.jsx for
* the rationale on offset-then-scale to keep the flip in-place. Text-resize
* scales the font-size rather than width/height (text doesn't have an
* intrinsic box; Konva computes one from the rendered glyphs).
*
* ── Arc ──────────────────────────────────────────────────────────────────
* Text can be curved along an arc using Konva's `TextPath`. The `arc` prop
* is a number in [-100, 100]:
*
* arc = 0 → flat text (renders as <Text>)
* arc > 0 → upward arc (text reads on the top of the curve, rainbow-style)
* arc < 0 → downward arc (text reads on the bottom, smile-shape)
* |arc| = 100 → tightly curved (semicircle-like)
*
* Path geometry
* ─────────────
* The path is a quadratic Bézier (cheap, visually indistinguishable from a
* true circular arc at typical curvature values). It starts at (0, baseline)
* and ends at (W, baseline) with a control point at (W/2, controlY) pulling
* the curve up or down.
*
* W is the *actual* rendered text width (via canvas `measureText`), not a
* geometric estimate. This matters for two reasons:
*
* 1. If the path is shorter than the text, Konva silently clips trailing
* characters. A wide-glyph font like a serif overflows the
* `fontSize × length × 0.55` estimate that previous versions used.
*
* 2. If the path is longer than the text, the bulge peak (at W/2) sits
* past the visual midpoint of the text. A condensed face like
* Bebas Neue under-flows the geometric estimate, so the arc looked
* lopsided.
*
* We also pass `align="center"` to the TextPath so any small measurement
* residual is absorbed evenly at both ends rather than only the right.
*/
export const TextElement = memo(function TextElement({
id: _id,
x = 0,
y = 0,
text = '',
fontSize = 24,
fontFamily = DEFAULT_BODY_FONT,
fill = '#0f172a',
stroke = null,
strokeWidth = 0,
rotation = 0,
opacity = 1,
flipX = false,
flipY = false,
arc = 0,
locked = false,
isSelected: _isSelected,
// Change 3 — see ImageElement.jsx for the rationale. For text we
// use the visible-ink bbox computed below (the same one fed to
// canvasDragBound for snap centering) rather than Konva.Text's
// line-height bbox so the warning rect hugs the actual glyphs.
isOutOfBounds = false,
shiftHeld: _shiftHeld = false,
onSelect,
onUpdate,
onCommit,
// Change 7 — the on-canvas double-click handler that used to fire
// this is gone. Text editing is now triggered exclusively via the
// pencil affordance (desktop, rendered by TextEditAffordance) or the
// mobile Edit-text toolbar button. We keep the prop here for the
// affordance components to call through, but TextElement itself
// doesn't fire it on double-click anymore.
onStartEdit,
dragBoundFunc,
transformBoundFunc: _transformBoundFunc,
}) {
// groupRef — the Konva.Group that wraps the text nodes is now the
// "element" node from the rest of the system's perspective:
// • carries the element's `name` (= id) for findOne lookups,
// • carries the draggable + dragBoundFunc + event handlers,
// • carries the _pawSnapBbox* custom attrs the snap math reads,
// • is what the DesignCanvas Transformer attaches to.
//
// The Text / TextPath children inside it are positioned in Group-
// local coordinates (no x/y/offset/scale/rotation of their own),
// so Group-level transforms propagate uniformly. There's no longer
// a separate `textRef` — nothing outside this component needs to
// address the inner Text node specifically.
const groupRef = useRef(null);
// Subscribe to font-loading state. Calling the hook in the render body
// means this component re-renders once `document.fonts.ready` resolves,
// which causes `measureTextWidth` below to retry with the now-loaded
// font and produce an accurate width. Without this, the first render
// can use fallback-font metrics and the arc path / centering math is
// off until the next unrelated update happens to come along.
useFontsReady();
// Real rendered text width plus glyph ascent/descent. Pixel-perfect for
// the current font; falls back to coarse geometric estimates only when
// DOM is unavailable. We still call measureTextMetrics here for the
// whole-string width (used to size the path and Konva.Text bbox), but
// the visible-ink envelope below comes from `computeTextVisibleBbox`
// which does per-character measurement for arc'd text — see
// textGeometry.js docblock for the rationale.
const metrics = measureTextMetrics(text, fontSize, fontFamily);
const measuredW = metrics.width;
// Cap measured width at canvas size to keep approximate bounding box
// sensible even with very long strings.
const approxW = Math.max(fontSize, measuredW);
const approxH = fontSize * LINE_HEIGHT_RATIO;
// Always-center offset for rotation + flip-in-place. See the
// matching docblock in ImageElement.jsx for the full rationale; the
// short version: rotating around the local origin (= offset point)
// gives center-pivot rotation when offset is at the bbox center,
// which is what users expect from a rotate handle and avoids the
// "swing on first touch" bug that the old top-left-pivot caused on
// mobile. Flip-in-place falls out of the same center-aligned offset
// for free.
//
// For text the "bbox" is the line-height envelope (approxW, approxH)
// rather than the tighter visible-ink envelope — we want rotation to
// pivot around the GEOMETRIC center of the text node, not the
// visible-ink center, because the geometric center is what the user
// sees the rotate handle attach to via the Transformer (which reads
// getSelfRect, overridden below to the visible-ink bbox — close to
// but not exactly identical to the line-height envelope; the small
// difference is unnoticeable for rotation feel).
const offsetX = approxW / 2;
const offsetY = approxH / 2;
const renderX = x + offsetX;
const renderY = y + offsetY;
// Arc path — only computed when a non-zero arc is requested. The path is
// expressed in the text node's local coordinate system: it starts at
// (0, baselineY) and ends at (approxW, baselineY), with a quadratic
// control point pulling the curve up (arc > 0) or down (arc < 0).
const arcAbs = Math.min(100, Math.abs(arc));
const isArced = arcAbs > 0.5;
const baselineY = approxH * 0.85;
// bulgeMag scales with font size so larger text gets proportionally bigger
// curvature.
const bulgeMag = (arcAbs / 100) * approxH * 1.4;
const controlY = arc > 0 ? baselineY - bulgeMag : baselineY + bulgeMag;
// Tiny overshoot on the path (2%) plus center alignment so a couple of
// pixels of measurement residual don't clip the last glyph.
const pathW = approxW * 1.02;
const arcPath = isArced
? `M 0 ${baselineY} Q ${pathW / 2} ${controlY} ${pathW} ${baselineY}`
: null;
// Visible-ink bbox in node-local coords (Change 23). Computed by
// `computeTextVisibleBbox` in textGeometry.js — see that function's
// docblock for the full geometry derivation. Critically, for arc'd
// text it does PER-CHARACTER measurement along the curve: each
// character's actual ascent/descent is applied at that character's
// baseline-along-the-path, so a string like "Queen" (cap Q at the
// arc endpoint, lowercase 'ee' at the peak) gets a tight bbox at
// the peak rather than the cap-height envelope we used before.
//
// The same function feeds elementBounds.js's OOB check, so the
// canvas's yellow OOB rect (rendered below), the Transformer rect,
// the snap-bbox custom attrs, and the App-level out-of-bounds
// warning all share one calculation. No drift between visual and
// logical bounds.
//
// These values are in the text node's UN-OFFSET local coords —
// origin at the top-left of the line-height envelope, so a typical
// capital letter sits at roughly (0, ascent..approxH-descent). The
// OOB Rect rendered below consumes them directly because the Rect
// is positioned inside the Group at Group-local (0, 0) and
// therefore shares the un-offset frame.
//
// For the dragBoundFunc on the Group, we need PIVOT-RELATIVE coords
// (relative to the offset point, which IS the rotation pivot — see
// the offsetX/Y docblock above). The conversion is just
// `visibleBbox± - offset`, performed below where the snap attrs
// are set on the Group.
//
// Fallback to the old line-height envelope only when the helper
// returns null (empty text). Keeps existing positioning math stable
// before any text has been typed.
const _bbox = computeTextVisibleBbox({ text, fontSize, fontFamily, arc });
const visibleBboxMinX = _bbox ? _bbox.minX : 0;
const visibleBboxMaxX = _bbox ? _bbox.maxX : approxW;
const visibleBboxMinY = _bbox ? _bbox.minY : 0;
const visibleBboxMaxY = _bbox ? _bbox.maxY : approxH;
// Pivot-relative versions of the visible bbox, for the dragBoundFunc
// on the Group. canvasDragBound rotates corners around the local
// origin (= offset point = bbox center). The visible-ink bbox above
// is in un-offset coords; subtracting (offsetX, offsetY) translates
// into the post-offset frame the rotation math expects.
//
// Without this translation, the drag clamp would treat the un-offset
// top-left as the pivot — the same bug that ImageElement had before
// its offset became unconditional. The OOB Rect below still consumes
// the un-offset visibleBbox values because Rect inherits Group's
// offset transform; the snap attrs are the only consumer that needs
// pivot-relative coords.
const snapBboxMinX = visibleBboxMinX - offsetX;
const snapBboxMinY = visibleBboxMinY - offsetY;
const snapBboxMaxX = visibleBboxMaxX - offsetX;
const snapBboxMaxY = visibleBboxMaxY - offsetY;
// Override the Konva node's getSelfRect with our visible-ink bbox.
// Re-applied whenever the bbox-affecting attrs change so the
// DesignCanvas-level Transformer (which lives in a separate layer and
// attaches via `findOne('.' + id)`) sees the right rect when it calls
// `tr.forceUpdate()` on element changes.
//
// The override now lives on the GROUP, not the inner text node.
// Konva.Group.getSelfRect by default returns the union of its
// children's getSelfRects — for us that would be either Konva.Text's
// line-height box (loose at the top) or TextPath's baseline-padded
// approximation. Overriding directly gives the Transformer (which
// reads getSelfRect from the attached node — the Group) the
// pixel-accurate visible-ink bbox that the rest of the system
// (snap math, OOB check, App-level bounds warning) also uses.
//
// Three concerns the override addresses:
//
// 1. arc=0 ↔ arc≠0 toggles swap the children between <Text> and
// <TextPath>. The Group instance is unchanged — the override
// stays attached — but the children's bboxes change shape, so
// the override needs to reflect the new visibleBbox values.
// The deps array catches this.
//
// 2. Stroke vs no-stroke toggles change the Group's child set
// (one node vs two for arc'd text). Same Group instance, but
// its native getSelfRect would change — override still wins.
//
// 3. Konva's defaults for Text / TextPath bboxes are loose
// approximations of actual rendered glyph extent. Our visible-
// ink bbox (from computeTextVisibleBbox) is what the canvas
// drag-snap math centers against (via the _pawSnapBbox*
// custom attrs), so overriding here keeps the Transformer
// rectangle aligned with the snap target.
useEffect(() => {
const node = groupRef.current;
if (!node) return;
node.getSelfRect = function () {
return {
x: visibleBboxMinX,
y: visibleBboxMinY,
width: visibleBboxMaxX - visibleBboxMinX,
height: visibleBboxMaxY - visibleBboxMinY,
};
};
}, [
arc,
visibleBboxMinX,
visibleBboxMinY,
visibleBboxMaxX,
visibleBboxMaxY,
]);
// Outline state. The Konva strokeWidth we actually pass to the glyph
// node(s) is DOUBLED because fillAfterStrokeEnabled (and the parallel
// dual-TextPath path for arc'd text) makes only the OUTER half of
// the stroke visible — the inner half gets covered by the fill. So
// the user's strokeWidth setting (which they perceive as the visible
// outline thickness) maps to 2× the Konva strokeWidth.
const hasOutline = Boolean(stroke) && strokeWidth > 0;
const konvaStrokeWidth = hasOutline ? strokeWidth * 2 : 0;
// Group-level transform + interaction attrs. Applied to the
// Konva.Group that wraps the text nodes; children render in Group-
// local coords so Group-level scale/rotation/flip propagates uniformly
// (this is what keeps the stroke and fill TextPath nodes locked
// together during live transforms in the arc'd outlined case).
const groupProps = {
ref: groupRef,
/* Konva `name` attr = element id. Used by DesignCanvas's findOne
* lookups for marquee, single-Transformer, and multi-Transformer
* attachment. See ImageElement.jsx for full rationale. */
name: _id,
x: renderX,
y: renderY,
offsetX,
offsetY,
scaleX: flipX ? -1 : 1,
scaleY: flipY ? -1 : 1,
rotation,
opacity,
draggable: !locked,
dragBoundFunc,
// Custom Konva attrs that DesignCanvas's canvasDragBound reads
// directly via this.attrs.*, encoding the *visible-ink* bbox of
// the rendered text in PIVOT-RELATIVE node-local coords (i.e.
// relative to the offset point, which is the rotation pivot).
// See the docblock in canvasDragBound for the corner-math
// rationale, and the snapBbox* declarations above for why the
// values are pre-shifted by (-offsetX, -offsetY) here. These
// live on the Group (the draggable node) rather than the inner
// text node because Group is what Konva calls `this` in
// dragBoundFunc.
_pawSnapBboxMinX: snapBboxMinX,
_pawSnapBboxMinY: snapBboxMinY,
_pawSnapBboxMaxX: snapBboxMaxX,
_pawSnapBboxMaxY: snapBboxMaxY,
onClick: (e) => { e.cancelBubble = true; onSelect?.(); },
onTap: (e) => { e.cancelBubble = true; onSelect?.(); },
// Bug 6 fix — select on press, not just on click-up. Konva fires
// `click` on the mouseup completing a press-release pair WITHOUT
// intervening drag motion; a press that becomes a drag (the user
// grabs an element and immediately starts moving) never produces
// a click event, so the element wasn't getting selected and the
// DesignCanvas-level Transformer (which attaches to the selected
// node) never mounted handles around it. Selecting on mousedown
// / touchstart ensures the selection state is updated BEFORE
// Konva's drag machinery starts, so the Transformer is attached
// by the next render and its handles appear immediately around
// the dragging element.
//
// 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 anything.
onMouseDown: (e) => { e.cancelBubble = true; onSelect?.(); },
onTouchStart: (e) => { e.cancelBubble = true; onSelect?.(); },
onDragEnd: (e) => {
// e.target is the Group (the draggable node). Same shape as
// before — unflipReportedXY just needs an object with x()/y()
// accessors, which Group has.
const { x: reportedX, y: reportedY } = unflipReportedXY(e.target, approxW, approxH, flipX, flipY);
onUpdate({ x: reportedX, y: reportedY });
onCommit?.();
},
onTransformEnd: () => {
// The Transformer attaches to the Group, so transform fires on
// the Group. We bake the live scale into a new fontSize and
// reset the Group's scale to ±1 (preserving flip state); the
// children inherit the reset on re-render.
const node = groupRef.current;
if (!node) return;
const sx = node.scaleX();
// Read fontSize from the prop (not from the node) — Group has
// no `fontSize` attr; that lives on the inner Text/TextPath.
// The prop is the same value react-konva pushed down to the
// child, so this is equivalent to the old node.fontSize() read.
const newFontSize = Math.max(MIN_FONT_SIZE, Math.round(fontSize * Math.abs(sx)));
node.scaleX(flipX ? -1 : 1);
node.scaleY(flipY ? -1 : 1);
// Anchor-preserving commit math.
//
// During the transform Konva scales the Group via scaleX/Y while
// keeping the un-grabbed corner of the Transformer rect anchored
// in stage coords. With center offset (offsetX = approxW/2,
// offsetY = approxH/2), the rendered line-height envelope
// top-left lands at:
//
// node.x() - offsetX * scaleX, node.y() - offsetY * scaleY
//
// That's the position we want element.x/y to reproduce on the
// very next render. Since we're about to commit a new fontSize
// (which will produce new approxW/H of approximately
// approxW*sx / approxH*sx on re-render under linear-in-fontSize
// scaling), we need element.x/y to equal that scaled-offset
// top-left. The naive `unflipReportedXY(node, approxW, approxH,
// ...)` call used the OLD (unscaled) offsets and produced an
// element.x/y that didn't match the visual position the user
// saw at release — the line-height envelope would visibly jump
// by approxW*(sx-1)/2 toward the bottom-right after the user
// lets go. Multiplying by Math.abs(sx) here gives the
// scaled-offset top-left, which the next render reconstructs
// exactly (re-renders the node at that line-height envelope
// origin with the new larger fontSize and matching new offset).
//
// Image elements get this right naturally because their
// onTransformEnd computes `newWidth = node.width() *
// Math.abs(sx)` first and feeds THAT into unflipReportedXY —
// so the helper's W/2 subtraction is already the scaled half-
// width. Text doesn't have a node.width() it controls (Konva
// measures the glyphs internally and approxW is the React-
// render-time measurement), so the equivalent here is to scale
// approxW/H by sx at the call site.
//
// Using sx for both axes — the Transformer has keepRatio set
// in DesignCanvas, so sy ≈ sx every frame, and the post-resize
// fontSize is single-axis (a single scalar that drives both
// measured width AND line-height-derived height). Decoupling
// here would just introduce sub-pixel disagreement between
// axes for no benefit.
const scaledApproxW = approxW * Math.abs(sx);
const scaledApproxH = approxH * Math.abs(sx);
const { x: reportedX, y: reportedY } = unflipReportedXY(node, scaledApproxW, scaledApproxH, flipX, flipY);
onUpdate({
x: reportedX,
y: reportedY,
fontSize: newFontSize,
rotation: node.rotation(),
});
onCommit?.();
},
};
// Glyph-render attrs that go on the inner Text / TextPath children.
// Position attrs are NOT here — those live on the Group above so its
// transform applies uniformly. Children render at Group-local (0, 0).
const sharedGlyphProps = {
fontSize,
fontFamily,
};
return (
<Group {...groupProps}>
{/* Out-of-bounds warning background (Change 3). Sits BEHIND the
text within the Group, in Group-local coords — the Group's
own transform applies so the highlight tracks the text
through every transform. Position is the visible-ink bbox
origin (which may be non-zero for text with baseline offset
or arc bulge extending above y=0). */}
{isOutOfBounds && (
<Rect
x={visibleBboxMinX}
y={visibleBboxMinY}
width={visibleBboxMaxX - visibleBboxMinX}
height={visibleBboxMaxY - visibleBboxMinY}
fill="#fde68a"
stroke="#f59e0b"
strokeWidth={1.5}
opacity={0.65}
listening={false}
perfectDrawEnabled={false}
/>
)}
{/* Stroke-only layer for ARC'D outlined text. Renders first so
it's visually behind the fill layer. listening=false so the
inflated stroke hit area doesn't shadow the fill node's
(rectangular bbox) hit area — clicks still register on the
fill text and bubble up to the Group.
Flat outlined text uses fillAfterStrokeEnabled on a single
Text node instead (see below), since Konva.Text strokes/fills
a whole line in one shot — no second node needed. */}
{isArced && hasOutline && (
<TextPath
{...sharedGlyphProps}
data={arcPath}
text={text}
align="center"
fill={undefined}
stroke={stroke}
strokeWidth={konvaStrokeWidth}
listening={false}
/>
)}
{/* Fill layer (always rendered). For flat text this is the only
glyph node; outline (when present) rides along on this same
node via fillAfterStrokeEnabled. For arc'd text this is the
top layer; the stroke-only TextPath above provides the
outline silhouette. */}
{isArced ? (
<TextPath
{...sharedGlyphProps}
data={arcPath}
text={text}
align="center"
fill={fill}
// No stroke on the fill layer the outline (when present)
// is drawn by the separate stroke-only TextPath above this
// one, and this layer's job is to cover the inner half of
// that stroke + any of it that's inside the union of glyph
// fills. Default stroke = undefined; strokeWidth omitted.
/>
) : (
<Text
{...sharedGlyphProps}
text={text}
fill={fill}
stroke={hasOutline ? stroke : undefined}
strokeWidth={konvaStrokeWidth}
// fillAfterStrokeEnabled reverses Konva.Text's default fill-
// then-stroke render order. Combined with a 2× strokeWidth
// (so the visible OUTER half matches the user's configured
// strokeWidth), the fill covers (a) the inner half of the
// stroke and (b) any portion of the stroke that's inside
// the union of all glyph fills in this line. Net visible
// outline = the outer silhouette of the whole word, not
// individual glyphs. Solves the overlap-outlines-cutting-
// through-glyphs problem for script fonts like Pacifico and
// Caveat. No-op when fill or stroke is absent.
fillAfterStrokeEnabled
// Explicit width + height + wrap="none". See the long
// docblock at the top of the file for why these three props
// are interdependent in summary, width+height fix the
// snap-to-center bbox getter (which otherwise reads ~0
// before textArr populates), and wrap="none" prevents
// sub-pixel measurement disagreements between us and Konva
// from triggering a word-wrap after a resize.
width={approxW}
height={approxH}
wrap="none"
/>
)}
{/* Per-element Transformer removed (bug 8 fix). Selection chrome
now lives in DesignCanvas's dedicated transformer layer,
which renders ABOVE the elements layer so resize / rotate
handles always draw on top of every element regardless of
z-order. */}
</Group>
);
});

View File

@@ -1,100 +0,0 @@
import '../../styles/ZoomControls.css';
/**
* Inert zoom-percentage display + ± buttons plus the global snap-to-center
* toggle. The Konva canvas is rendered at a fixed 300×300 design-pixel
* resolution; "zoom" here scales the wrapper via CSS transform so the user
* can get a closer or farther view without changing the underlying
* coordinate system. That keeps the export math identical and keeps
* drag/rotate hot-paths untouched.
*
* The snap toggle lives here (Change 5 — see [[Refinements_2026-05-20_Part2]])
* because snap-to-center is a GLOBAL editor preference — enabling /
* disabling it from a per-element panel sent the wrong signal that it
* was a per-element setting. The zoom-controls toolbar is the natural
* home for "editor view" controls that apply to the whole canvas
* regardless of what's selected.
*/
export function ZoomControls({
zoom,
onZoomIn,
onZoomOut,
min = 0.5,
max = 2,
// Snap-to-center toggle. Optional; when omitted the toggle button
// just doesn't render. snapEnabled defaults to true so we match the
// app-level default (snap on) when callers haven't wired it up yet.
snapEnabled = true,
onToggleSnap,
}) {
return (
<div className="zoom-controls" role="toolbar" aria-label="Canvas view">
<button
type="button"
className="zoom-controls__btn"
onClick={onZoomOut}
disabled={zoom <= min + 0.001}
aria-label="Zoom out"
>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round">
<path d="M5 12h14" />
</svg>
</button>
<span className="zoom-controls__value" aria-live="polite">{Math.round(zoom * 100)}%</span>
<button
type="button"
className="zoom-controls__btn"
onClick={onZoomIn}
disabled={zoom >= max - 0.001}
aria-label="Zoom in"
>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round">
<path d="M12 5v14" />
<path d="M5 12h14" />
</svg>
</button>
{/* Snap-to-center toggle (Change 5). Separated from the zoom
buttons by a thin divider so the user reads them as two
distinct affordances on the same toolbar. The button is
marked active when snap is ON (the default), so toggling it
once visibly turns it off — matching the user's mental model
of "disable this feature". */}
{onToggleSnap && (
<>
<span className="zoom-controls__divider" aria-hidden="true" />
<button
type="button"
className={`zoom-controls__btn zoom-controls__btn--snap${snapEnabled ? ' is-active' : ''}`}
onClick={onToggleSnap}
aria-label={snapEnabled ? 'Turn off snap to center' : 'Turn on snap to center'}
aria-pressed={snapEnabled}
title={snapEnabled ? 'Snap to center: on' : 'Snap to center: off'}
>
{snapEnabled ? (
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
{/* Crosshair + dot — the classic "snap to alignment"
glyph. Center dot signals "this is the target". */}
<line x1="12" y1="2" x2="12" y2="6" />
<line x1="12" y1="18" x2="12" y2="22" />
<line x1="2" y1="12" x2="6" y2="12" />
<line x1="18" y1="12" x2="22" y2="12" />
<circle cx="12" cy="12" r="2" />
</svg>
) : (
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
{/* Same crosshair with a slash through it — the
universal "this feature is off" overlay. */}
<line x1="12" y1="2" x2="12" y2="6" />
<line x1="12" y1="18" x2="12" y2="22" />
<line x1="2" y1="12" x2="6" y2="12" />
<line x1="18" y1="12" x2="22" y2="12" />
<circle cx="12" cy="12" r="2" />
<line x1="4" y1="4" x2="20" y2="20" />
</svg>
)}
</button>
</>
)}
</div>
);
}

View File

@@ -1,12 +0,0 @@
export { DesignCanvas } from './DesignCanvas';
export { TShirtSVG } from './TShirtSVG';
export { ImageElement } from './ImageElement';
export { TextElement } from './TextElement';
export { TemplateLayer } from './TemplateLayer';
export { SlotPlaceholder, SlotBoundsGuide } from './SlotPlaceholder';
export { CanvasHint } from './CanvasHint';
export { ZoomControls } from './ZoomControls';
export { HistoryControls } from './HistoryControls';
export { ElementToolbar } from './ElementToolbar';
export { ModelSilhouette } from './ModelSilhouette';
export { BackgroundRemovalButton } from './BackgroundRemovalButton';

View File

@@ -1,116 +0,0 @@
import { useEffect, useRef } from 'react';
import FilerobotImageEditor, { TABS } from 'react-filerobot-image-editor';
import { StyleSheetManager } from 'styled-components';
import isPropValid from '@emotion/is-prop-valid';
import { useFocusTrap } from '../../hooks/useFocusTrap';
import '../../styles/PhotoPreEditor.css';
export function PhotoPreEditor({ imageSrc, onComplete, onClose }) {
const modalContentRef = useRef(null);
const previousFocusRef = useRef(null);
// Focus trap (S24) — keyboard navigation can't escape the photo
// editor while it's open. Filerobot itself manages focus inside the
// editor; the trap just prevents Tab from leaking back into the
// shell editor underneath.
useFocusTrap(true, modalContentRef);
useEffect(() => {
previousFocusRef.current = document.activeElement;
const handleKeyDown = (e) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
previousFocusRef.current?.focus();
};
}, [onClose]);
const base64ToBlob = (base64DataUrl) => {
const [header, data] = base64DataUrl.split(',');
const mimeMatch = header?.match(/data:(.*?);base64/);
const mime = mimeMatch?.[1] || 'image/png';
const binary = atob(data || '');
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return new Blob([bytes], { type: mime });
};
const handleSave = async (savedImageData) => {
try {
// Prefer base64 when available (works without CORS/network).
if (savedImageData?.imageBase64) {
const blob = base64ToBlob(savedImageData.imageBase64);
onComplete(URL.createObjectURL(blob));
return;
}
// Fallback to canvas when provided by the library.
if (savedImageData?.imageCanvas instanceof HTMLCanvasElement) {
const blob = await new Promise((resolve) =>
savedImageData.imageCanvas.toBlob(resolve, savedImageData.mimeType || 'image/png', savedImageData.quality),
);
if (blob) onComplete(URL.createObjectURL(blob));
else throw new Error('Canvas export failed');
return;
}
// Final fallback: cloudimageUrl (fetch then blob).
if (savedImageData?.cloudimageUrl) {
const res = await fetch(savedImageData.cloudimageUrl);
const blob = await res.blob();
onComplete(URL.createObjectURL(blob));
return;
}
throw new Error('No export data returned from image editor');
} catch (error) {
console.error('Export failed:', error);
onClose();
}
};
return (
<div className="filerobot-overlay" role="dialog" aria-modal="true" aria-labelledby="photo-editor-title">
<div className="filerobot-container" ref={modalContentRef} role="document">
<StyleSheetManager
// Filerobot/@scaleflex styled-components pass a bunch of styling props to DOM nodes (e.g. isPhoneScreen).
// Filtering them here prevents noisy React console warnings.
shouldForwardProp={(prop, element) => (typeof element === 'string' ? isPropValid(prop) : true)}
>
<FilerobotImageEditor
source={imageSrc}
onBeforeSave={() => false}
onSave={handleSave}
onClose={() => onClose()}
tabsIds={[TABS.ADJUST, TABS.FILTERS, TABS.FINETUNE]}
defaultTabId={TABS.ADJUST}
// Crop defaults:
// - ratio: 'original' selects the source image's native ratio so
// the initial crop covers the whole image rather than dropping a
// small "custom" rectangle in the middle.
// - autoResize: false the prior `autoResize: true` together with
// defaultSizePercentage:1 was being interpreted as "size to fit
// the canvas viewport", which on small previews shrank the crop
// to a sub-image region.
// - presetCropAreaPosition: 'cover' explicitly anchors the crop
// to cover the full image on first paint.
Crop={{
autoResize: false,
ratio: 'original',
presetCropAreaPosition: 'cover',
}}
theme={{ accentColor: '#ec4899', palettePrimary: '#ec4899' }}
forceToPngInEllipticalCrop
closeAfterSave
defaultSavedImageName="edited-image"
defaultSavedImageType="png"
defaultSavedImageQuality={1}
savingPixelRatio={4}
previewPixelRatio={4}
/>
</StyleSheetManager>
</div>
<h2 id="photo-editor-title" className="sr-only">Photo Editor</h2>
</div>
);
}

View File

@@ -1,123 +0,0 @@
import { useEffect, useRef } from 'react';
import { useFocusTrap } from '../../hooks/useFocusTrap';
import '../../styles/PreviewModal.css';
/**
* Print-preview modal.
*
* Shows the server-rendered export image in a fullscreen overlay so the
* user can verify the printable result before adding to cart. Distinct
* from the in-editor canvas because:
*
* • The editor canvas is design-coordinate (300×300) at viewport scale.
* The export is 4500×4500 @ 300 DPI.
* • Server-side rendering applies any text path / layer ordering /
* transparency that the editor's Konva pipeline simulates but doesn't
* necessarily render identically.
* • Some quirks (font fallback on the server, complex stroke handling)
* only manifest in the export pipeline.
*
* Showing the actual exported PNG closes the loop on "what will my
* shirt look like".
*
* Loading state
* ─────────────
* The modal can be shown before the export URL is ready — when the user
* triggers preview, we open the modal immediately with a spinner, then
* swap in the image once `imageUrl` arrives. Closing while loading is
* fine; the in-flight export still completes (and the URL ends up in
* exportUrl state for later use).
*/
export function PreviewModal({ open, imageUrl, loading, error, onClose, onDownload }) {
const panelRef = useRef(null);
// Focus trap (S24) — keyboard users can't tab past the modal back
// into the editor while it's open. Restores focus to the previously
// focused element on close.
useFocusTrap(open, panelRef);
// Lock body scroll while open. Mirrors MobileBottomSheet's pattern so
// the underlying editor doesn't pan when the user pinch-zooms the
// preview image.
useEffect(() => {
if (!open) return;
document.body.classList.add('has-preview-modal');
return () => document.body.classList.remove('has-preview-modal');
}, [open]);
// ESC to dismiss.
useEffect(() => {
if (!open) return;
const onKey = (e) => { if (e.key === 'Escape') onClose?.(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, onClose]);
if (!open) return null;
return (
<div className="preview-modal" role="dialog" aria-modal="true" aria-label="Print preview">
<button
type="button"
className="preview-modal__backdrop"
aria-label="Close preview"
onClick={onClose}
/>
<div className="preview-modal__panel" ref={panelRef}>
<div className="preview-modal__header">
<h2 className="preview-modal__title">Print preview</h2>
<button
type="button"
className="preview-modal__close"
onClick={onClose}
aria-label="Close"
></button>
</div>
<div className="preview-modal__body">
{loading && (
<div className="preview-modal__loading">
<div className="spinner-small" aria-hidden="true" />
<p>Rendering at print resolution</p>
</div>
)}
{!loading && error && (
<div className="preview-modal__error">
<p> Couldn't render preview: {error}</p>
</div>
)}
{!loading && !error && imageUrl && (
<img
src={imageUrl}
alt="Your design at print resolution"
className="preview-modal__image"
/>
)}
</div>
<div className="preview-modal__footer">
<p className="preview-modal__hint">
This is what will print on your shirt 4500×4500 at 300 DPI.
</p>
<div className="preview-modal__actions">
<button
type="button"
className="preview-modal__btn preview-modal__btn--ghost"
onClick={onClose}
>
Keep editing
</button>
<button
type="button"
className="preview-modal__btn preview-modal__btn--primary"
onClick={onDownload}
disabled={!imageUrl || loading}
>
Download PNG
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1 +0,0 @@
export { PhotoPreEditor } from './PhotoPreEditor';

View File

@@ -1,404 +0,0 @@
import { memo, useState } from 'react';
import { useCroppedThumbnail } from '../../hooks/useCroppedThumbnail';
import '../../styles/LayersPanel.css';
/**
* Layers panel.
*
* Lists all elements on the canvas in render order (last in array = topmost).
* Click selects, shift-click toggles a row's membership in a multi-selection
* set (S3) — when more than one is selected, the panel shows a "Delete N
* selected" affordance and bulk-deletes on click.
*
* Per-row controls (Change 4 in [[Refinements_2026-05-20_Part2]]):
* • To the LEFT of each row's main button: nothing (the previous ledger-style
* close-X delete affordance moved to the right side and became a proper
* trash-can icon).
* • To the RIGHT of each row's main button, as siblings (NOT nested inside
* the main button), in order: duplicate, lock/unlock, trash-can delete,
* drag grip. These three actions used to live in the floating
* ElementToolbar; co-locating them with the layer they target makes
* them discoverable from the same place the user reasons about layers,
* and makes them reachable without having to click the layer first.
*
* Drag handles on each row let the user reorder items via HTML5 DnD (S3).
* Reordering produces a single history entry (the parent's reorder callback
* uses replaceElements internally).
*
* Accessibility (S24):
* • Each row is a flex container holding multiple <button>s (main row
* selector + per-row actions), so keyboard users can Tab through each
* action independently.
* • Each action button has an explicit aria-label so screen readers
* announce "Duplicate Photo" rather than just "button".
* • Selection is indicated by aria-current="true" PLUS a visible
* check-glyph and a pink ring — not background color alone.
* Colorblind users can tell selected rows apart from unselected.
* • The drag handle is keyboard-skippable (tabindex=-1) since DnD is a
* pointer-only affordance.
*/
export const LayersPanel = memo(function LayersPanel({
elements,
selectedId,
selectedIds, // optional: Set<string> for multi-select (S3)
onSelect,
onToggleInSelection, // optional: shift-click toggle for S3
onDelete,
onDeleteMany, // optional: bulk delete for S3
onReorder, // optional: (sourceId, targetId, position: 'before' | 'after') for S3
// Per-row action callbacks (Change 4). Optional so existing call
// sites that haven't been updated continue to work — the controls
// just don't render when the callback is missing.
onDuplicate,
onUpdate, // (id, attrs) — used by the lock toggle to flip element.locked
}) {
// Icon slot — returns JSX rendered inside `.layers-item-icon`. We use
// real thumbnails for image/sticker layers (mini preview of the actual
// element bytes) so users can identify their content at a glance without
// having to read a generic glyph; text layers get a stylized 'T' icon
// matching the Text tab's nav icon.
//
// For images with a CROP applied, the thumbnail mirrors the cropped
// sub-region rather than the full original source — see <LayerThumb>
// below for the rationale and implementation. Without that, cropping
// to a tight region (e.g. a face) would leave the layers panel
// showing the full uncropped photo, making the cropped layer
// visually identical to the pre-crop layer and defeating the
// thumbnail's purpose of identifying which layer is which.
//
// For images with FILTERS or BG-REMOVAL applied: those are applied
// by Konva at render time over `element.src`. The thumbnail shows
// the cropped (if applicable) but unfiltered version. Mirroring the
// full filter pipeline on a 24px thumbnail would either lag the
// panel or require a separate render path. The canvas itself is
// the source of truth for what the user sees; the thumbnail is a
// "close enough" identifier.
const renderIcon = (el) => {
if (el.type === 'image' || el.type === 'sticker') {
// `el.src` should always be defined for these types — sticker assets
// and image uploads both set it on add. Guard anyway so a malformed
// saved state doesn't crash the panel.
if (!el.src) return <span className="layers-item-icon-fallback" aria-hidden="true">·</span>;
return <LayerThumb element={el} />;
}
if (el.type === 'text') {
// Match the Text-tab nav icon: capital T with serifs. Inlined SVG so
// it picks up `currentColor` from the row text color and inverts
// properly when the row is selected.
return (
<svg className="layers-item-icon-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M4 7V5h16v2" />
<path d="M9 5v14" />
<path d="M15 5v14" />
<path d="M7 19h4" />
<path d="M13 19h4" />
</svg>
);
}
return <span className="layers-item-icon-fallback" aria-hidden="true">·</span>;
};
const getName = (el) => {
if (el.type === 'text') return el.text || 'Text';
// Stickers all read as 'Sticker' regardless of whether they're an
// image sticker or a rasterized emoji glyph. The renderIcon path
// already shows the actual sticker thumbnail on the left of the
// row, so including the emoji character in the name (e.g.
// 'Sticker 😀') duplicated the same glyph on both sides of the row.
if (el.type === 'sticker') return 'Sticker';
// Photo layers all read as "Photo" regardless of bg-removal state.
// The bg-removed status is visible on the canvas and via the
// ElementToolbar; surfacing it in the layer name (e.g. "Image (BG ✓)")
// added noise to the row without helping the user pick out the layer.
if (el.type === 'image') return 'Photo';
return 'Element';
};
// Resolve the active selection set. If the parent passes `selectedIds`
// we use it (multi-select aware); otherwise fall back to a single-item
// set built from `selectedId` so the rendering path is uniform.
const activeSelectionSet = selectedIds instanceof Set
? selectedIds
: new Set(selectedId ? [selectedId] : []);
const selectedCount = activeSelectionSet.size;
// DnD state. The dragged row's id is in dragId; dragOverId tracks the
// current drop target so we can render an insertion line above or below
// it. We compute insertion position based on whether the cursor is in
// the upper or lower half of the row's bounding rect.
const [dragId, setDragId] = useState(null);
const [dropTarget, setDropTarget] = useState(null); // { id, position }
const handleDragStart = (e, id) => {
if (!onReorder) return;
setDragId(id);
e.dataTransfer.effectAllowed = 'move';
// Setting some data is required for Firefox to actually fire drag events.
try { e.dataTransfer.setData('text/plain', id); } catch { /* ignore */ }
};
const handleDragOver = (e, id) => {
if (!onReorder || !dragId || dragId === id) return;
e.preventDefault();
const rect = e.currentTarget.getBoundingClientRect();
const position = (e.clientY - rect.top) < rect.height / 2 ? 'before' : 'after';
setDropTarget((prev) =>
prev?.id === id && prev?.position === position ? prev : { id, position }
);
};
const handleDragLeave = (e, id) => {
// Only clear if we're actually leaving the row (not just entering a
// child element). currentTarget is the row; relatedTarget is where
// the pointer's heading. If relatedTarget is still inside the row,
// ignore the leave.
if (e.currentTarget.contains(e.relatedTarget)) return;
// `id` is passed in from the JSX rather than read out of the row's
// DOM via e.currentTarget.dataset — React's synthetic events aren't
// safe to read asynchronously, and by the time the setDropTarget
// updater below runs, e.currentTarget has been nulled out. Reading
// e.currentTarget.dataset.layerId there would throw "Cannot read
// properties of null (reading 'dataset')".
setDropTarget((prev) => (prev?.id === id ? null : prev));
};
const handleDrop = (e, targetId) => {
if (!onReorder || !dragId || dragId === targetId) {
setDragId(null);
setDropTarget(null);
return;
}
e.preventDefault();
// `dropTarget.position` is a PANEL position computed from the cursor's
// Y coordinate in handleDragOver:
// 'before' = cursor in upper half of row → visually above the row
// 'after' = cursor in lower half of row → visually below the row
//
// But `onReorder` (→ reorderElement in useDesignEditor) takes ARRAY
// positions, and the panel renders the elements array reversed
// (top-of-panel = last-in-array). So "above in panel" is "after in
// array" and vice-versa — we invert the position here, at the boundary
// between the two coordinate systems. Without this translation,
// dropping A above B in the panel asks reorderElement to put A
// immediately before B in the array, which keeps A below B in the
// panel (the opposite of what the user did) — or no-ops entirely
// when A was already before B, which is the common case and reads
// as "drag does nothing".
const panelPosition = dropTarget?.id === targetId ? dropTarget.position : 'after';
const arrayPosition = panelPosition === 'before' ? 'after' : 'before';
onReorder(dragId, targetId, arrayPosition);
setDragId(null);
setDropTarget(null);
};
const handleDragEnd = () => {
setDragId(null);
setDropTarget(null);
};
const handleRowClick = (e, id) => {
if (e.shiftKey && onToggleInSelection) {
onToggleInSelection(id);
} else {
onSelect(id);
}
};
if (elements.length === 0) {
return <div className="layers-empty">No elements yet. Add photos, text, or stickers to your design.</div>;
}
return (
<div>
<div className="layers-titlebar">
<h3 className="layers-title">Layers ({elements.length})</h3>
{selectedCount > 1 && onDeleteMany && (
<button
type="button"
className="layers-bulk-delete"
onClick={() => onDeleteMany([...activeSelectionSet])}
>
Delete {selectedCount} selected
</button>
)}
</div>
<ul className="layers-list" role="list">
{/* Render top-down (most recently added on top) — Konva renders
elements later in the array on top, but in a layers panel
users expect the topmost item at the top of the list. */}
{[...elements].reverse().map((element) => {
const isSelected = activeSelectionSet.has(element.id);
const isDragging = dragId === element.id;
const isDropBefore = dropTarget?.id === element.id && dropTarget.position === 'before';
const isDropAfter = dropTarget?.id === element.id && dropTarget.position === 'after';
return (
<li key={element.id} className="layers-row-wrap">
{isDropBefore && <div className="layers-drop-indicator" aria-hidden="true" />}
<div
className={`layers-item${isSelected ? ' selected' : ''}${isDragging ? ' is-dragging' : ''}`}
draggable={!!onReorder}
onDragStart={(e) => handleDragStart(e, element.id)}
onDragOver={(e) => handleDragOver(e, element.id)}
onDragLeave={(e) => handleDragLeave(e, element.id)}
onDrop={(e) => handleDrop(e, element.id)}
onDragEnd={handleDragEnd}
>
<button
type="button"
className="layers-item-main"
onClick={(e) => handleRowClick(e, element.id)}
aria-current={isSelected ? 'true' : undefined}
aria-label={`${isSelected ? 'Selected: ' : ''}${getName(element)}, ${element.type}${onToggleInSelection ? '. Shift-click to add to selection' : ''}`}
>
{/* Visible non-color selection indicator (S24): a small
check chip on the left of selected rows. Colorblind
users can identify selection without relying on the
pink background. */}
<span
className={`layers-item-check${isSelected ? ' is-on' : ''}`}
aria-hidden="true"
>
{isSelected ? '✓' : ''}
</span>
<span className="layers-item-icon" aria-hidden="true">{renderIcon(element)}</span>
<span className="layers-item-name">{getName(element)}</span>
</button>
{/* Per-row actions (Change 4). Siblings of the main row
button, NOT nested inside it — nesting <button>s is
invalid HTML and breaks keyboard / screen-reader
navigation. Each is its own tab stop with its own
aria-label so the user can keyboard through them. */}
{onDuplicate && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onDuplicate(element.id); }}
className="layers-item-action"
aria-label={`Duplicate ${getName(element)}`}
title="Duplicate"
>
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
{/* Two overlapping rounded squares — the standard
duplicate / copy glyph. Matches the icon used in
the previous ElementToolbar so the muscle memory
transfers. */}
<rect x="9" y="9" width="11" height="11" rx="2" />
<path d="M5 15V6a2 2 0 0 1 2-2h9" />
</svg>
</button>
)}
{onUpdate && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onUpdate(element.id, { locked: !element.locked }); }}
className={`layers-item-action${element.locked ? ' is-active' : ''}`}
aria-label={element.locked ? `Unlock ${getName(element)}` : `Lock ${getName(element)}`}
aria-pressed={!!element.locked}
title={element.locked ? 'Unlock' : 'Lock'}
>
{element.locked ? (
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
{/* Closed padlock — base rectangle + bowed shackle
entering both sides of the body. */}
<rect x="4" y="11" width="16" height="10" rx="2" />
<path d="M8 11V7a4 4 0 0 1 8 0v4" />
</svg>
) : (
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
{/* Open padlock — shackle disconnected from the
right side so the user reads it as "unlocked,
click to lock". */}
<rect x="4" y="11" width="16" height="10" rx="2" />
<path d="M8 11V7a4 4 0 0 1 7-3" />
</svg>
)}
</button>
)}
{/* Delete — now a proper trash-can glyph (Change 4).
Previously this was a bare ✕ close icon which read as
"close this row" rather than "delete this layer". The
classic trash-can with lid + body + three vertical
streaks reads unambiguously as destructive deletion. */}
<button
type="button"
onClick={(e) => { e.stopPropagation(); onDelete(element.id); }}
className="layers-item-action layers-item-action--delete"
aria-label={`Delete ${getName(element)}`}
title="Delete"
>
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
{/* Trash-can outline: top lid, side handles, body
edges, and three vertical streaks for the trash
"slats". Same path used by the floating ElementToolbar
delete button before Change 4 collapsed that button
into this single location. */}
<path d="M3 6h18" />
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<path d="M6 6l1 14a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-14" />
<path d="M10 11v6" />
<path d="M14 11v6" />
</svg>
</button>
{onReorder && (
<span
className="layers-item-grip"
aria-hidden="true"
title="Drag to reorder"
tabIndex={-1}
>
</span>
)}
</div>
{isDropAfter && <div className="layers-drop-indicator" aria-hidden="true" />}
</li>
);
})}
</ul>
</div>
);
});
/**
* Per-row thumbnail for image and sticker layers.
*
* Exists as its own component so it can call `useCroppedThumbnail`
* (a React hook, can't live inside the .map() loop in the parent's
* render body). The hook returns a data URL of the cropped sub-region
* when the element has a crop applied, or the raw `element.src`
* otherwise — see the hook's docblock for the rasterization details.
*
* Memoized so a re-render of the parent panel (e.g. when an unrelated
* layer's selection state changes) doesn't redo the thumbnail's load
* and crop computation. The hook itself also caches by (src, crop)
* signature, so even without memo this would be fast — but skipping
* the render path entirely for unchanged elements is faster still.
*
* The visual styling (size, object-fit, etc.) lives on the existing
* `.layers-item-icon-img` CSS class, unchanged from when this was an
* inline <img> in renderIcon. The only thing this wrapper changes
* about the rendered output is which URL ends up in the `src` attr.
*/
const LayerThumb = memo(function LayerThumb({ element }) {
const src = useCroppedThumbnail(element);
// The hook returns null in the rare initial-render-with-no-src case;
// fall back to the fallback dot to avoid emitting an <img> with an
// empty src (which would trigger a broken-image icon in some browsers).
if (!src) {
return <span className="layers-item-icon-fallback" aria-hidden="true">·</span>;
}
return (
<img
src={src}
alt=""
className="layers-item-icon-img"
loading="lazy"
decoding="async"
draggable={false}
/>
);
});

View File

@@ -1,110 +0,0 @@
import { memo } from 'react';
import { BackgroundRemovalButton } from '../sidebar/BackgroundRemovalButton';
import '../../styles/PropertiesPanel.css';
export const PropertiesPanel = memo(function PropertiesPanel({ element, onUpdate, onDelete, onEditPhoto }) {
if (!element) {
return (
<div className="properties-panel">
<div className="properties-panel__header">
<h3 className="properties-panel__title">Properties</h3>
</div>
<div className="properties-panel__empty">
Select an element to edit its properties
</div>
</div>
);
}
const handlePositionChange = (axis, value) => onUpdate({ [axis]: parseFloat(value) || 0 });
const handleSizeChange = (axis, value) => onUpdate({ [axis]: Math.max(20, parseFloat(value) || 20) });
const handleRotationChange = (value) => onUpdate({ rotation: Math.max(-180, Math.min(180, parseFloat(value) || 0)) });
return (
<div className="properties-panel">
<div className="properties-panel__header">
<h3 className="properties-panel__title">Properties</h3>
</div>
<div className="properties-panel__body">
<div className="properties-panel__type-badge">
{element.type}
</div>
{/* Position */}
<div className="properties-panel__section">
<label className="properties-panel__label">Position</label>
<div className="properties-panel__row">
<div className="properties-panel__field">
<label className="properties-panel__axis-label">X</label>
<input type="number" value={Math.round(element.x)} onChange={(e) => handlePositionChange('x', e.target.value)} className="properties-panel__input" />
</div>
<div className="properties-panel__field">
<label className="properties-panel__axis-label">Y</label>
<input type="number" value={Math.round(element.y)} onChange={(e) => handlePositionChange('y', e.target.value)} className="properties-panel__input" />
</div>
</div>
</div>
{/* Size (for images and stickers) */}
{(element.type === 'image' || element.type === 'sticker') && (
<div className="properties-panel__section">
<label className="properties-panel__label">Size</label>
<div className="properties-panel__row">
<div className="properties-panel__field">
<label className="properties-panel__axis-label">W</label>
<input type="number" value={Math.round(element.width)} onChange={(e) => handleSizeChange('width', e.target.value)} className="properties-panel__input" />
</div>
<div className="properties-panel__field">
<label className="properties-panel__axis-label">H</label>
<input type="number" value={Math.round(element.height)} onChange={(e) => handleSizeChange('height', e.target.value)} className="properties-panel__input" />
</div>
</div>
</div>
)}
{/* Edit Photo button (user uploads only, not stickers) */}
{element.type === 'image' && !element.emoji && onEditPhoto && (
<div className="properties-panel__section">
<button onClick={() => onEditPhoto(element)} className="properties-panel__edit-btn">
Edit Photo
</button>
</div>
)}
{/* Text-specific controls */}
{element.type === 'text' && (
<>
<div className="properties-panel__section">
<label className="properties-panel__label">Font Size: {Math.round(element.fontSize)}px</label>
<input type="range" min="12" max="120" value={element.fontSize} onChange={(e) => onUpdate({ fontSize: parseInt(e.target.value, 10) })} className="properties-panel__range" />
</div>
<div className="properties-panel__section">
<label className="properties-panel__label">Color</label>
<input type="color" value={element.fill} onChange={(e) => onUpdate({ fill: e.target.value })} className="properties-panel__color-input" />
</div>
</>
)}
{/* Rotation */}
<div className="properties-panel__section">
<label className="properties-panel__label">Rotation: {Math.round(element.rotation)}°</label>
<input type="range" min="-180" max="180" value={element.rotation} onChange={(e) => handleRotationChange(e.target.value)} className="properties-panel__range" />
</div>
{/* Background Removal (user uploads only, not stickers) */}
{element.type === 'image' && !element.emoji && (
<BackgroundRemovalButton
selectedElement={element}
onUpdate={(_id, attrs) => onUpdate(attrs)}
/>
)}
{/* Delete */}
<button onClick={() => onDelete(element.id)} className="properties-panel__delete-btn">
Delete Element
</button>
</div>
</div>
);
});

View File

@@ -1,2 +0,0 @@
export { LayersPanel } from './LayersPanel';
export { PropertiesPanel } from './PropertiesPanel';

View File

@@ -1,11 +0,0 @@
// MOVED — the canonical source for this component is now
// `../canvas/BackgroundRemovalButton`. It belongs there because it's
// rendered inside the canvas-area's floating element toolbar and operates
// on selected canvas elements; nothing in the sidebar imports it.
//
// This file remains as a thin re-export shim because the local environment
// doesn't expose a delete operation. No internal code imports from this
// path anymore (sidebar/index.js no longer re-exports it, and ElementToolbar
// imports directly from canvas/). Safe to remove in a future cleanup pass.
export { BackgroundRemovalButton } from '../canvas/BackgroundRemovalButton';

View File

@@ -1,173 +0,0 @@
import '../../styles/StickersTab.css';
/**
* Emoji tab.
*
* A simple emoji-picker grid. Tapping an emoji rasterizes the glyph onto a
* 100×100 transparent canvas and adds it to the design as a sticker element.
*
* Why rasterize rather than render the glyph live on Konva: emoji rendering
* is OS- and browser-dependent (Apple's Color Emoji vs Segoe UI Emoji vs
* Noto Color Emoji look quite different from each other), and the server's
* node-canvas almost certainly doesn't have any of those fonts installed.
* Rasterizing client-side and shipping the resulting PNG via a data URL
* means the printed output matches what the user designed, regardless of
* which renderer touches it.
*
* This used to live in StickersTab with a `variant` switch. Splitting it
* out cleaned up the conditional rendering and matched the actual data
* separation: image stickers are folder-driven, emojis are a static glyph
* list.
*/
// Inline emoji list. This used to live in constants/stickers.js as the
// `STICKERS` export; that constant has been repurposed for the new
// image-sticker manifest, so the emoji catalog moved here. Keeping it
// local also means the StickersTab build doesn't pay for the emoji
// data and vice-versa — two surfaces that no longer share anything.
const EMOJIS = [
// Faces
'😀','😁','😂','🤣','😃','😄','😅','😆','😉','😊','😋','😎',
'😍','😘','🥰','😗','🤔','🤨','🧐','🤓','😈','🤠','🥳','🤩',
// Animals
'🐶','🐱','🐭','🐹','🐰','🦊','🐻','🐼','🐨','🐯','🦁','🐮',
'🐷','🐸','🐵','🐔','🐧','🐦','🦄','🐝','🦋','🐌','🐞','🐢',
// Food
'🍎','🍐','🍊','🍋','🍌','🍉','🍇','🍓','🍈','🍒','🍑','🍍',
'🥥','🥝','🍅','🥑','🍆','🥔','🥕','🌽','🍕','🍔','🍟','🌭',
// Sports
'⚽','🏀','🏈','⚾','🥎','🎾','🏐','🏉','🎱','🏓','🏸','🥅',
'⛳','🥊','🥋','🎯','⛹️','🚴','🏆','🥇','🥈','🥉','🏅','🎖️',
// Nature
'🌸','💐','🌹','🌺','🌻','🌼','🌷','🌱','🌲','🌳','🌴','🌵',
'🌾','🌿','☘️','🍀','🍁','🍂','🍃','🌈','☀️','🌙','⭐','🔥',
// Hearts / objects
'❤️','💛','💚','💙','💜','🧡','💔','💯','✨','🌟','💫','🎵',
'🎶','🎸','🎺','🎷','🎹','👑','💎','🎁','🎈','🎉','🎊','🔮',
];
export function EmojiTab({ onAddSticker }) {
const handleAddEmoji = (emoji) => {
// Rasterize the emoji edge-to-edge.
//
// The previous approach drew the glyph at 80% scale on a 100×100
// canvas with `textBaseline: 'middle'`. That left ~10px of
// transparent padding above and below the glyph because:
// (a) the explicit 0.8× scale factor inset the glyph; and
// (b) `textBaseline: 'middle'` centers the em-box, not the
// visual bounds — emoji glyphs are designed with vertical
// metrics that include space for diacritics, descenders,
// etc., most of which a given emoji doesn't actually use.
//
// The transformer handles on the design canvas then sat that
// 10-pixel slack away from the visible glyph on every side, which
// read as sloppy when resizing or rotating.
//
// Robust fix: rasterize generously, then alpha-scan to find the
// true visual bounding box and crop the result to it. This is the
// same approach `useBackgroundRemoval` uses post-mask, just on a
// self-rasterized source instead of an alpha-masked one.
//
// Source canvas is 256×256 (vs the previous 100×100) so the cropped
// result still has enough resolution to print crisply at the 1200px
// print-scale target (see public/stickers/README.md for the math).
const SOURCE = 256;
const FONT_PX = 220; // generous; the alpha-scan crop trims the rest
const src = document.createElement('canvas');
src.width = SOURCE;
src.height = SOURCE;
const sctx = src.getContext('2d');
sctx.font = `${FONT_PX}px Arial`;
sctx.textAlign = 'center';
sctx.textBaseline = 'middle';
sctx.fillText(emoji, SOURCE / 2, SOURCE / 2);
// Alpha-scan for the visible bbox. `data` is a flat RGBA byte array;
// every fourth byte is alpha. ALPHA_THRESHOLD is intentionally low
// (above 0) so faint anti-aliasing pixels at the glyph edge are
// included in the bbox — cropping right at the solid pixels would
// visibly clip the emoji's edges.
const ALPHA_THRESHOLD = 8;
const { data } = sctx.getImageData(0, 0, SOURCE, SOURCE);
let minX = SOURCE, minY = SOURCE, maxX = -1, maxY = -1;
for (let y = 0; y < SOURCE; y++) {
for (let x = 0; x < SOURCE; x++) {
if (data[(y * SOURCE + x) * 4 + 3] > ALPHA_THRESHOLD) {
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
}
// Fallback for the degenerate case (no visible pixels — should never
// happen for a real emoji glyph, but keep the path defensive). Just
// ship the uncropped source.
let dataUrl;
let bboxW = SOURCE;
let bboxH = SOURCE;
if (maxX < minX || maxY < minY) {
dataUrl = src.toDataURL('image/png');
} else {
// Add 1px on each side so anti-aliased edges aren't clipped.
const pad = 1;
const cropX = Math.max(0, minX - pad);
const cropY = Math.max(0, minY - pad);
const cropW = Math.min(SOURCE, maxX + 1 + pad) - cropX;
const cropH = Math.min(SOURCE, maxY + 1 + pad) - cropY;
bboxW = cropW;
bboxH = cropH;
const out = document.createElement('canvas');
out.width = cropW;
out.height = cropH;
out.getContext('2d').drawImage(src, cropX, cropY, cropW, cropH, 0, 0, cropW, cropH);
dataUrl = out.toDataURL('image/png');
}
// Fit the cropped bbox into an 80×80 design-coord box, preserving
// aspect. Same algorithm as StickersTab.handleAddSticker (image
// stickers) and same placement: center on the print zone center
// (150, 150) so non-square emojis don't have their top-left pinned.
// Wide emojis (✨) and tall emojis (⛔) both end up centered.
const fitInto = 80;
const scale = fitInto / Math.max(bboxW, bboxH);
const width = bboxW * scale;
const height = bboxH * scale;
const printCenter = 150;
// Type 'sticker' (not 'image') so the editor can distinguish
// emoji-rasterized stickers from user-uploaded photos. They render
// through the same Konva component (ImageElement) but the type tag
// localizes special-casing — e.g. ElementToolbar's photo-only
// surfaces (filters, background removal, edit photo) gate on type.
onAddSticker({
type: 'sticker',
x: printCenter - width / 2,
y: printCenter - height / 2,
width,
height,
rotation: 0,
src: dataUrl,
emoji,
});
};
return (
<div className="st st--emoji">
<div className="st__grid st__grid--emoji">
{EMOJIS.map((emoji, index) => (
<button
key={`${emoji}-${index}`}
type="button"
onClick={() => handleAddEmoji(emoji)}
className="st__sticker"
aria-label={`Add emoji ${emoji}`}
>
<span className="st__sticker-emoji">{emoji}</span>
</button>
))}
</div>
</div>
);
}

View File

@@ -1,162 +0,0 @@
import { useState, useId } from 'react';
import { SHIRT_COLORS, SHIRT_SIZES, formatUsd } from '../../constants/shirt';
import '../../styles/ShirtOptionsPanel.css';
/**
* The first card in the right panel — controls that affect the *garment*,
* not the design overlay: shirt color, size, and the live price.
*
* The shirt color drives the on-screen mockup only. The export endpoint
* receives just the design overlay (no shirt body) since DTG / sublimation
* production pipelines composite onto the chosen shirt downstream.
*
* Collapsed / expanded UX
* ——————————————————————
* The full color + size pickers consume meaningful vertical space at the
* top of the right rail — space that, mid-edit, is more valuable for the
* tools and layers panels below. We default to a compact summary that
* shows the current color (small swatch + label), the current size, and
* the price, with an expand affordance for when the user actually wants
* to change shirt options. After expanding, the panel stays open until
* the user collapses it again — we don't auto-collapse on selection,
* which would punish users who want to compare a few options.
*
* The collapse state is component-local and resets on reload — each
* fresh session of the editor starts collapsed since the user almost
* always cares about the design first and the garment second.
*/
export function ShirtOptionsPanel({
selectedColorId,
onColorChange,
selectedSize,
onSizeChange,
price,
}) {
const [expanded, setExpanded] = useState(false);
// Stable id for aria-controls / id pairing. useId is preferable over a
// hardcoded string because if this component is ever rendered twice in
// the same view (e.g. mobile bottom sheet + desktop sidebar mirroring),
// each instance needs a distinct id.
const contentId = useId();
const selectedColor = SHIRT_COLORS.find((c) => c.id === selectedColorId) ?? SHIRT_COLORS[0];
return (
<section className={`shirt-options${expanded ? ' is-expanded' : ' is-collapsed'}`} aria-labelledby="shirt-options-heading">
{/* The header is the toggle target. Wrapping it in a <button> means
the entire row is clickable and keyboard-focusable. The price and
summary chips inside the button are visual content; the button's
aria-label carries the full state for screen readers. */}
<button
type="button"
className="shirt-options__toggle"
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
aria-controls={contentId}
aria-label={`Shirt options. Currently ${selectedColor.label}, size ${selectedSize}, ${formatUsd(price)}. ${expanded ? 'Click to collapse.' : 'Click to change.'}`}
>
<header className="shirt-options__header">
<h2 id="shirt-options-heading" className="shirt-options__title">
<span className="shirt-options__title-icon" aria-hidden="true">👕</span>
Shirt Options
{/* Chevron renders in both expanded and collapsed states so
users always see an affordance for the toggle. CSS
rotates it 180° when the section is expanded — down
arrow says "click to reveal", up arrow says "click to
close". Sits immediately after the title so it reads as
part of the disclosure widget rather than as a
decoration on the price. */}
<span className="shirt-options__chevron" aria-hidden="true">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 9l6 6 6-6" />
</svg>
</span>
</h2>
<div className="shirt-options__price" aria-hidden="true">
{formatUsd(price)}
</div>
</header>
{/* Compact summary chips — visible only when collapsed. Shows a
mini swatch + the color label + the size, so the user can
confirm the current selection at a glance without expanding. */}
{!expanded && (
<div className="shirt-options__summary" aria-hidden="true">
<span
className="shirt-options__summary-swatch"
style={{
'--swatch-color': selectedColor.hex,
'--swatch-border': selectedColor.borderHint,
}}
/>
<span className="shirt-options__summary-text">
{selectedColor.label} · Size {selectedSize}
</span>
</div>
)}
</button>
{/* Expanded content. We always render the markup (rather than
short-circuiting on `expanded`) so the CSS max-height transition
has something to animate between. `aria-hidden` + visibility:hidden
(in the CSS) on the collapsed state keep it out of the tab order
and screen-reader tree when it's not visible. */}
<div
id={contentId}
className="shirt-options__content"
aria-hidden={!expanded}
>
<div className="shirt-options__row">
<span className="shirt-options__label">Color</span>
<div className="shirt-options__swatches" role="radiogroup" aria-label="Shirt color">
{SHIRT_COLORS.map((c) => {
const isSelected = c.id === selectedColorId;
return (
<button
key={c.id}
type="button"
role="radio"
aria-checked={isSelected}
aria-label={c.label}
className={`shirt-options__swatch${isSelected ? ' is-selected' : ''}`}
onClick={() => onColorChange?.(c.id)}
// The expanded controls are tabIndex=-1 when collapsed
// so keyboard users don't tab through invisible options.
tabIndex={expanded ? 0 : -1}
style={{
'--swatch-color': c.hex,
'--swatch-border': c.borderHint,
}}
>
<span className="shirt-options__swatch-fill" />
</button>
);
})}
</div>
</div>
<div className="shirt-options__row">
<span className="shirt-options__label">Size</span>
<div className="shirt-options__sizes" role="radiogroup" aria-label="Shirt size">
{SHIRT_SIZES.map((size) => {
const isSelected = size === selectedSize;
return (
<button
key={size}
type="button"
role="radio"
aria-checked={isSelected}
className={`shirt-options__size${isSelected ? ' is-selected' : ''}`}
onClick={() => onSizeChange?.(size)}
tabIndex={expanded ? 0 : -1}
>
{size}
</button>
);
})}
</div>
</div>
</div>
</section>
);
}

View File

@@ -1,329 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { ShirtOptionsPanel } from './ShirtOptionsPanel';
import { UploadTab } from './UploadTab';
import { StickersTab } from './StickersTab';
import { EmojiTab } from './EmojiTab';
import { TextTab } from './TextTab';
import { LayersPanel } from '../panels/LayersPanel';
import { formatUsd } from '../../constants/shirt';
import '../../styles/Sidebar.css';
// Tab nav for the right rail. Templates are deliberately absent — templates
// are loaded exclusively via the `?template=X` URL parameter (see
// `readTemplateFromUrl` in App.jsx). The Layers panel used to be a tab here
// too; it's now a permanent section rendered beneath the tab card so the
// user can browse / reorder / delete elements without losing access to the
// upload / stickers / text tools.
const TABS = [
{ id: 'upload', label: 'Upload Photo', icon: UploadIcon },
{ id: 'stickers', label: 'Stickers', icon: StickersIcon },
{ id: 'emoji', label: 'Emoji', icon: EmojiIcon },
{ id: 'text', label: 'Text', icon: TextIcon },
];
/**
* Right-rail panel.
*
* Top: Shirt Options card (color/size/price)
* Middle: Tab nav + active tab content
* Bottom (sticky): Preview-on-Model toggle + Add to Cart button
*/
export function Sidebar({
// Editor wiring
onAddImage, onAddSticker, onAddText,
recentUploads,
onRemoveUpload,
// Selection-aware editing surface for the text tab. NOTE:
// `selectedElement` is used by the layers panel and the mobile
// bottom-sheet compact mode. The Text tab now reads from a
// SEPARATE prop (`editingTextElement`) so that *selecting* a text
// element on the canvas no longer pulls the Text tab into edit
// mode — only an explicit edit gesture (pencil affordance on
// desktop, Edit-text toolbar button on mobile) sets that prop.
// See the Change 7 follow-up in [[Refinements_2026-05-20_Part2]].
selectedElement, editingTextElement, onUpdateSelected, onCommit,
// Shirt config
shirtColorId, onShirtColorChange,
shirtSize, onShirtSizeChange,
totalPrice, basePrice,
// Cart
onAddToCart,
cartDisabled = false,
cartDisabledReason = null,
// Color helpers for the text tab
recentColors,
onPickColor,
// Font helpers for the text tab (recent fonts dropdown)
recentFonts,
onPickFont,
// Layers tab — needs the full elements list and a delete callback. We
// already have selectedElement; add elements + onSelectElement +
// onDeleteElement so the LayersPanel can show the list and act on it.
elements = [],
onSelectElement,
onDeleteElement,
// Layers-panel per-row actions (Change 4). Optional so existing
// callers continue to work — the buttons just don't render when
// the callback is missing.
onDuplicateElement,
onUpdateElement,
// S3 multi-select wiring. LayersPanel uses these to render the
// ✓ chip per row, the bulk-delete affordance, and the drag-reorder
// grip. All optional — the panel falls back to single-selection
// rendering when they aren't provided.
selectedIds,
onToggleInSelection,
onDeleteMany,
onReorderElement,
// Controlled active-tab API (Change 7 follow-up). Sidebar used to
// own its tab state internally; lifting it to App lets the pencil
// affordance / Edit-text gesture set the tab directly (“clicking
// the pencil should switch to the Text tab and populate the
// form”). Both props are optional so existing call sites that
// didn't pass them fall back to internal state — uncontrolled.
activeTab: controlledActiveTab,
onActiveTabChange,
// Desktop focus signal for the Text tab. Threaded straight through
// to TextTab — see its docblock for how it's consumed. Optional;
// when omitted, TextTab simply never auto-focuses (which is fine
// for any caller that doesn't need the pencil-click behaviour).
textEditFocusToken,
}) {
// Controlled or uncontrolled. When the parent supplies `activeTab`
// and `onActiveTabChange`, those drive the visible tab; otherwise
// we keep our own `useState` and behave as before. Detecting via
// `!== undefined` so a deliberate `null` from the parent (= reset
// to default) is still respected.
const [internalActiveTab, setInternalActiveTab] = useState('upload');
const isControlled = controlledActiveTab !== undefined && typeof onActiveTabChange === 'function';
const activeTab = isControlled ? controlledActiveTab : internalActiveTab;
const setActiveTab = isControlled ? onActiveTabChange : setInternalActiveTab;
// ---------------------------------------------------------------
// Pencil-click focus plumbing (Change 7 follow-up).
//
// The textarea-focus logic LIVES HERE — in Sidebar — rather than
// inside TextTab, on purpose. TextTab is mounted/unmounted as the
// user navigates between tabs; pressing the pencil flips the tab
// to 'text' AND bumps the focus token in the same React batch,
// which means TextTab is mounting FRESH at the moment the token
// first changes. Any `useRef(focusToken)` initializer inside
// TextTab would capture the new value at mount and the
// "transition detection" effect would immediately bail out
// (`1 === 1`) — the user has to click the pencil a second time
// before TextTab's ref carries an old value.
//
// Sidebar, by contrast, is persistently mounted on desktop
// (App.jsx wraps it in an <aside> that lives for the lifetime of
// the editor). Its refs survive every tab switch. The ref
// initializer captures the very-first token value (0) at app
// boot, before any pencil click can have happened — so the next
// bump is correctly detected as a transition.
//
// textareaRef is forwarded down to TextTab via the `textareaRef`
// prop. TextTab attaches it to its <textarea>. When this effect
// fires, the next-frame callback runs after React has mounted
// TextTab and committed its ref — so textareaRef.current is
// populated by the time we call .focus().
// ---------------------------------------------------------------
const textareaRef = useRef(null);
const lastFocusTokenRef = useRef(textEditFocusToken ?? 0);
useEffect(() => {
if (textEditFocusToken == null) return;
if (textEditFocusToken === lastFocusTokenRef.current) return;
lastFocusTokenRef.current = textEditFocusToken;
// requestAnimationFrame defers past the current commit — if the
// same pencil click that bumped this token ALSO switched
// activeTab to 'text', TextTab is mounting on the same render
// and its textarea ref isn't attached yet at the moment React
// fires this effect. Waiting one frame guarantees the textarea
// is in the DOM and the ref is set.
const rafId = window.requestAnimationFrame(() => {
textareaRef.current?.focus();
});
return () => window.cancelAnimationFrame(rafId);
}, [textEditFocusToken]);
// The Text tab's edit mode is gated on `editingTextElement` (NOT
// `selectedElement`) so it only activates when the user has
// explicitly entered text-editing mode via the pencil affordance
// (desktop) or the Edit-text toolbar button (mobile). Selection
// alone is no longer enough — see the Change 7 follow-up in
// [[Refinements_2026-05-20_Part2]] for the rationale.
const selectedTextElement = editingTextElement?.type === 'text' ? editingTextElement : null;
// NOTE: the auto-switch-to-Text-tab effect that used to live here
// moved up to App.jsx — the pencil/Edit-text handler now sets the
// active tab directly via the controlled API. The behaviour is
// identical from the user's perspective (Text tab pops to the
// front on edit gesture, stays put when selection alone changes)
// but the routing is one place instead of two. See Change 7
// follow-up in [[Refinements_2026-05-20_Part2]].
const renderTabContent = () => {
switch (activeTab) {
case 'upload': return <UploadTab onAddImage={onAddImage} recentUploads={recentUploads} onRemoveUpload={onRemoveUpload} />;
case 'stickers': return <StickersTab onAddSticker={onAddSticker} />;
case 'emoji': return <EmojiTab onAddSticker={onAddSticker} />;
case 'text': return (
<TextTab
onAddText={onAddText}
selectedTextElement={selectedTextElement}
onUpdateSelected={onUpdateSelected}
onCommit={onCommit}
recentColors={recentColors}
onPickColor={onPickColor}
recentFonts={recentFonts}
onPickFont={onPickFont}
textareaRef={textareaRef}
/* Bug 3 fix forward the full canvas elements list so the
* TextTab can derive sensible draft defaults from any text
* already on the canvas. The tab filters to text-typed
* elements and checks for a shared style; when found, the
* draft inherits that style's font / fill / outline. */
elements={elements}
/>
);
default: return null;
}
};
return (
<aside className="rp" aria-label="Customization options">
<div className="rp__scroll">
<ShirtOptionsPanel
selectedColorId={shirtColorId}
onColorChange={onShirtColorChange}
selectedSize={shirtSize}
onSizeChange={onShirtSizeChange}
price={basePrice}
/>
<section className="rp__tools" aria-labelledby="rp-tools-heading">
<h2 id="rp-tools-heading" className="visually-hidden">Design tools</h2>
<div className="rp__tabs" role="tablist" aria-label="Design tools">
{TABS.map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
role="tab"
aria-selected={isActive}
aria-controls={`tab-panel-${tab.id}`}
id={`tab-${tab.id}`}
className={`rp__tab${isActive ? ' is-active' : ''}`}
onClick={() => setActiveTab(tab.id)}
>
<Icon />
<span>{tab.label}</span>
</button>
);
})}
</div>
<div
role="tabpanel"
id={`tab-panel-${activeTab}`}
aria-labelledby={`tab-${activeTab}`}
className="rp__tabpanel"
>
{renderTabContent()}
</div>
</section>
{/* Layers section — permanent surface beneath the tools card. The
LayersPanel renders its own visible "Layers (N)" titlebar and
handles the empty-state message internally, so this section
just provides a matching card chrome and a screen-reader
heading. */}
<section className="rp__layers" aria-labelledby="rp-layers-heading">
<h2 id="rp-layers-heading" className="visually-hidden">Layers</h2>
<LayersPanel
elements={elements}
selectedId={selectedElement?.id ?? null}
selectedIds={selectedIds}
onSelect={onSelectElement}
onToggleInSelection={onToggleInSelection}
onDelete={onDeleteElement}
onDeleteMany={onDeleteMany}
onReorder={onReorderElement}
onDuplicate={onDuplicateElement}
onUpdate={onUpdateElement}
/>
</section>
</div>
<div className="rp__cart-bar">
{/* Reason text for the disabled cart, when present. Rendered as a
sibling of the button rather than a tooltip because (a) the
primary surface for the warning is already the canvas-frame
chip, this is a follow-up affordance for the user who has
scrolled to the cart, and (b) tooltips are unreliable on
touch. The button's `disabled` and `aria-disabled` attributes
give assistive tech the actionable signal. */}
{cartDisabled && cartDisabledReason && (
<p className="rp__cart-reason" role="status">
{cartDisabledReason}
</p>
)}
<button
type="button"
className={`rp__add-to-cart${cartDisabled ? ' is-disabled' : ''}`}
onClick={onAddToCart}
disabled={cartDisabled}
aria-disabled={cartDisabled}
title={cartDisabled ? cartDisabledReason || undefined : undefined}
>
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true">
<path d="M12 21s-7-4.5-9.3-9A5.4 5.4 0 0 1 12 5.5 5.4 5.4 0 0 1 21.3 12C19 16.5 12 21 12 21z" />
</svg>
<span>Add to Cart</span>
<span className="rp__add-to-cart-price">{formatUsd(totalPrice)}</span>
</button>
</div>
</aside>
);
}
/* ─── Inline icon set — kept here so each tab definition stays declarative ─── */
function UploadIcon() {
return (
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<path d="M17 8l-5-5-5 5" />
<path d="M12 3v12" />
</svg>
);
}
function StickersIcon() {
return (
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14.5 2.5a8 8 0 1 1-12 12L14.5 2.5z" />
<path d="M14.5 2.5L21.5 9.5a8 8 0 0 1-7 7" />
</svg>
);
}
function EmojiIcon() {
return (
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="9" />
<path d="M8 14s1.5 2 4 2 4-2 4-2" />
<path d="M9 9h.01" />
<path d="M15 9h.01" />
</svg>
);
}
function TextIcon() {
return (
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 7V5h16v2" />
<path d="M9 5v14" />
<path d="M15 5v14" />
<path d="M7 19h4" />
<path d="M13 19h4" />
</svg>
);
}

View File

@@ -1,180 +0,0 @@
import { useMemo, useState } from 'react';
import { STICKERS, STICKER_CATEGORIES } from '../../constants/stickers';
import '../../styles/StickersTab.css';
/**
* Stickers tab — image stickers from `public/stickers/`.
*
* Lists every PNG/WebP/JPG/SVG dropped into the public/stickers/ folder,
* grouped by the category prefix in its filename. Tapping a sticker adds it
* to the canvas as a 'sticker'-type element with the file's URL as the src.
*
* Image fetches are lazy: each thumbnail uses `<img loading="lazy">`, so a
* library of 200+ stickers doesn't fire 200 GETs on tab open. The browser
* pulls them in as the user scrolls through the grid.
*
* Adding stickers is folder-driven (see public/stickers/README.md). There's
* no constants file to edit and no manifest to regenerate — a small Vite
* plugin reads the directory at build time and emits the file list.
*
* The Emoji tab (EmojiTab.jsx) used to live here too as a `variant="emoji"`
* mode. It's a separate component now: image stickers and emoji glyphs have
* essentially no shared rendering path beyond the grid CSS, which both reuse.
*/
export function StickersTab({ onAddSticker }) {
const [activeCategory, setActiveCategory] = useState('all');
const [failedIds, setFailedIds] = useState(() => new Set());
// Categories pill bar. Prepend an "All" entry that the data layer
// intentionally doesn't include — "All" is a UI concept (filter
// disable), not a data category.
const categoryPills = useMemo(
() => [{ id: 'all', label: 'All' }, ...STICKER_CATEGORIES],
[],
);
const visibleStickers = useMemo(() => {
if (activeCategory === 'all') return STICKERS;
return STICKERS.filter((s) => s.categoryId === activeCategory);
}, [activeCategory]);
const handleAddSticker = async (sticker) => {
// Read the source image's natural dimensions so the sticker lands on
// the canvas with its true aspect ratio, not as a stretched square.
// The thumbnail's <img> in the panel may not have loaded yet (it's
// lazy) and even if it has, the DOM element isn't a stable place to
// pull `naturalWidth` from — we just construct a fresh Image. If the
// browser already has the file in cache the onload fires immediately;
// otherwise we wait for the network. Either way, the click feels
// responsive because the bottleneck is decode latency, not network.
//
// Falls back to a square 80×80 if the load fails for any reason
// (broken file, CORS oddity, blocked). That's strictly no worse than
// the previous unconditional 80×80 placement.
const fitInto = 80; // design-coord bounding box
let width = fitInto;
let height = fitInto;
try {
const dims = await new Promise((resolveDims, rejectDims) => {
const img = new Image();
img.onload = () => resolveDims({ w: img.naturalWidth, h: img.naturalHeight });
img.onerror = () => rejectDims(new Error('Sticker image failed to load'));
img.src = sticker.url;
});
if (dims.w > 0 && dims.h > 0) {
// Fit (dims.w × dims.h) into an 80×80 box, preserving aspect.
// The longer side becomes 80; the shorter side scales
// proportionally. Equal sides land on 80×80 (matches old
// behavior for square sources).
const scale = fitInto / Math.max(dims.w, dims.h);
width = dims.w * scale;
height = dims.h * scale;
}
} catch {
// Keep the 80×80 fallback. handleImgError will fire from the
// thumbnail render path separately if the file is truly missing.
}
// Center the (width, height) box on the print zone center. Design
// coords are 0300, so center is at 150. With width=height=80, this
// produces (x: 110, y: 110), matching the old fixed placement — so
// square stickers don't appear to move relative to the previous
// behavior. Non-square stickers center properly instead of having
// their top-left pinned (which would have skewed their visual
// center off the print-zone center).
const printCenter = 150;
onAddSticker({
type: 'sticker',
x: printCenter - width / 2,
y: printCenter - height / 2,
width,
height,
rotation: 0,
src: sticker.url,
});
};
const handleImgError = (id) => {
// Track failed loads so we can hide broken thumbnails. We don't
// remove the sticker entry — the file is presumably there (the
// manifest came from a directory listing), so a 404 likely means a
// dev-server hiccup that'll fix itself on reload. Hiding the broken
// image tile is just polite.
setFailedIds((prev) => {
if (prev.has(id)) return prev;
const next = new Set(prev);
next.add(id);
return next;
});
};
// Empty-state: no stickers in the folder. Friendlier than rendering an
// empty pill bar and a blank grid. The README at public/stickers/README.md
// explains the filename convention; pointing the user there in the
// message would be over-explaining for an end-user-facing surface — this
// copy is intentionally generic.
if (STICKERS.length === 0) {
return (
<div className="st st--full">
<div className="st__header">
<h3 className="st__title">Stickers Library</h3>
</div>
<p className="st__empty">
No stickers available yet. Stickers will appear here once they're
added to the library.
</p>
</div>
);
}
return (
<div className="st st--full">
<div className="st__header">
<h3 className="st__title">Stickers Library</h3>
</div>
<div className="st__categories" role="tablist" aria-label="Sticker categories">
{categoryPills.map((cat) => {
const isActive = cat.id === activeCategory;
return (
<button
key={cat.id}
type="button"
role="tab"
aria-selected={isActive}
className={`st__category${isActive ? ' is-active' : ''}`}
onClick={() => setActiveCategory(cat.id)}
>
{cat.label}
</button>
);
})}
</div>
<div className="st__grid">
{visibleStickers.map((sticker) => {
if (failedIds.has(sticker.id)) return null;
return (
<button
key={sticker.id}
type="button"
onClick={() => handleAddSticker(sticker)}
className="st__sticker st__sticker--image"
aria-label={`Add sticker ${sticker.name}`}
title={sticker.name}
>
<img
src={sticker.url}
alt=""
loading="lazy"
decoding="async"
className="st__sticker-img"
onError={() => handleImgError(sticker.id)}
/>
</button>
);
})}
</div>
</div>
);
}

View File

@@ -1,107 +0,0 @@
import { useState } from 'react';
import { TEMPLATES, TEMPLATE_CATEGORIES } from '../../constants/templates';
import '../../styles/TemplatesTab.css';
function getCategoryEmoji(category) {
const emojis = {
Sports: '⚽', Music: '🎸', Quotes: '💬', Animals: '🐱',
Abstract: '🌈', Vintage: '🏅', Nature: '🏔️', Tech: '💻',
};
return emojis[category] || '🎨';
}
export function TemplatesTab({ onAddTemplate, onSlotImageUpload }) {
const [selectedTemplateId, setSelectedTemplateId] = useState(null);
const [uploadSlotId, setUploadSlotId] = useState(null);
const templates = [
{ id: 'freeform', name: 'Freeform', description: 'No template - design freely', thumbnail: '🎨' },
...TEMPLATES.map(t => ({
id: t.id,
name: t.name,
description: t.description,
thumbnail: getCategoryEmoji(t.category),
hasSlots: !!t.slots,
})),
];
const handleSelectTemplate = (template) => {
setSelectedTemplateId(template.id);
onAddTemplate(template.id);
};
const handleSlotClick = (slotId) => {
setUploadSlotId(slotId);
document.getElementById('slot-file-input')?.click();
};
const handleFileChange = (e) => {
const file = e.target.files?.[0];
if (file && uploadSlotId) {
const reader = new FileReader();
reader.onload = (event) => {
onSlotImageUpload?.(uploadSlotId, event.target.result);
};
reader.readAsDataURL(file);
}
e.target.value = '';
setUploadSlotId(null);
};
const selectedTemplate = TEMPLATES.find(t => t.id === selectedTemplateId);
const slots = selectedTemplate?.slots || [];
return (
<div>
<input id="slot-file-input" type="file" accept="image/*" onChange={handleFileChange} className="templates-hidden-input" />
<h3 className="templates-title">Templates</h3>
<div className="templates-description">
Choose a template to get started or design freely.
</div>
<div className="templates-list">
{templates.map((template) => (
<button
key={template.id}
onClick={() => handleSelectTemplate(template)}
className={`template-btn${template.id === selectedTemplateId ? ' selected' : ''}`}
>
<div className="template-thumbnail">
{template.thumbnail}
</div>
<div className="template-info">
<div className="template-name">{template.name}</div>
<div className="template-desc">{template.description}</div>
</div>
{template.hasSlots && (
<span className="template-slots-badge">SLOTS</span>
)}
</button>
))}
</div>
{selectedTemplateId && selectedTemplateId !== 'freeform' && slots.length > 0 && (
<div className="template-slots-section">
<h4 className="template-slots-title">Template Slots</h4>
<div className="template-slots-list">
{slots.map((slot) => (
<button
key={slot.id}
onClick={() => handleSlotClick(slot.id)}
className="template-slot-btn"
>
<span className="template-slot-icon">📷</span>
<span>{slot.label}</span>
<span className="template-slot-dimensions">
{slot.bounds.width}×{slot.bounds.height}
</span>
</button>
))}
</div>
</div>
)}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,168 +0,0 @@
import { useRef, useState } from 'react';
import '../../styles/UploadTab.css';
import { loadImageDimensions } from '../../utils/imageLoading';
import { MIN_ELEMENT_SIZE } from '../../constants/elements';
/**
* Upload Photo tab.
*
* Layout:
* [drop zone] — Click to upload or drop a file
* [Recent Uploads] — quick re-add of previously-used pet photos
*/
export function UploadTab({ onAddImage, recentUploads = [], onRemoveUpload }) {
const fileInputRef = useRef(null);
const [isDragging, setIsDragging] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const placeImage = async (previewUrl, originalUrl) => {
const { width: naturalW, height: naturalH } = await loadImageDimensions(previewUrl);
const maxSide = 150;
const scale = Math.min(maxSide / naturalW, maxSide / naturalH, 1);
const width = Math.max(MIN_ELEMENT_SIZE, Math.round(naturalW * scale));
const height = Math.max(MIN_ELEMENT_SIZE, Math.round(naturalH * scale));
const x = Math.round((300 - width) / 2);
const y = Math.round((300 - height) / 2);
onAddImage({
type: 'image',
x, y, width, height,
rotation: 0,
src: previewUrl,
originalUrl,
});
};
const handleFiles = async (files) => {
const file = files[0];
if (!file) return;
const validTypes = ['image/jpeg', 'image/png', 'image/webp'];
if (!validTypes.includes(file.type)) { alert('Please upload a JPEG, PNG, or WebP image'); return; }
if (file.size > 20 * 1024 * 1024) { alert('File size must be under 20MB'); return; }
setIsUploading(true);
try {
const formData = new FormData();
formData.append('image', file);
const response = await fetch('/api/upload', { method: 'POST', body: formData });
if (!response.ok) throw new Error('Upload failed');
const data = await response.json();
await placeImage(data.preview.url, data.original.url);
} catch (error) {
console.error('Upload error:', error);
alert('Failed to upload image. Please try again.');
} finally {
setIsUploading(false);
// Reset the file input value so picking the same file twice in a row still fires
// the change event. Without this, click-to-pick → delete → click-to-pick the same
// file silently does nothing.
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
return (
<div className="ut">
<div
onClick={() => fileInputRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
onDragLeave={(e) => { e.preventDefault(); setIsDragging(false); }}
onDrop={(e) => { e.preventDefault(); setIsDragging(false); handleFiles(e.dataTransfer.files); }}
className={`ut__dropzone${isDragging ? ' is-dragging' : ''}`}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fileInputRef.current?.click(); }
}}
>
<div className="ut__dropzone-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="34" height="34" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 15a4 4 0 0 1 .9-7.9 5.5 5.5 0 0 1 10.6-1.5A4.5 4.5 0 0 1 22 9.5" />
<path d="M12 12v9" />
<path d="M8 16l4-4 4 4" />
</svg>
</div>
<div className="ut__dropzone-text">Drop your pet photo here</div>
<div className="ut__dropzone-hint">
or <span className="ut__browse">click to browse</span>
</div>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={(e) => handleFiles(e.target.files)}
className="ut__hidden-input"
/>
{isUploading && <div className="ut__status">Uploading</div>}
{recentUploads.length > 0 && (
<div className="ut__recent">
<div className="ut__recent-header">
<span className="ut__recent-title">Recent Uploads</span>
<button type="button" className="ut__see-all" onClick={(e) => e.preventDefault()}>See all</button>
</div>
<div className="ut__recent-grid">
{recentUploads.slice(0, 5).map((u) => (
<div key={u.id} className="ut__recent-item">
<button
type="button"
className="ut__recent-thumb"
onClick={() => placeImage(u.previewUrl, u.originalUrl)}
aria-label="Add this photo to canvas"
>
{/* onError prunes entries whose underlying file is
* no longer on the server. The `recentUploads` list
* is persisted to localStorage, but the files behind
* those URLs can vanish between sessions — server
* restart wiping /uploads, manual cleanup, a TTL on
* the preview dir, etc. When that happens the
* thumbnail GET returns 404 and the browser renders
* a broken-image icon.
*
* Calling `onRemoveUpload(u.id)` on error lets the
* parent (App) drop the entry from `recentUploads`,
* which removes the row on the next render and
* triggers a persistence save that cleans the entry
* out of localStorage too. No retry / debouncing
* because the cost of mis-pruning a transient
* network failure is low (user can re-upload from
* the dropzone), while the cost of NOT pruning a
* permanently-dead URL is a persistent broken
* thumbnail across every future session.
*
* Guarded on `onRemoveUpload` being supplied so the
* component still works in callers that don't wire
* the prune callback through (the broken thumb just
* stays, same as the previous behaviour). */}
<img
src={u.previewUrl}
alt=""
onError={() => onRemoveUpload?.(u.id)}
/>
</button>
{onRemoveUpload && (
<button
type="button"
className="ut__recent-delete"
onClick={() => onRemoveUpload(u.id)}
aria-label="Remove this photo from recent uploads"
>
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M3 6h18" />
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<path d="M6 6l1 14a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-14" />
<path d="M10 11v6" />
<path d="M14 11v6" />
</svg>
<span>Delete</span>
</button>
)}
</div>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -1,9 +0,0 @@
export { Sidebar } from './Sidebar';
export { ShirtOptionsPanel } from './ShirtOptionsPanel';
export { UploadTab } from './UploadTab';
export { StickersTab } from './StickersTab';
export { EmojiTab } from './EmojiTab';
export { TextTab } from './TextTab';
export { TemplatesTab } from './TemplatesTab';
// BackgroundRemovalButton has moved to ../canvas/. Import it from
// '../canvas/BackgroundRemovalButton' or via canvas/index.js.

View File

@@ -1,23 +0,0 @@
/**
* Element-sizing constants shared across components and bounds-fitting.
*
* Before this module, `Math.max(20, ...)` appeared inline in
* `ImageElement.onTransformEnd`, `elementBounds.fitElementToBounds`,
* `UploadTab.placeImage`, and several other places. The 20 isn't a guess —
* it's the smallest box the user can still hit with the transformer
* handles on a touch device — so changing it would change UX. Pulling it
* here makes that explicit and prevents drift.
*/
// Floor for image / sticker width and height. Below this, transformer
// corner handles overlap and the element becomes effectively un-targetable
// on touch.
export const MIN_ELEMENT_SIZE = 20;
// Floor for inline-text-editor textarea overlay dimensions. Bigger than the
// canvas-element minimum because the user is interacting with a real DOM
// textarea (text selection, IME composition) where 20px would be too small
// to be usable. Width gets a higher floor than height because empty/short
// strings still need a recognizable click target.
export const MIN_INLINE_EDIT_WIDTH = 60;
export const MIN_INLINE_EDIT_HEIGHT = 20;

View File

@@ -1,24 +0,0 @@
export const FONTS = [
{ name: 'Roboto', family: 'Roboto' },
{ name: 'Open Sans', family: 'Open Sans' },
{ name: 'Lato', family: 'Lato' },
{ name: 'Montserrat', family: 'Montserrat' },
{ name: 'Oswald', family: 'Oswald' },
{ name: 'Raleway', family: 'Raleway' },
{ name: 'Poppins', family: 'Poppins' },
{ name: 'Roboto Condensed', family: 'Roboto Condensed' },
{ name: 'Source Sans 3', family: 'Source Sans 3' },
{ name: 'Roboto Slab', family: 'Roboto Slab' },
{ name: 'Merriweather', family: 'Merriweather' },
{ name: 'Ubuntu', family: 'Ubuntu' },
{ name: 'Playfair Display', family: 'Playfair Display' },
{ name: 'Nunito', family: 'Nunito' },
{ name: 'Rubik', family: 'Rubik' },
{ name: 'Work Sans', family: 'Work Sans' },
{ name: 'Lora', family: 'Lora' },
{ name: 'Fira Sans', family: 'Fira Sans' },
{ name: 'Barlow', family: 'Barlow' },
{ name: 'Bebas Neue', family: 'Bebas Neue' },
{ name: 'Pacifico', family: 'Pacifico' },
{ name: 'Caveat', family: 'Caveat' },
];

View File

@@ -1,60 +0,0 @@
/**
* Image filter presets for photo elements.
*
* Each preset can specify ONE of two ways to identify its filter:
* - `konva`: a string name looked up on `Konva.Filters` at apply time.
* Used for built-in Konva filters (Grayscale, Sepia, etc.). We avoid
* importing Konva here so consumers (e.g. ElementToolbar) that only
* iterate the list for UI don't pay the bundle cost.
* - `customFn`: a direct function reference (a Konva-compatible filter
* that mutates ImageData in place). Used for filters that don't ship
* with Konva — e.g. the `warm` preset uses Filerobot's Kelvin filter.
*
* Lookup order in ImageElement: `customFn ?? Konva.Filters[konva] ?? null`.
*
* Each preset can also carry default attribute values that Konva reads
* during filter application — `Brighten` reads `brightness`, `Contrast`
* reads `contrast`, etc. Presets currently only use the basic toggle-style
* filters; the schema supports parameter passing for future presets.
*
* Server-side note
* ────────────────
* The print export pipeline (server.js → applyFilterToSharp) currently
* mirrors grayscale, sepia, and invert via sharp. The `warm`/Kelvin preset
* is editor-only — exports silently skip it — so the printed shirt won't
* match the on-screen preview for warm-filtered photos until a sharp
* implementation is added.
*/
import KelvinFilter from 'react-filerobot-image-editor/lib/custom/filters/Kelvin';
export const IMAGE_FILTERS = [
{
id: 'none',
label: 'Original',
konva: null,
icon: '◯',
},
{
id: 'grayscale',
label: 'B&W',
konva: 'Grayscale',
icon: '◐',
},
{
id: 'sepia',
label: 'Sepia',
konva: 'Sepia',
icon: '◑',
},
{
id: 'warm',
label: 'Warm',
customFn: KelvinFilter,
icon: '◓',
},
];
export function getFilterPreset(id) {
return IMAGE_FILTERS.find((f) => f.id === id) || IMAGE_FILTERS[0];
}

View File

@@ -1,154 +0,0 @@
/**
* Image-sticker manifest.
*
* Stickers are PNG/WebP/JPG/SVG files dropped into `public/stickers/` with a
* specific filename convention:
*
* <category>__<sticker_name>.<ext>
*
* Examples:
* hearts__pink_heart.png → category: Hearts, name: Pink Heart
* pets__golden_retriever.png → category: Pets, name: Golden Retriever
*
* Adding a sticker is zero-config: drop the file in `public/stickers/`, name
* it correctly, refresh the page. No imports to edit, no manifest to update.
*
* The filename list comes from a Vite virtual module (see
* `stickerManifestPlugin` in vite.config.js). Vite's import.meta.glob can't
* see `public/`, so the plugin reads the directory at build time and emits
* the list. URLs are stable and unhashed (`/stickers/<filename>`), which
* lets the server also read these files during export — see
* `resolveImageSource` in server.js for that path.
*
* If `public/stickers/` is empty, this module exports empty arrays and the
* StickersTab renders a friendly empty state. No crash, no noisy console.
*/
import { STICKER_FILES } from 'virtual:sticker-manifest';
// Public URL base for sticker assets. The Vite plugin watches the matching
// directory on disk; the server reads from the same prefix on its side.
const STICKER_URL_PREFIX = '/stickers/';
/**
* Parse a filename into display metadata.
*
* Filename shape: `[<sort>-]<category>__<name>.<ext>` where:
* - <sort> (optional) leading "<digits>-" prefix used only for category ordering
* - <category> lowercase word, no underscores within
* - <name> one or more underscore-separated words
*
* Returns null for files that don't match (e.g. files lacking `__`). Returning
* null rather than throwing lets the module evaluate cleanly even if someone
* accidentally drops a stray file into the folder.
*/
function parseStickerFilename(filename) {
const dotIdx = filename.lastIndexOf('.');
if (dotIdx <= 0) return null;
const base = filename.slice(0, dotIdx);
// Optional "<digits>-" prefix for explicit ordering.
let sortKey = '';
let rest = base;
const dashMatch = base.match(/^(\d+)-(.+)$/);
if (dashMatch) {
// Pad so '2' sorts before '10' lexicographically.
sortKey = dashMatch[1].padStart(6, '0');
rest = dashMatch[2];
}
// Split on the DOUBLE underscore. Exactly one expected; if missing or
// duplicated, the file isn't a valid sticker.
const parts = rest.split('__');
if (parts.length !== 2) return null;
const [categorySlug, nameSlug] = parts;
if (!categorySlug || !nameSlug) return null;
return {
categoryId: categorySlug.toLowerCase(),
categoryLabel: titleCase(categorySlug),
name: titleCase(nameSlug),
sortKey,
};
}
function titleCase(slug) {
return slug
.split('_')
.filter(Boolean)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
.join(' ');
}
/**
* Build the sticker list. One pass over the filename list from the virtual
* module; each valid file becomes a sticker with `{ id, url, name,
* categoryId, categoryLabel }`.
*
* `id` is derived from the URL because the URL is unique per file and stable
* enough across reloads to use as a React key. We don't persist sticker ids
* anywhere — once a sticker is added to the canvas, the relevant identity is
* the canvas element's id, not the source sticker's.
*/
function buildStickerList() {
const out = [];
for (const filename of STICKER_FILES) {
const parsed = parseStickerFilename(filename);
if (!parsed) continue;
const url = STICKER_URL_PREFIX + filename;
out.push({
id: url,
url,
name: parsed.name,
categoryId: parsed.categoryId,
categoryLabel: parsed.categoryLabel,
sortKey: parsed.sortKey,
});
}
return out;
}
const _all = buildStickerList();
/**
* All sticker entries, sorted by (category sortKey, sticker name). The
* category sort key is derived from the lowest `sortKey` seen for that
* category — if any file in the category has a `<digits>-` prefix, the
* whole category gets ordered by it; otherwise alphabetical.
*/
export const STICKERS = (() => {
// Resolve a stable sort key per category: lowest seen `sortKey` (already
// zero-padded) for files in that category. Falls back to the category
// label for purely alphabetical ordering.
const catKey = new Map();
for (const s of _all) {
const existing = catKey.get(s.categoryId);
if (!existing || (s.sortKey && (!existing.sortKey || s.sortKey < existing.sortKey))) {
catKey.set(s.categoryId, { sortKey: s.sortKey || '', label: s.categoryLabel });
}
}
return [..._all].sort((a, b) => {
const ka = catKey.get(a.categoryId);
const kb = catKey.get(b.categoryId);
const keyA = (ka.sortKey || 'zzz') + '|' + ka.label.toLowerCase();
const keyB = (kb.sortKey || 'zzz') + '|' + kb.label.toLowerCase();
if (keyA !== keyB) return keyA < keyB ? -1 : 1;
return a.name.localeCompare(b.name);
});
})();
/**
* Deduped list of categories, in display order. Each entry is
* `{ id, label }`. The Stickers tab adds an implicit "All" pill at the
* front itself — that's a tab concern, not a data one.
*/
export const STICKER_CATEGORIES = (() => {
const seen = new Set();
const out = [];
for (const s of STICKERS) {
if (seen.has(s.categoryId)) continue;
seen.add(s.categoryId);
out.push({ id: s.categoryId, label: s.categoryLabel });
}
return out;
})();

View File

@@ -1,54 +0,0 @@
/**
* Text-rendering constants shared across measurement, bounds, and render code.
*
* Before this module: `1.2`, `12`, and similar magic numbers were repeated
* across `textGeometry.js`, `elementBounds.js`, and `TextElement.jsx`. If
* Konva's defaults ever changed (or the design system picked a different
* line-height ratio), every site had to be updated in lockstep — and there
* was no way to grep for "the line-height ratio" since it was just `1.2`.
*
* Centralizing here keeps those callers honest. New text-related constants
* that show up in 2+ places should land here.
*
* Note on per-component font-family defaults
* ──────────────────────────────────────────
* `TextElement.jsx` defaults `fontFamily = 'DM Sans'` (the body face) for
* its prop. `textGeometry.js` defaulted to `'Pacifico'` (the display face)
* for its measurement-fallback path. Those are intentionally different —
* an element rendered without a fontFamily uses DM Sans, but the
* measurement helpers fall back to the display face that matches new-text
* placement defaults. We keep that distinction by exposing both names
* here and letting each call site pick the one that matches its semantics.
*/
// Konva's default line-height ratio. Multiply by fontSize to get the
// rendered height of a single text line.
export const LINE_HEIGHT_RATIO = 1.2;
// Floor for fontSize during scale-down operations. Below this, text becomes
// hard to read and the rounded display in the Text tab starts losing
// precision (12 → 11 → 10 each cost ~10% of legibility).
export const MIN_FONT_SIZE = 12;
// Ceiling for auto-computed fontSize values (e.g. width-preservation
// when swapping to a narrower font). The Text tab's slider tops out at
// 120 for typical fine-tuning, but width-preservation can legitimately
// land above that when going from a wide display face to a very
// condensed one. 200 gives plenty of headroom (about 2/3 of the
// 300-design-unit canvas height) while preventing pathological growth
// on a font whose measureText returns near-zero for a particular glyph.
// The user can still corner-drag the text on canvas past this value;
// the cap only governs the auto-preserve math.
export const MAX_FONT_SIZE = 200;
// Default fontSize when an element doesn't specify one. Matches the
// historical TEXT default in textGeometry.placeTextCentered.
export const DEFAULT_FONT_SIZE = 32;
// Display face used by the freeform "Add text" button and as the
// measurement fallback when an element has no explicit fontFamily.
export const DEFAULT_DISPLAY_FONT = 'Pacifico';
// Body face used by TextElement's prop default (so Konva renders something
// reasonable when the prop is omitted).
export const DEFAULT_BODY_FONT = 'DM Sans';

View File

@@ -1,62 +0,0 @@
/**
* Konva Transformer style + behavior shared by ImageElement and TextElement.
*
* Before this module: both files independently declared identical
* `ROTATION_SNAPS_15` arrays and identical visual config (anchor size /
* stroke / fill / corner radius / rotate-anchor offset). Two sites with
* the same constants is a drift risk — and the brand pink (#ec4899) was
* spelled as a literal in both rather than referenced from anywhere
* central.
*
* Centralizing here means changing the brand pink, the snap granularity,
* or the anchor look-and-feel is a one-file edit. Per-element behavior
* (the bound functions, lock state, ratio-keep logic) stays at the call
* site since those legitimately differ.
*/
// Pre-computed rotation snap angles — every 15°. Used when the user holds
// shift while rotating, so multi-element layouts can be aligned to common
// angles without pixel-perfect dragging. We exclude 360 because Konva's
// Transformer normalizes 360 → 0 anyway.
export const ROTATION_SNAPS_15 = (() => {
const out = [];
for (let a = 0; a < 360; a += 15) out.push(a);
return out;
})();
// Angular window inside which Konva pulls the rotation to the nearest
// snap when shift is held. 7° gives a comfortable "magnetic" feel
// without making it hard to land between snaps.
export const ROTATION_SNAP_TOLERANCE = 7;
// Corner-only anchors. Combined with `keepRatio` on the consumer this
// gives uniform scaling that always preserves aspect ratio — what we
// want for both photos (no squish) and text (no letter-stretching).
export const CORNER_ANCHORS = ['top-left', 'top-right', 'bottom-left', 'bottom-right'];
// Visual style for selection bbox + handles. Spread into the Transformer
// element at the call site. `borderDash` is intentionally NOT here
// because it varies (solid for unlocked, dashed when locked), and is a
// per-render decision.
//
// `rotateAnchorOffset` controls the distance (in design-coord px) between
// the top edge of the bbox and the rotate handle. Was 28 originally,
// which created a visible gap between the element and its rotate handle
// that read as "the rotate handle belongs to no one" — especially after
// the move to a dedicated transformer layer, where the handle floats
// above unrelated content in z-order and the long offset made the
// association ambiguous. 14 keeps the handle clearly outside the bbox
// border (so it doesn't overlap a corner anchor at typical zoom levels)
// while making the "this handle controls THIS element" association
// unambiguous. Reduce further only if corner anchors and the rotate
// handle start visually colliding for very small elements; the current
// value tested fine across the editor's MIN_ELEMENT_SIZE (20px) range.
export const TRANSFORMER_VISUAL_STYLE = {
anchorSize: 9,
anchorCornerRadius: 6,
borderStroke: '#ec4899',
borderStrokeWidth: 1.5,
anchorStroke: '#ec4899',
anchorFill: '#ffffff',
rotateAnchorOffset: 14,
};

View File

@@ -1,4 +0,0 @@
export { useDesignEditor } from './useDesignEditor';
export { useBackgroundRemoval } from './useBackgroundRemoval';
export { useExport } from './useExport';
export { useTemplate } from './useTemplate';

View File

@@ -1,144 +0,0 @@
import { useState, useCallback, useRef } from 'react';
import { AutoModel, AutoProcessor, RawImage, env } from '@huggingface/transformers';
export function useBackgroundRemoval() {
const [loading, setLoading] = useState(false);
const [progress, setProgress] = useState(0);
const [hasModel, setHasModel] = useState(false);
const modelRef = useRef(null);
const processorRef = useRef(null);
const loadModel = useCallback(async () => {
if (modelRef.current && processorRef.current) return true;
setLoading(true);
setProgress(0);
try {
// Reduce ONNX Runtime Web console noise (node assignment warnings, etc.).
// Note: depending on ONNX Runtime build/version, some warnings may still appear.
env.backends.onnx.logLevel = 'error';
modelRef.current = await AutoModel.from_pretrained('briaai/RMBG-1.4', {
dtype: 'q8',
device: navigator.gpu ? 'webgpu' : 'wasm',
progress_callback: (p) => {
if (p.progress != null) setProgress(Math.round(p.progress * 50));
},
});
processorRef.current = await AutoProcessor.from_pretrained('briaai/RMBG-1.4');
setProgress(50);
setLoading(false);
setHasModel(true);
return true;
} catch (error) {
console.error('Failed to load background removal model:', error);
setLoading(false);
return false;
}
}, []);
const removeBackground = useCallback(async (imageSrc) => {
if (!modelRef.current || !processorRef.current) {
const loaded = await loadModel();
if (!loaded) return null;
}
setLoading(true);
setProgress(50);
try {
const image = await RawImage.fromURL(imageSrc);
const { pixel_values } = await processorRef.current(image);
setProgress(70);
const { output } = await modelRef.current({ input: pixel_values });
setProgress(90);
const mask = await RawImage.fromTensor(
output[0].mul(255).to('uint8')
).resize(image.width, image.height);
const canvas = document.createElement('canvas');
canvas.width = image.width;
canvas.height = image.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(await image.toCanvas(), 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
for (let i = 0; i < mask.data.length; i++) {
imageData.data[i * 4 + 3] = mask.data[i];
}
ctx.putImageData(imageData, 0, 0);
// Tighten the result to its visible (non-transparent) pixels so the
// returned image's bounding box is the subject, not the original
// photo's mostly-transparent canvas. Callers use the returned `bbox`
// to shrink the element's transform handles around the remaining
// content. ALPHA_THRESHOLD = 8 (≈3% opacity) skips near-invisible
// model noise at edges without clipping visible details.
const W = canvas.width;
const H = canvas.height;
const ALPHA_THRESHOLD = 8;
const pixels = imageData.data;
let minX = W;
let minY = H;
let maxX = -1;
let maxY = -1;
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
if (pixels[(y * W + x) * 4 + 3] > ALPHA_THRESHOLD) {
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
}
let resultCanvas = canvas;
let bbox;
if (maxX < 0) {
// Entirely transparent — the model decided the whole image was
// background. Return the (empty) full canvas rather than a 0×0
// crop, so the caller can still produce a sensible element
// update if it wants to.
bbox = { sx: 0, sy: 0, sw: W, sh: H, srcWidth: W, srcHeight: H };
} else {
const cropW = maxX - minX + 1;
const cropH = maxY - minY + 1;
if (cropW !== W || cropH !== H) {
resultCanvas = document.createElement('canvas');
resultCanvas.width = cropW;
resultCanvas.height = cropH;
resultCanvas.getContext('2d').drawImage(canvas, -minX, -minY);
}
bbox = { sx: minX, sy: minY, sw: cropW, sh: cropH, srcWidth: W, srcHeight: H };
}
setProgress(100);
setLoading(false);
return {
url: resultCanvas.toDataURL('image/png'),
bbox,
};
} catch (error) {
console.error('Background removal failed:', error);
setLoading(false);
return null;
}
}, [loadModel]);
return {
loading,
progress,
hasModel,
loadModel,
removeBackground,
};
}

View File

@@ -1,214 +0,0 @@
import { useEffect, useRef, useState } from 'react';
/**
* Produce a data URL for the cropped sub-region of an image element,
* suitable for use as the `src` of a thumbnail <img> in the layers
* panel (or anywhere else we render a small representative bitmap of
* a cropped photo).
*
* Why this exists
* ───────────────
* Image elements with a crop applied store the crop separately from
* the source pixels:
*
* element.src = URL of the ORIGINAL (uncropped) image
* element.crop = { sx, sy, sWidth, sHeight } // sub-region to render
*
* Konva.Image consumes both via its native `crop` attr — it renders
* only the sub-region. But anywhere else we display the image as a
* plain HTML <img> (the LayersPanel thumbnail, the recent-uploads
* thumbnails), `<img src={element.src} />` shows the WHOLE original,
* not the crop. The user sees their cropped photo on canvas but the
* uncropped version in the layers panel, which is confusing — the
* layer-panel thumbnail is meant to read as "this is the layer I'm
* looking at," and if the cropped face becomes a tiny corner of an
* un-cropped landscape in the thumbnail, the user can't pick out the
* layer at a glance.
*
* This hook closes the gap by rendering the cropped sub-region into
* an offscreen <canvas> and returning its toDataURL(). The data URL
* is a drop-in replacement for `element.src` in any <img> we render.
*
* Fast path
* ─────────
* Elements WITHOUT a crop bypass canvas work entirely — the hook
* returns `element.src` as-is. There's no perf cost for the common
* case of uncropped photos. Canvas work only runs when:
*
* 1. The element has a non-null `element.crop`, AND
* 2. The (src, crop) signature differs from the last successful
* render. The signature lives on the ref alongside the data URL
* so re-renders triggered by unrelated state changes don't redo
* the rasterization.
*
* Returns
* ───────
* The string passed back is always usable as an <img src>. While the
* canvas render is in flight we return the previous successful URL
* (or `element.src` if there isn't one yet) so the thumbnail doesn't
* flicker to an empty placeholder mid-update. On error (image fails
* to load, taint from cross-origin, etc.) we fall back to
* `element.src` and let the browser show whatever it would have
* shown before this hook existed.
*
* Implementation notes
* ────────────────────
* • The image is loaded with `crossOrigin = 'anonymous'` so the
* canvas remains untainted and `toDataURL()` succeeds for both
* local blob: URLs and remote https URLs (when the server sends
* permissive CORS headers, which our upload backend does). If a
* future URL source is added that doesn't support CORS, the catch
* handler quietly falls back to the original src — no crash, just
* no crop in the thumbnail for that one image.
*
* • Output dimensions are clamped via the `maxSize` parameter (default
* 96px) so the data URL stays small even for high-resolution source
* images. The thumbnail viewport is 24px square, so 96px is 4x the
* display size — enough resolution for HiDPI screens without
* bloating the data URL.
*
* • The aspect ratio of the output matches the crop region's aspect
* ratio (sWidth / sHeight). The <img> in the layers panel uses
* `object-fit: cover`, so a non-square crop will be center-cropped
* inside the square 24px viewport. That matches how Konva renders
* the cropped photo on canvas (the canvas shows the entire crop
* region within the element's bounds), so the thumbnail and the
* on-canvas photo share visual content even if neither's aspect
* matches the row's icon slot exactly.
*
* • An AbortController-style cancellation flag handles unmount during
* in-flight loads: setState after unmount is a memory leak warning
* in dev and an undefined-behavior risk in production. The
* cancelled-flag pattern is the lightest-weight way to short-circuit
* the load-then-setState chain without requiring AbortController
* support on the underlying `new Image()` API (which doesn't
* accept one).
*/
export function useCroppedThumbnail(element, maxSize = 96) {
const [thumb, setThumb] = useState(() => {
// Initial value: if there's no crop, the source IS the thumbnail.
// If there is a crop, return null so the consumer can show a
// placeholder or fall back until the canvas render completes.
if (!element) return null;
if (!element.crop || !element.src) return element.src ?? null;
return element.src ?? null;
});
// Signature cache. Avoids redoing canvas work when the consumer
// re-renders for reasons unrelated to the cropped image — e.g. the
// selection state changed, or a sibling layer was added. Keyed on
// (src, crop) because the same source can be cropped differently
// across duplicated layers, and the same crop value can be applied
// to a freshly-uploaded source.
const lastRenderRef = useRef(null);
useEffect(() => {
if (!element) return undefined;
const { src, crop } = element;
// No source → nothing to render. Clear state so a stale thumb
// from a previous element doesn't leak across.
if (!src) {
setThumb(null);
lastRenderRef.current = null;
return undefined;
}
// No crop → the source IS the thumbnail. Bypass canvas entirely.
if (!crop) {
setThumb(src);
lastRenderRef.current = null; // clear so a future crop re-renders
return undefined;
}
// Build the signature. Object equality on crop would force a
// re-render every time the parent re-renders (new object each
// time), but the actual values rarely change — stringifying
// gives us value equality without committing to a deep-equal
// helper.
const signature = `${src}::${crop.sx}::${crop.sy}::${crop.sWidth}::${crop.sHeight}`;
if (lastRenderRef.current?.signature === signature) {
// Already rendered this exact (src, crop) combination. The
// cached data URL is in state already; nothing to do.
return undefined;
}
let cancelled = false;
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
if (cancelled) return;
try {
// Compute output dimensions: keep the crop's aspect ratio,
// clamp the longest side to maxSize. Going smaller than the
// source crop is fine — the canvas downsamples cleanly. Going
// LARGER would blow up file pixels with no visual benefit.
const sW = Math.max(1, crop.sWidth);
const sH = Math.max(1, crop.sHeight);
const scale = Math.min(1, maxSize / Math.max(sW, sH));
const outW = Math.max(1, Math.round(sW * scale));
const outH = Math.max(1, Math.round(sH * scale));
const canvas = document.createElement('canvas');
canvas.width = outW;
canvas.height = outH;
const ctx = canvas.getContext('2d');
if (!ctx) {
// No 2D context (vanishingly rare — Safari private mode in
// some old versions, headless browsers without canvas). Fall
// back to the original src so the thumbnail still shows
// SOMETHING, just uncropped.
setThumb(src);
return;
}
// drawImage(sx, sy, sW, sH, dx, dy, dW, dH) — the 9-arg form
// maps a source sub-rect onto a destination rect. This is
// exactly the same math Konva does internally via the `crop`
// attribute on Image nodes, so the thumbnail content matches
// the on-canvas photo for any crop value.
ctx.drawImage(img, crop.sx, crop.sy, sW, sH, 0, 0, outW, outH);
const dataUrl = canvas.toDataURL('image/png');
lastRenderRef.current = { signature, dataUrl };
setThumb(dataUrl);
} catch {
// Canvas was tainted (cross-origin without CORS), toDataURL
// failed, or some other unexpected failure. Fall back to the
// original source so the thumbnail still renders SOMETHING —
// just without the crop applied. Better degradation than a
// missing image.
if (!cancelled) setThumb(src);
}
};
img.onerror = () => {
// Image failed to load entirely. Fall back to the raw src; the
// <img> tag in the consumer will surface the same load error
// there. The hook's job is to provide a string; whether it
// actually loads is the renderer's problem.
if (!cancelled) setThumb(src);
};
img.src = src;
return () => {
cancelled = true;
};
// We deliberately depend on the granular crop fields rather than
// the crop object itself — the App-level state replaces the crop
// object on every update, so depending on `element.crop`
// (reference) would re-run the effect every render. Field-level
// deps re-run only when the actual values change.
}, [
element?.src,
element?.crop?.sx,
element?.crop?.sy,
element?.crop?.sWidth,
element?.crop?.sHeight,
maxSize,
]);
return thumb;
}

View File

@@ -1,592 +0,0 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { makeElementId } from '../utils/makeId';
const MAX_HISTORY = 50;
const DEBOUNCE_DELAY_MS = 300;
// ────────────────────────────────────────────────────────────────────
// Diagnostic history logging.
//
// Enable by running this in the browser console, then reload:
// localStorage.setItem('paw_debug_history', '1')
// Disable with:
// localStorage.removeItem('paw_debug_history')
//
// Every line is prefixed `[history]` so you can filter in DevTools.
// Designed specifically for diagnosing the crop+undo "canvas ends up
// blank" bug: when undo lands on an unexpected snapshot, this log
// reveals which push got missed or deduped, or whether history was
// wiped between the action and the undo.
//
// Each event logs `{ idx, len, source, ...details }` so a single
// scroll-through tells you:
// • What the index was at each step — if undo walks from N to N-1
// and N-1 is unexpectedly an empty snapshot, the wipe happened
// earlier in the log.
// • What the history length was — truncations (redo-stack discard)
// and the MAX_HISTORY shift are visible.
// • Where the call came from — every push includes `source` so you
// can tell addElement vs replaceElements vs updateAndCommit vs
// the debounced updateElement timer apart.
// ────────────────────────────────────────────────────────────────────
const HISTORY_DEBUG = (() => {
try {
return typeof localStorage !== 'undefined'
&& localStorage.getItem('paw_debug_history') === '1';
} catch {
return false;
}
})();
// Short summary of an elements array — we don't dump the full JSON in
// every log line because it's huge and obscures the signal. We do show
// element count and first-three IDs, which together are enough to
// distinguish "the image array", "the cropped array", and "empty"
// in a glance.
function summarizeElements(arr) {
if (!Array.isArray(arr)) return '<not-array>';
if (arr.length === 0) return '[] (empty)';
const ids = arr.slice(0, 3).map((el) => el?.id ?? '?').join(', ');
const tail = arr.length > 3 ? `, …+${arr.length - 3}` : '';
return `${arr.length} els [${ids}${tail}]`;
}
function hlog(event, info) {
if (!HISTORY_DEBUG) return;
// Using a single console.log call (not console.group) keeps the lines
// copyable as a single block when the user wants to share the log.
// The leading prefix is intentionally verbose so it survives DevTools
// filter-by-text searches even when the rest of the line wraps.
// eslint-disable-next-line no-console
console.log(`[history] ${event}`, info);
}
export function useDesignEditor() {
const [elements, setElements] = useState([]);
// Multi-select state (S3). The authoritative selection is a Set of element
// ids. Single-selection callers read `selectedId` (a derived value that's
// non-null only when exactly one element is selected) and use it like
// before — that path is unchanged. Multi-select callers (LayersPanel) read
// `selectedIds` directly.
const [selectedIds, setSelectedIds] = useState(() => new Set());
const selectedId = selectedIds.size === 1 ? [...selectedIds][0] : null;
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);
const historyRef = useRef([]);
const historyIndexRef = useRef(-1);
const historyTimerRef = useRef(null);
const pendingChangesRef = useRef(null);
const syncUndoRedo = useCallback(() => {
setCanUndo(historyIndexRef.current > 0);
setCanRedo(historyIndexRef.current < historyRef.current.length - 1);
}, []);
// `source` is a label identifying who called us (addElement,
// replaceElements, updateAndCommit, flushPendingChanges-timer, etc.).
// It's only consulted by the diagnostic logger — production behaviour
// is unchanged. Defaults to '?' so callers that don't bother to pass
// it still work.
const saveToHistory = useCallback((newElements, source = '?') => {
const json = JSON.stringify(newElements);
const beforeIdx = historyIndexRef.current;
const beforeLen = historyRef.current.length;
// Dedupe consecutive identical snapshots. The primary motivation is React
// StrictMode, which double-invokes the setElements updater in dev to detect
// impure updaters; the inner saveToHistory side-effect runs twice with the
// same input and used to push two identical entries (so undo had to be
// pressed twice to undo one action). This dedupe is also a real correctness
// improvement outside StrictMode — calling saveToHistory with no actual
// change is a no-op rather than a bloated history slot.
if (historyIndexRef.current >= 0 && historyRef.current[historyIndexRef.current] === json) {
hlog('saveToHistory DEDUPE', {
source,
idx: beforeIdx,
len: beforeLen,
snapshot: summarizeElements(newElements),
reason: 'identical to current entry',
});
return;
}
const willTruncate = historyIndexRef.current < historyRef.current.length - 1;
if (willTruncate) {
historyRef.current = historyRef.current.slice(0, historyIndexRef.current + 1);
}
historyRef.current.push(json);
let shifted = false;
if (historyRef.current.length > MAX_HISTORY) {
historyRef.current.shift();
shifted = true;
} else {
historyIndexRef.current++;
}
syncUndoRedo();
hlog('saveToHistory PUSH', {
source,
idx: `${beforeIdx}${historyIndexRef.current}`,
len: `${beforeLen}${historyRef.current.length}`,
snapshot: summarizeElements(newElements),
truncated: willTruncate ? `dropped ${beforeLen - 1 - beforeIdx} redo entries` : false,
shifted: shifted ? `MAX_HISTORY exceeded; oldest entry dropped` : false,
});
}, [syncUndoRedo]);
const flushPendingChanges = useCallback(() => {
if (pendingChangesRef.current) {
hlog('flushPendingChanges', {
reason: 'draining pending debounced updateElement',
snapshot: summarizeElements(pendingChangesRef.current),
});
saveToHistory(pendingChangesRef.current, 'flushPendingChanges');
pendingChangesRef.current = null;
}
if (historyTimerRef.current) { clearTimeout(historyTimerRef.current); historyTimerRef.current = null; }
}, [saveToHistory]);
useEffect(() => { return () => { if (historyTimerRef.current) clearTimeout(historyTimerRef.current); }; }, []);
const makeId = makeElementId;
const addElement = useCallback((element) => {
flushPendingChanges();
const newElement = { ...element, id: makeId() };
hlog('addElement', { type: element?.type, newId: newElement.id });
setElements((prev) => {
const newElements = [...prev, newElement];
saveToHistory(newElements, 'addElement');
return newElements;
});
setSelectedIds(new Set([newElement.id]));
return newElement.id;
}, [flushPendingChanges, saveToHistory]);
// Add many elements as a single history entry.
// Used for template loading so an undo collapses the whole template back at once,
// and so the elements appear atomically rather than racing each other through
// setTimeout-based scheduling.
//
// ID handling: preserves the input element's `id` if present, generates a
// fresh one if not. See replaceElements below for the rationale on why we
// preserve rather than regenerate — the short version is that regeneration
// breaks saveToHistory's dedupe when StrictMode invokes the path twice.
const addElements = useCallback((items) => {
if (!items || items.length === 0) return [];
flushPendingChanges();
const newOnes = items.map((el, i) => ({
...el,
id: el?.id || makeElementId(i),
}));
setElements((prev) => { const newElements = [...prev, ...newOnes]; saveToHistory(newElements); return newElements; });
setSelectedIds(new Set());
return newOnes.map((e) => e.id);
}, [flushPendingChanges, saveToHistory]);
// Replace all elements in one history entry.
//
// Used for two flows:
// 1. Template loading (App.jsx restore effect, the urlTemplateValid /
// no-saved-state branch). Template constants ship elements WITHOUT
// ids — we generate them here.
// 2. Session restoration (App.jsx restore effect, the saved-state
// branch). Restored elements come from localStorage with their
// original ids intact — we preserve them.
//
// ID handling: preserves the input element's `id` if present, generates a
// fresh one with `makeElementId(i)` if not.
//
// Why preserve rather than always regenerate
// ───────────────────────────────────────────────────
// Pre-May 23 2026 this function unconditionally regenerated all ids via
// `makeElementId(i)` (which embeds Date.now()). That made the function
// non-idempotent: two calls with the same input produced two outputs with
// DIFFERENT ids (different millisecond timestamps).
//
// The visible consequence: React 18 StrictMode artificially double-mounts
// in dev, so the session-restoration effect ran twice, both calls to
// replaceElements pushed snapshots, and saveToHistory's dedupe couldn't
// catch them because their JSON differed (different ids). Result: two
// history entries for the same visual state, so undoing a crop required
// two Cmd+Z presses in dev to walk past both restoration entries.
//
// Preserving input ids when present makes the function idempotent for the
// session-restoration path — second call produces the SAME JSON, dedupe
// catches it, single history entry. Template loading still generates
// fresh ids because template constants don't ship with them; that path
// doesn't trip the dedupe regression because templates are loaded once
// per URL navigation, not by an effect that StrictMode double-runs.
//
// The fallback to `makeElementId(i)` rather than throwing on missing id
// is deliberate: callers shouldn't need to know whether their inputs
// have ids, just hand us elements and let us sort it out.
const replaceElements = useCallback((items) => {
flushPendingChanges();
const next = (items || []).map((el, i) => ({
...el,
id: el?.id || makeElementId(i),
}));
hlog('replaceElements', { snapshot: summarizeElements(next) });
setElements(next);
saveToHistory(next, 'replaceElements');
setSelectedIds(new Set());
return next.map((e) => e.id);
}, [flushPendingChanges, saveToHistory]);
const updateElement = useCallback((id, attrs) => {
setElements((prev) => {
const newElements = prev.map((el) => (el.id === id ? { ...el, ...attrs } : el));
pendingChangesRef.current = newElements;
if (historyTimerRef.current) clearTimeout(historyTimerRef.current);
historyTimerRef.current = setTimeout(() => { flushPendingChanges(); }, DEBOUNCE_DELAY_MS);
return newElements;
});
// Logged AFTER the setElements call so the timer reset is visible
// — a rapid burst of updates collapses into a single history push
// when the timer finally fires, and this log helps confirm that.
hlog('updateElement (debounced)', { id, attrs: Object.keys(attrs || {}) });
}, [flushPendingChanges]);
// Immediate-commit variant of updateElement — applies the change AND
// pushes the new state to history within the same React commit, no
// debounce. Use this for atomic one-shot mutations where the caller
// is certain the change is final (crop apply, photo-edit complete,
// etc.) and needs the history snapshot to be available right away.
//
// Why this exists (the bug that motivated it)
// ───────────────────────────────────────────────
// The natural pattern `updateElement(...); commitHistory();` doesn't
// work for synchronous immediate-commit, because updateElement uses
// a debounced path: it queues a setElements updater that sets
// pendingChangesRef + schedules a 300ms timer. React doesn't run
// the updater synchronously — it runs it during the next commit
// phase — so when commitHistory's flushPendingChanges runs in the
// same synchronous block, pendingChangesRef is still null and the
// flush is a no-op. The actual history push happens 300ms later
// via the timer.
//
// If the user clicks Undo within that 300ms window, undo() walks
// historyIndexRef back to the previous snapshot — which is the
// PRE-change state. The post-change snapshot was never written, so
// the user appears to lose more state than they undid.
//
// For the crop-apply case specifically, the user could legitimately
// click Apply then immediately Undo to undo an accidental crop. With
// the debounce-then-flush path, that produced a confusing "undo wiped
// everything" outcome because the post-crop snapshot was never
// captured.
//
// This method follows addElement's pattern (saveToHistory inside the
// updater) so the history push happens during React's commit, before
// any subsequent user event can fire undo().
const updateAndCommit = useCallback((id, attrs) => {
hlog('updateAndCommit', { id, attrs: Object.keys(attrs || {}) });
flushPendingChanges();
setElements((prev) => {
const newElements = prev.map((el) => (el.id === id ? { ...el, ...attrs } : el));
saveToHistory(newElements, 'updateAndCommit');
return newElements;
});
}, [flushPendingChanges, saveToHistory]);
const deleteElement = useCallback((id) => {
flushPendingChanges();
hlog('deleteElement', { id });
setElements((prev) => {
const newElements = prev.filter((el) => el.id !== id);
saveToHistory(newElements, 'deleteElement');
return newElements;
});
setSelectedIds((prev) => {
if (!prev.has(id)) return prev;
const next = new Set(prev);
next.delete(id);
return next;
});
}, [flushPendingChanges, saveToHistory]);
// Bulk-delete (S3). Wipes every id in `ids` from the elements array as a
// single history entry, and clears them from the selection set. No-op if
// `ids` is empty.
const deleteMany = useCallback((ids) => {
if (!ids || ids.length === 0) return;
const idSet = new Set(ids);
flushPendingChanges();
hlog('deleteMany', { count: ids.length });
setElements((prev) => {
const next = prev.filter((el) => !idSet.has(el.id));
saveToHistory(next, 'deleteMany');
return next;
});
setSelectedIds((prev) => {
let changed = false;
const next = new Set(prev);
for (const id of ids) {
if (next.delete(id)) changed = true;
}
return changed ? next : prev;
});
}, [flushPendingChanges, saveToHistory]);
// Duplicate the given element. The copy lands a few pixels offset so it's
// visually distinct from the original, and becomes the new selection so the
// user can immediately drag/edit it.
//
// Both the existence check AND the new-id generation happen synchronously
// BEFORE setElements is called. The previous version of this function set a
// `didDuplicate` flag inside the setElements updater — but in React 18,
// updater functions run asynchronously when the update queue is processed,
// not at the call site. So `if (didDuplicate)` observed the stale (false)
// value, and selection never flipped to the copy.
const duplicateElement = useCallback((id) => {
flushPendingChanges();
const sourceIdx = elements.findIndex((e) => e.id === id);
if (sourceIdx === -1) return null;
const newId = makeId();
hlog('duplicateElement', { sourceId: id, newId });
setElements((prev) => {
const idx = prev.findIndex((e) => e.id === id);
if (idx === -1) return prev;
const original = prev[idx];
const copy = {
...original,
id: newId,
x: (original.x || 0) + 12,
y: (original.y || 0) + 12,
// Fresh slot relationship: a duplicated element shouldn't claim the
// same template slot as its source — that would double-fill the slot.
slotId: undefined,
};
// Insert immediately above the source so it visually appears on top.
const next = [...prev.slice(0, idx + 1), copy, ...prev.slice(idx + 1)];
saveToHistory(next, 'duplicateElement');
return next;
});
setSelectedIds(new Set([newId]));
return newId;
}, [elements, flushPendingChanges, saveToHistory]);
// Z-order helpers. Konva's render order matches array order: elements later
// in the array paint on top. "Bring forward" moves the element one slot
// higher in the array; "send backward" moves it one slot lower.
const bringForward = useCallback((id) => {
flushPendingChanges();
setElements((prev) => {
const idx = prev.findIndex((e) => e.id === id);
if (idx === -1 || idx === prev.length - 1) return prev;
const next = prev.slice();
[next[idx], next[idx + 1]] = [next[idx + 1], next[idx]];
saveToHistory(next, 'bringForward');
return next;
});
}, [flushPendingChanges, saveToHistory]);
const sendBackward = useCallback((id) => {
flushPendingChanges();
setElements((prev) => {
const idx = prev.findIndex((e) => e.id === id);
if (idx <= 0) return prev;
const next = prev.slice();
[next[idx], next[idx - 1]] = [next[idx - 1], next[idx]];
saveToHistory(next, 'sendBackward');
return next;
});
}, [flushPendingChanges, saveToHistory]);
// Drag-reorder (S3). Moves the source element to immediately before or
// after the target in the elements array, producing a single history
// entry. The LayersPanel renders top-down (last-in-array on top), so
// "before" in the panel UI means "after" in the array, and vice-versa
// — the caller is responsible for that translation. We just take the
// semantic position and apply it directly.
const reorderElement = useCallback((sourceId, targetId, position = 'after') => {
if (sourceId === targetId) return;
flushPendingChanges();
setElements((prev) => {
const sourceIdx = prev.findIndex((e) => e.id === sourceId);
const targetIdx = prev.findIndex((e) => e.id === targetId);
if (sourceIdx === -1 || targetIdx === -1) return prev;
const next = prev.slice();
const [source] = next.splice(sourceIdx, 1);
// After the splice, the target's index may have shifted by one if
// it was after the source's original position. Re-find by id.
const newTargetIdx = next.findIndex((e) => e.id === targetId);
if (newTargetIdx === -1) return prev;
const insertAt = position === 'before' ? newTargetIdx : newTargetIdx + 1;
next.splice(insertAt, 0, source);
saveToHistory(next, 'reorderElement');
return next;
});
}, [flushPendingChanges, saveToHistory]);
// Selection setters. `selectElement` and `deselectAll` keep the same
// single-select semantics as before. `toggleInSelection` is the
// multi-select primitive: toggles the id's membership in the set.
// `setSelection` replaces the entire set with a fresh one built from
// the provided id array — used by canvas marquee drag and the Cmd+A
// "select all" keyboard shortcut to commit a many-id selection in a
// single state update (vs. clearing + toggling N times, which would
// produce N+1 renders and could flicker the Transformer's bound nodes
// mid-update).
const selectElement = useCallback((id) => {
setSelectedIds(id ? new Set([id]) : new Set());
}, []);
const deselectAll = useCallback(() => setSelectedIds(new Set()), []);
const toggleInSelection = useCallback((id) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const setSelection = useCallback((ids) => {
setSelectedIds(new Set(ids || []));
}, []);
const commitHistory = useCallback(() => flushPendingChanges(), [flushPendingChanges]);
// Undo / redo restore the elements snapshot at the new history index AND
// try to preserve selection. The previous version unconditionally cleared
// selection, which felt punishing — undo a property tweak and you'd lose
// your selection too, so refining further required reselecting first.
//
// We preserve any ids in the current selection that still exist in the
// restored snapshot. Selections of already-deleted elements naturally
// drop out.
const undo = useCallback(() => {
if (historyIndexRef.current > 0) {
const beforeIdx = historyIndexRef.current;
historyIndexRef.current--;
const next = JSON.parse(historyRef.current[historyIndexRef.current]);
hlog('undo', {
idx: `${beforeIdx}${historyIndexRef.current}`,
len: historyRef.current.length,
restored: summarizeElements(next),
});
setElements(next);
setSelectedIds((prev) => {
const liveIds = new Set(next.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;
});
syncUndoRedo();
} else {
hlog('undo NO-OP', {
idx: historyIndexRef.current,
len: historyRef.current.length,
reason: 'already at history floor',
});
}
}, [syncUndoRedo]);
const redo = useCallback(() => {
if (historyIndexRef.current < historyRef.current.length - 1) {
const beforeIdx = historyIndexRef.current;
historyIndexRef.current++;
const next = JSON.parse(historyRef.current[historyIndexRef.current]);
hlog('redo', {
idx: `${beforeIdx}${historyIndexRef.current}`,
len: historyRef.current.length,
restored: summarizeElements(next),
});
setElements(next);
setSelectedIds((prev) => {
const liveIds = new Set(next.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;
});
syncUndoRedo();
} else {
hlog('redo NO-OP', {
idx: historyIndexRef.current,
len: historyRef.current.length,
reason: 'no redo entries available',
});
}
}, [syncUndoRedo]);
// Seed an initial empty-canvas entry as the history floor IF AND ONLY
// IF history is empty. When session restoration has already pushed
// snapshots via replaceElements (URL template, saved localStorage state,
// etc.), those entries ARE the correct floor and must not be touched.
//
// History of this function
// ────────────────────────────────────────────────────────────────────────────────────
// Previously this function UNCONDITIONALLY set history to
// `[JSON.stringify([])]`, regardless of what was already there. That was
// the root cause of the "crop + Cmd+Z = blank canvas" bug for any user
// with a restored session:
//
// 1. App.jsx's persistence effect calls replaceElements(saved.elements)
// — saveToHistory pushes the restored 5-element snapshot at idx 0.
// 2. React 18 StrictMode artificially unmounts and remounts the
// component in development. The persistence effect re-runs.
// replaceElements regenerates element IDs (makeElementId uses fresh
// timestamps), so the new snapshot's JSON differs from the first
// one and saveToHistory's dedupe doesn't catch it. Second push at
// idx 1.
// 3. App.jsx's setRestorationComplete(true) effect fires once, after
// both mounts have settled. initializeHistory ran here and WIPED
// both snapshots, leaving history = [[]] at idx 0 — even though
// `elements` was still showing the 5 restored items.
// 4. User crops a photo. updateAndCommit's saveToHistory pushes the
// cropped state at idx 1. History = [[], [cropped]], idx = 1.
// 5. User presses Cmd+Z. undo walks idx 1 → 0, sets elements = [],
// blanking the canvas.
//
// The fix is the guard below: skip the seed when history already has
// entries. That keeps the restored snapshot(s) intact as the undo floor.
//
// The fresh-session case (no URL template, no saved state) still works:
// history is empty when this fires, so we push the [] floor. The first
// user action then sits at idx 1, and Cmd+Z takes the user back to the
// empty starting canvas — the expected behavior.
//
// The StrictMode duplicate snapshot (idx 0 = old-ID set, idx 1 = new-ID
// set in the example above) is a separate pre-existing oddity: in dev,
// the user's history will contain TWO entries representing the same
// visual state with different element IDs. Their first Cmd+Z lands on
// the still-uncropped state (visually correct — different IDs but the
// same picture), a second Cmd+Z lands on the OTHER uncropped state
// (visually identical), and a third is a no-op at the floor. Annoying
// in dev but harmless; in production builds StrictMode's double-mount
// doesn't run, so there's only one restored snapshot and one Cmd+Z to
// undo the crop. Fixing the dev-only duplicate would require making
// replaceElements idempotent (deterministic IDs across calls) and is
// out of scope for this fix.
const initializeHistory = useCallback(() => {
const beforeIdx = historyIndexRef.current;
const beforeLen = historyRef.current.length;
if (historyRef.current.length === 0) {
historyRef.current = [JSON.stringify([])];
historyIndexRef.current = 0;
hlog('initializeHistory SEED', {
reason: 'history was empty; seeding [] as the undo floor',
newState: 'history = [[]] (single empty entry), idx = 0',
});
} else {
hlog('initializeHistory NO-OP', {
reason: 'history already has entries; restored snapshots preserved',
idx: beforeIdx,
len: beforeLen,
currentEntry: beforeIdx >= 0
? summarizeElements(JSON.parse(historyRef.current[beforeIdx]))
: '<none>',
});
}
syncUndoRedo();
}, [syncUndoRedo]);
return {
elements, selectedId, selectedIds,
addElement, addElements, replaceElements,
updateElement, updateAndCommit, deleteElement, deleteMany,
duplicateElement, bringForward, sendBackward, reorderElement,
selectElement, deselectAll, toggleInSelection, setSelection, commitHistory,
undo, redo, canUndo, canRedo, initializeHistory,
};
}

View File

@@ -1,837 +0,0 @@
// Test suite for useDesignEditor's history subsystem.
//
// What this covers
// ────────────────
// The history system is the most bug-bitten area of the codebase. This
// file exists primarily as a regression net for those bugs:
//
// 1. The May 22 "crop + Cmd+Z = blank canvas" bug. Root cause was
// `initializeHistory` unconditionally wiping history to [empty],
// destroying snapshots that `replaceElements` had already pushed
// during session restoration. See the dedicated test block below
// titled "initializeHistory + replaceElements regression"; the
// named test there is the smoking-gun reproduction.
//
// 2. The debounce-then-undo race that motivated `updateAndCommit`'s
// existence. The `updateElement` docblock describes it: a
// debounced setElements queues a 300 ms timer; if the user clicks
// undo within that window, the post-update snapshot was never
// written and undo walks back further than intended. The
// "updateAndCommit pushes synchronously" test below is the
// regression test for the fix.
//
// 3. The `didDuplicate`-stale-flag bug in `duplicateElement` (per
// its docblock). The "duplicateElement selects the copy" test
// pins the fix.
//
// 4. The `reorderElement` splice-then-re-find: target index shifts
// after the source splice. The reorder tests cover this.
//
// What this doesn't cover
// ───────────────────────
// Anything that requires Konva or the actual canvas to be mounted —
// transformBoundFunc behavior, snap math, the visible-ink text bbox
// integration with Transformer handles. Those need a different test
// setup (Playwright or a Konva mock) and are out of scope here. This
// file is purely about the pure-state portion of useDesignEditor.
import { renderHook, act } from '@testing-library/react';
import { useDesignEditor } from './useDesignEditor';
// ────────────────────────────────────────────────────────────────────
// Test helpers.
//
// `setupEditor()` wraps renderHook + an element factory so each test
// can read like a flat sequence of editor operations rather than
// boilerplate around the act() invocations. Returns the same object
// renderHook does (so callers can access `result.current.elements`
// directly), plus the helpers we use most often.
// ────────────────────────────────────────────────────────────────────
function setupEditor() {
const view = renderHook(() => useDesignEditor());
return {
...view,
get current() {
return view.result.current;
},
/**
* Wrap any synchronous editor mutation in act() and return the
* result. Keeps individual assertions noise-free.
*/
do(fn) {
let returnValue;
act(() => {
returnValue = fn(view.result.current);
});
return returnValue;
},
/**
* Advance fake timers inside an act() boundary.
*
* Why this exists: when the debounced timer fires, its callback
* calls saveToHistory which in turn calls syncUndoRedo which
* setCanUndo/setCanRedo — a React state update. If we advance
* timers OUTSIDE act(), the state update happens outside React's
* batched-update window and triggers the "not wrapped in act"
* warning. Wrapping the advance in act() makes the timer's
* downstream setState calls visible to React's scheduler.
*
* The alternative — wrapping every advanceTimersByTime call site
* in act() inline — works but adds visual noise to each test
* body. Centralising on this helper keeps the timer tests
* scannable.
*/
advanceTimers(ms) {
act(() => {
vi.advanceTimersByTime(ms);
});
},
};
}
// Minimal element factory. We don't care about realistic image dims
// for these tests — we only need objects that flow through the
// elements pipeline and end up in JSON snapshots. Each invocation
// produces a fresh object so callers don't accidentally share
// references across snapshots (which would invalidate the dedupe
// invariant we're checking).
function makeImage(overrides = {}) {
return {
type: 'image',
src: 'data:image/png;base64,_test_',
x: 0,
y: 0,
width: 100,
height: 100,
...overrides,
};
}
function makeText(overrides = {}) {
return {
type: 'text',
text: 'hello',
fontSize: 32,
fontFamily: 'Pacifico',
x: 0,
y: 0,
...overrides,
};
}
// ────────────────────────────────────────────────────────────────────
// Initial state.
// ────────────────────────────────────────────────────────────────────
describe('useDesignEditor — initial state', () => {
it('starts with an empty elements array', () => {
const editor = setupEditor();
expect(editor.current.elements).toEqual([]);
});
it('starts with no selection', () => {
const editor = setupEditor();
expect(editor.current.selectedId).toBeNull();
expect(editor.current.selectedIds.size).toBe(0);
});
it('starts with canUndo and canRedo both false', () => {
const editor = setupEditor();
// The history is empty; undo at idx -1 has nowhere to go. canRedo
// is bounded by history length so it's also false.
expect(editor.current.canUndo).toBe(false);
expect(editor.current.canRedo).toBe(false);
});
});
// ────────────────────────────────────────────────────────────────────
// addElement: the simplest history-push path.
// ────────────────────────────────────────────────────────────────────
describe('useDesignEditor — addElement', () => {
it('pushes the element into state', () => {
const editor = setupEditor();
editor.do((e) => e.addElement(makeImage()));
expect(editor.current.elements).toHaveLength(1);
expect(editor.current.elements[0].type).toBe('image');
});
it('assigns a unique id (overrides any provided id)', () => {
// `addElement` always calls makeId() and overwrites any incoming
// id. This is by design — callers don't get to dictate ids — but
// worth pinning so a future refactor that wanted to honour
// incoming ids would have to explicitly opt out of this test.
const editor = setupEditor();
editor.do((e) => e.addElement({ ...makeImage(), id: 'caller-supplied' }));
expect(editor.current.elements[0].id).not.toBe('caller-supplied');
expect(editor.current.elements[0].id).toMatch(/^element-/);
});
it('selects the newly added element', () => {
const editor = setupEditor();
const newId = editor.do((e) => e.addElement(makeImage()));
expect(editor.current.selectedId).toBe(newId);
});
it('after one add, canUndo is false (we are at the history floor)', () => {
// The first push lands at history idx 0. canUndo = (idx > 0)
// evaluates false. This protects against users accidentally
// undoing past their first action into an empty-canvas state
// they never explicitly created.
const editor = setupEditor();
editor.do((e) => e.addElement(makeImage()));
expect(editor.current.canUndo).toBe(false);
});
it('after two adds, canUndo is true', () => {
const editor = setupEditor();
editor.do((e) => e.addElement(makeImage()));
editor.do((e) => e.addElement(makeImage({ src: 'data:image/png;base64,_two_' })));
expect(editor.current.canUndo).toBe(true);
});
});
// ────────────────────────────────────────────────────────────────────
// undo / redo: the read side of history.
// ────────────────────────────────────────────────────────────────────
describe('useDesignEditor — undo / redo', () => {
it('undo restores the previous snapshot', () => {
const editor = setupEditor();
editor.do((e) => e.addElement(makeImage()));
editor.do((e) => e.addElement(makeText()));
expect(editor.current.elements).toHaveLength(2);
editor.do((e) => e.undo());
expect(editor.current.elements).toHaveLength(1);
expect(editor.current.elements[0].type).toBe('image');
});
it('undo is a no-op at the history floor', () => {
// After one push, idx is 0 — the floor. Pressing undo should
// leave state unchanged rather than walk into idx -1 territory
// (which previously could have restored an undefined snapshot).
const editor = setupEditor();
editor.do((e) => e.addElement(makeImage()));
const before = editor.current.elements;
editor.do((e) => e.undo());
expect(editor.current.elements).toBe(before);
expect(editor.current.canUndo).toBe(false);
});
it('redo walks back up after undo', () => {
const editor = setupEditor();
editor.do((e) => e.addElement(makeImage()));
editor.do((e) => e.addElement(makeText()));
editor.do((e) => e.undo());
expect(editor.current.elements).toHaveLength(1);
expect(editor.current.canRedo).toBe(true);
editor.do((e) => e.redo());
expect(editor.current.elements).toHaveLength(2);
expect(editor.current.canRedo).toBe(false);
});
it('redo is a no-op when there are no redo entries', () => {
const editor = setupEditor();
editor.do((e) => e.addElement(makeImage()));
expect(editor.current.canRedo).toBe(false);
editor.do((e) => e.redo());
expect(editor.current.elements).toHaveLength(1);
});
it('a new action after undo discards redo entries (redo-stack truncation)', () => {
// Standard editor behavior: if you undo and then make a new edit,
// the redo stack is gone. The new action becomes the only
// future from the current idx.
const editor = setupEditor();
editor.do((e) => e.addElement(makeImage())); // idx 0
editor.do((e) => e.addElement(makeText())); // idx 1
editor.do((e) => e.undo()); // idx 0, redo to text available
expect(editor.current.canRedo).toBe(true);
editor.do((e) => e.addElement(makeImage({ src: 'data:image/png;base64,_3_' }))); // idx 1, new branch
expect(editor.current.canRedo).toBe(false);
expect(editor.current.elements).toHaveLength(2);
// The redo target (the text element) is gone — undo from the new
// branch's tip lands on the single-image state, not the text state.
editor.do((e) => e.undo());
expect(editor.current.elements).toHaveLength(1);
expect(editor.current.elements[0].type).toBe('image');
});
it('undo preserves selection when the restored snapshot still contains the selected id', () => {
const editor = setupEditor();
const firstId = editor.do((e) => e.addElement(makeImage()));
editor.do((e) => e.addElement(makeText()));
editor.do((e) => e.selectElement(firstId));
editor.do((e) => e.undo());
expect(editor.current.selectedId).toBe(firstId);
});
it('undo drops selection when the restored snapshot no longer contains the selected id', () => {
const editor = setupEditor();
editor.do((e) => e.addElement(makeImage()));
const secondId = editor.do((e) => e.addElement(makeText()));
expect(editor.current.selectedId).toBe(secondId);
editor.do((e) => e.undo());
// The second element doesn't exist in the restored snapshot,
// so it gets filtered out of the selection set.
expect(editor.current.selectedIds.has(secondId)).toBe(false);
});
});
// ────────────────────────────────────────────────────────────────────
// initializeHistory — the May 22 regression.
//
// This is the headline reason the test file exists.
// ────────────────────────────────────────────────────────────────────
describe('useDesignEditor — initializeHistory', () => {
it('seeds an empty-array entry when history is empty (fresh-session path)', () => {
// Fresh session: no replaceElements, no template. initializeHistory
// creates the history floor so the user's first action becomes
// undoable back to the empty starting canvas.
const editor = setupEditor();
editor.do((e) => e.initializeHistory());
// After init, history = [[]] at idx 0. Adding an element pushes
// to idx 1; undo walks back to idx 0 (empty); canUndo flips off.
editor.do((e) => e.addElement(makeImage()));
expect(editor.current.canUndo).toBe(true);
editor.do((e) => e.undo());
expect(editor.current.elements).toEqual([]);
});
it('does NOT wipe history when entries already exist (regression for crop+undo blank-canvas bug)', () => {
// This is THE regression test. Pre-fix, `initializeHistory`
// unconditionally set history to [JSON.stringify([])], which
// destroyed the snapshot that `replaceElements` had pushed
// during session restoration. Sequence reproducing the bug:
//
// 1. replaceElements pushes the restored 5-element snapshot.
// 2. initializeHistory wipes history to [[]] at idx 0.
// 3. User crops → updateAndCommit pushes the cropped state
// at idx 1. History = [[], [cropped]].
// 4. User presses Cmd+Z → undo walks 1 → 0 → restores [],
// blanking the canvas.
//
// Post-fix, step 2 is a no-op when history has entries. The
// assertion below is on step 4's outcome: undo must restore the
// PRE-CROP state, not the empty array.
const editor = setupEditor();
// Step 1: session restoration via replaceElements.
editor.do((e) =>
e.replaceElements([
makeImage({ src: 'data:image/png;base64,_a_' }),
makeImage({ src: 'data:image/png;base64,_b_' }),
])
);
expect(editor.current.elements).toHaveLength(2);
// Step 2: initializeHistory runs (App.jsx's restorationComplete
// effect). Pre-fix this would wipe; post-fix it's a no-op.
editor.do((e) => e.initializeHistory());
expect(editor.current.elements).toHaveLength(2);
// Step 3: user performs an action. We use updateAndCommit because
// that's the path the crop button takes, but any committing path
// would expose the same bug. Capture the post-restore ids so we
// can target one with the update.
const firstId = editor.current.elements[0].id;
editor.do((e) =>
e.updateAndCommit(firstId, {
x: 50,
y: 50,
width: 80,
height: 80,
crop: { sx: 10, sy: 10, sWidth: 50, sHeight: 50 },
})
);
expect(editor.current.elements.find((el) => el.id === firstId).crop).toBeDefined();
// Step 4: undo. The crucial assertion.
//
// Pre-fix: undo walks idx 1 → 0, restores [], elements.length === 0.
// Post-fix: undo walks idx 1 → 0, restores the replaceElements
// snapshot, elements.length === 2 with no crop on the first.
expect(editor.current.canUndo).toBe(true);
editor.do((e) => e.undo());
expect(editor.current.elements).toHaveLength(2);
expect(editor.current.elements.find((el) => el.id === firstId)?.crop).toBeUndefined();
});
it('multiple initializeHistory calls after the first push are no-ops', () => {
// Defends against a future refactor that might call
// initializeHistory more than once — for instance if React 18
// StrictMode causes the App-level restorationComplete effect to
// fire twice. Each subsequent call must remain a no-op so the
// floor stays correct.
const editor = setupEditor();
editor.do((e) => e.replaceElements([makeImage()]));
editor.do((e) => e.initializeHistory());
editor.do((e) => e.initializeHistory());
editor.do((e) => e.initializeHistory());
editor.do((e) => e.addElement(makeText()));
expect(editor.current.elements).toHaveLength(2);
editor.do((e) => e.undo());
// Restored snapshot is the replaceElements one, not empty.
expect(editor.current.elements).toHaveLength(1);
});
});
// ────────────────────────────────────────────────────────────────────
// updateAndCommit — the synchronous-push escape hatch.
// ────────────────────────────────────────────────────────────────────
describe('useDesignEditor — replaceElements id preservation', () => {
it('preserves input ids when present (regression: StrictMode dual-restoration)', () => {
// The session-restoration case. Elements come back from localStorage
// with their original ids; replaceElements must preserve them so a
// second call with the same input produces the same JSON and gets
// deduped by saveToHistory.
const editor = setupEditor();
const items = [
{ ...makeImage(), id: 'persisted-1' },
{ ...makeText(), id: 'persisted-2' },
];
editor.do((e) => e.replaceElements(items));
expect(editor.current.elements[0].id).toBe('persisted-1');
expect(editor.current.elements[1].id).toBe('persisted-2');
});
it('generates ids for elements that do not have one (template-loading path)', () => {
// Template constants ship elements without ids — replaceElements
// generates them. This test verifies the generation path still works
// after the id-preservation refactor.
const editor = setupEditor();
editor.do((e) => e.replaceElements([makeImage(), makeText()]));
expect(editor.current.elements[0].id).toBeTruthy();
expect(editor.current.elements[1].id).toBeTruthy();
expect(editor.current.elements[0].id).toMatch(/^element-/);
expect(editor.current.elements[1].id).toMatch(/^element-/);
expect(editor.current.elements[0].id).not.toBe(editor.current.elements[1].id);
});
it('mixed input (some with ids, some without) preserves and generates as needed', () => {
// Defensive: a future caller might pass a partially-ided array.
// Per-element decision means elements with ids keep them, elements
// without get fresh generation.
const editor = setupEditor();
editor.do((e) => e.replaceElements([
{ ...makeImage(), id: 'keeper' },
makeText(), // no id
{ ...makeImage(), id: 'also-keeper' },
]));
expect(editor.current.elements[0].id).toBe('keeper');
expect(editor.current.elements[1].id).toMatch(/^element-/);
expect(editor.current.elements[2].id).toBe('also-keeper');
});
it('second call with the same id-bearing input dedupes in history (the StrictMode case)', () => {
// The headline regression test. Before the fix:
// call 1 → replaceElements assigns fresh ids → pushes snapshot A at idx 0
// call 2 → replaceElements assigns DIFFERENT fresh ids → pushes
// snapshot B at idx 1 (dedupe misses because JSON differs)
// undo → walks idx 1 → 0, restores snapshot A (different ids than
// what's currently on canvas). Visually equivalent but
// produces two undos for one logical action.
//
// After the fix:
// call 1 → preserves input ids → pushes at idx 0
// call 2 → preserves the SAME input ids → saveToHistory dedupes →
// no idx change
// undo → no-op at floor; canUndo stays false
const editor = setupEditor();
const items = [
{ ...makeImage(), id: 'restored-1' },
{ ...makeImage({ src: 'data:image/png;base64,_b_' }), id: 'restored-2' },
];
editor.do((e) => e.replaceElements(items));
editor.do((e) => e.replaceElements(items));
// After two identical calls, canUndo must still be false — only one
// entry exists, dedupe caught the second. (canUndo = idx > 0; idx
// stays at 0.)
expect(editor.current.canUndo).toBe(false);
});
});
describe('useDesignEditor — updateAndCommit', () => {
it('pushes the new state synchronously (no debounce)', () => {
// The whole point of updateAndCommit existing separately from
// updateElement is that its history push happens during React's
// commit, before any subsequent user event. This test verifies
// that contract: an undo immediately after updateAndCommit
// restores the PRE-update state. Pre-fix, an immediate undo
// would walk past the never-written post-update snapshot and
// restore the snapshot before THAT.
const editor = setupEditor();
const id = editor.do((e) => e.addElement(makeImage({ x: 0 })));
editor.do((e) => e.updateAndCommit(id, { x: 50 }));
expect(editor.current.elements[0].x).toBe(50);
editor.do((e) => e.undo());
expect(editor.current.elements[0].x).toBe(0);
});
it('flushes pending debounced changes before its own push', () => {
// updateElement queues a 300 ms timer. If updateAndCommit fires
// before the timer, the pending change would be lost without an
// explicit flush. The fix is the leading flushPendingChanges()
// inside updateAndCommit. After updateAndCommit completes,
// both the debounced update AND the commit should be undoable
// as separate history entries.
vi.useFakeTimers();
try {
const editor = setupEditor();
const id = editor.do((e) => e.addElement(makeImage({ x: 0 })));
// Debounced update — timer scheduled but NOT yet fired.
editor.do((e) => e.updateElement(id, { x: 25 }));
expect(editor.current.elements[0].x).toBe(25);
// Immediate commit — should flush the pending x=25 to history,
// then push x=50 on top.
editor.do((e) => e.updateAndCommit(id, { x: 50 }));
expect(editor.current.elements[0].x).toBe(50);
// Two undos should land at x=25, then x=0 — proving both
// snapshots made it to history.
editor.do((e) => e.undo());
expect(editor.current.elements[0].x).toBe(25);
editor.do((e) => e.undo());
expect(editor.current.elements[0].x).toBe(0);
} finally {
vi.useRealTimers();
}
});
});
// ────────────────────────────────────────────────────────────────────
// updateElement — the debounced path.
// ────────────────────────────────────────────────────────────────────
describe('useDesignEditor — updateElement (debounced)', () => {
it('updates state immediately but defers the history push', () => {
vi.useFakeTimers();
try {
const editor = setupEditor();
const id = editor.do((e) => e.addElement(makeImage({ x: 0 })));
editor.do((e) => e.updateElement(id, { x: 10 }));
// State is updated synchronously…
expect(editor.current.elements[0].x).toBe(10);
// …but the history push hasn't happened. Undoing right now
// would restore the pre-update state (the addElement snapshot).
// We confirm by NOT advancing the timer and checking that the
// state is exposed but the snapshot isn't committed yet.
// (canUndo is true because the addElement snapshot is still
// undoable; we can't directly inspect history length, but we
// can verify the commitHistory behavior by advancing time.)
editor.advanceTimers(299);
expect(editor.current.elements[0].x).toBe(10);
editor.advanceTimers(2); // total 301 ms, timer fired
expect(editor.current.elements[0].x).toBe(10);
// After flush, undoing should restore the addElement snapshot.
editor.do((e) => e.undo());
expect(editor.current.elements[0].x).toBe(0);
} finally {
vi.useRealTimers();
}
});
it('rapid updates collapse into a single history entry', () => {
// A drag handler that fires updateElement on every mousemove
// shouldn't fill history with 60 entries per second. The
// debounce timer collapses a burst of updates into one push.
vi.useFakeTimers();
try {
const editor = setupEditor();
const id = editor.do((e) => e.addElement(makeImage({ x: 0 })));
for (let i = 1; i <= 10; i++) {
editor.do((e) => e.updateElement(id, { x: i * 5 }));
}
expect(editor.current.elements[0].x).toBe(50);
editor.advanceTimers(300);
// One history entry from the burst. Undo lands at x=0.
editor.do((e) => e.undo());
expect(editor.current.elements[0].x).toBe(0);
// Redo restores the final x=50 (not an intermediate value).
editor.do((e) => e.redo());
expect(editor.current.elements[0].x).toBe(50);
} finally {
vi.useRealTimers();
}
});
it('commitHistory drains pending changes synchronously', () => {
// commitHistory is the user-facing flush. Callers use it after
// a continuous interaction (transform-end, blur on text input)
// to commit the latest state to history without waiting for
// the 300 ms timer.
const editor = setupEditor();
const id = editor.do((e) => e.addElement(makeImage({ x: 0 })));
editor.do((e) => e.updateElement(id, { x: 42 }));
editor.do((e) => e.commitHistory());
// Without fake timers, the debounced timer hasn't fired —
// commitHistory should be what got the snapshot in.
editor.do((e) => e.undo());
expect(editor.current.elements[0].x).toBe(0);
});
});
// ────────────────────────────────────────────────────────────────────
// deleteElement — selection cleanup.
// ────────────────────────────────────────────────────────────────────
describe('useDesignEditor — deleteElement', () => {
it('removes the element from state and history', () => {
const editor = setupEditor();
const id = editor.do((e) => e.addElement(makeImage()));
editor.do((e) => e.deleteElement(id));
expect(editor.current.elements).toHaveLength(0);
// Undo restores it.
editor.do((e) => e.undo());
expect(editor.current.elements).toHaveLength(1);
});
it('clears the deleted id from selection', () => {
const editor = setupEditor();
const id = editor.do((e) => e.addElement(makeImage()));
expect(editor.current.selectedId).toBe(id);
editor.do((e) => e.deleteElement(id));
expect(editor.current.selectedId).toBeNull();
});
});
// ────────────────────────────────────────────────────────────────────
// duplicateElement — regression for the didDuplicate stale-flag bug.
// ────────────────────────────────────────────────────────────────────
describe('useDesignEditor — duplicateElement', () => {
it('inserts the copy immediately after the source in the array', () => {
const editor = setupEditor();
const a = editor.do((e) => e.addElement(makeImage({ src: 'a' })));
const b = editor.do((e) => e.addElement(makeImage({ src: 'b' })));
editor.do((e) => e.duplicateElement(a));
// Order: a, a-copy, b. The copy lands at index 1.
const srcs = editor.current.elements.map((el) => el.src);
expect(srcs[0]).toBe('a');
expect(srcs[1]).toBe('a');
expect(srcs[2]).toBe('b');
expect(editor.current.elements[2].id).toBe(b);
});
it('selects the copy synchronously (regression for didDuplicate stale-flag bug)', () => {
// The pre-fix bug: selection assignment lived inside an async
// setElements updater, so `didDuplicate` was read before the
// updater ran and selection stayed on the source. The fix
// moved the id generation and selection assignment outside the
// updater. This test pins that behavior.
const editor = setupEditor();
const a = editor.do((e) => e.addElement(makeImage()));
const newId = editor.do((e) => e.duplicateElement(a));
expect(newId).toBeTruthy();
expect(newId).not.toBe(a);
expect(editor.current.selectedId).toBe(newId);
});
it('returns null and is a no-op when the source id does not exist', () => {
const editor = setupEditor();
const ret = editor.do((e) => e.duplicateElement('nonexistent-id'));
expect(ret).toBeNull();
expect(editor.current.elements).toHaveLength(0);
});
it('clears the source-element slotId on the copy', () => {
// Documented behavior: a duplicated element shouldn't claim the
// same template slot as its source. Without this, a slot would
// appear filled twice (once by the original, once by the copy).
const editor = setupEditor();
const a = editor.do((e) => e.addElement(makeImage({ slotId: 'slot-1' })));
editor.do((e) => e.duplicateElement(a));
const copy = editor.current.elements[1];
expect(copy.slotId).toBeUndefined();
});
});
// ────────────────────────────────────────────────────────────────────
// reorderElement — splice-then-re-find target.
// ────────────────────────────────────────────────────────────────────
describe('useDesignEditor — reorderElement', () => {
it('moves source AFTER target', () => {
const editor = setupEditor();
const a = editor.do((e) => e.addElement(makeImage({ src: 'a' })));
const b = editor.do((e) => e.addElement(makeImage({ src: 'b' })));
const c = editor.do((e) => e.addElement(makeImage({ src: 'c' })));
// Move a (currently first) to AFTER c (currently last). Result: b, c, a.
editor.do((e) => e.reorderElement(a, c, 'after'));
expect(editor.current.elements.map((el) => el.src)).toEqual(['b', 'c', 'a']);
});
it('moves source BEFORE target', () => {
const editor = setupEditor();
editor.do((e) => e.addElement(makeImage({ src: 'a' })));
const b = editor.do((e) => e.addElement(makeImage({ src: 'b' })));
const c = editor.do((e) => e.addElement(makeImage({ src: 'c' })));
// Move c to BEFORE b. Result: a, c, b.
editor.do((e) => e.reorderElement(c, b, 'before'));
expect(editor.current.elements.map((el) => el.src)).toEqual(['a', 'c', 'b']);
});
it('correctly accounts for target-index shift after source splice', () => {
// Documented bug source: when source is BEFORE target in the
// array, splicing source out reduces target's index by one.
// The reorder logic re-finds target by id after the splice
// instead of using the cached index. This test covers the
// case where the cached-index approach would have been wrong:
// start: [a, b, c, d]
// reorder a after d
// if we used target's original index (3) the splice would
// place a at index 4, beyond the array length
// correct behavior: re-find d at index 2 (after a's removal),
// insert a at index 3
// result: [b, c, d, a]
const editor = setupEditor();
const a = editor.do((e) => e.addElement(makeImage({ src: 'a' })));
editor.do((e) => e.addElement(makeImage({ src: 'b' })));
editor.do((e) => e.addElement(makeImage({ src: 'c' })));
const d = editor.do((e) => e.addElement(makeImage({ src: 'd' })));
editor.do((e) => e.reorderElement(a, d, 'after'));
expect(editor.current.elements.map((el) => el.src)).toEqual(['b', 'c', 'd', 'a']);
});
it('is a no-op when source === target', () => {
const editor = setupEditor();
const a = editor.do((e) => e.addElement(makeImage({ src: 'a' })));
const before = editor.current.elements;
editor.do((e) => e.reorderElement(a, a, 'after'));
expect(editor.current.elements).toBe(before);
});
});
// ────────────────────────────────────────────────────────────────────
// Z-order: bringForward / sendBackward.
// ────────────────────────────────────────────────────────────────────
describe('useDesignEditor — z-order', () => {
it('bringForward swaps with the next element', () => {
const editor = setupEditor();
const a = editor.do((e) => e.addElement(makeImage({ src: 'a' })));
editor.do((e) => e.addElement(makeImage({ src: 'b' })));
editor.do((e) => e.bringForward(a));
expect(editor.current.elements.map((el) => el.src)).toEqual(['b', 'a']);
});
it('bringForward is a no-op for the last element', () => {
const editor = setupEditor();
editor.do((e) => e.addElement(makeImage({ src: 'a' })));
const b = editor.do((e) => e.addElement(makeImage({ src: 'b' })));
const before = editor.current.elements;
editor.do((e) => e.bringForward(b));
expect(editor.current.elements).toBe(before);
});
it('sendBackward swaps with the previous element', () => {
const editor = setupEditor();
editor.do((e) => e.addElement(makeImage({ src: 'a' })));
const b = editor.do((e) => e.addElement(makeImage({ src: 'b' })));
editor.do((e) => e.sendBackward(b));
expect(editor.current.elements.map((el) => el.src)).toEqual(['b', 'a']);
});
it('sendBackward is a no-op for the first element', () => {
const editor = setupEditor();
const a = editor.do((e) => e.addElement(makeImage({ src: 'a' })));
editor.do((e) => e.addElement(makeImage({ src: 'b' })));
const before = editor.current.elements;
editor.do((e) => e.sendBackward(a));
expect(editor.current.elements).toBe(before);
});
});
// ────────────────────────────────────────────────────────────────────
// Selection setters.
// ────────────────────────────────────────────────────────────────────
describe('useDesignEditor — selection', () => {
it('selectElement sets a single-element selection', () => {
const editor = setupEditor();
const id = editor.do((e) => e.addElement(makeImage()));
editor.do((e) => e.addElement(makeText()));
editor.do((e) => e.selectElement(id));
expect(editor.current.selectedId).toBe(id);
expect(editor.current.selectedIds.size).toBe(1);
});
it('deselectAll clears the selection', () => {
const editor = setupEditor();
editor.do((e) => e.addElement(makeImage()));
expect(editor.current.selectedIds.size).toBe(1);
editor.do((e) => e.deselectAll());
expect(editor.current.selectedIds.size).toBe(0);
expect(editor.current.selectedId).toBeNull();
});
it('toggleInSelection adds and removes ids', () => {
const editor = setupEditor();
const a = editor.do((e) => e.addElement(makeImage({ src: 'a' })));
const b = editor.do((e) => e.addElement(makeImage({ src: 'b' })));
// After two adds, b is selected (addElement selects the new id).
expect(editor.current.selectedIds.has(b)).toBe(true);
editor.do((e) => e.toggleInSelection(a));
expect(editor.current.selectedIds.has(a)).toBe(true);
expect(editor.current.selectedIds.has(b)).toBe(true);
expect(editor.current.selectedIds.size).toBe(2);
// selectedId is null when more than one element is selected.
expect(editor.current.selectedId).toBeNull();
editor.do((e) => e.toggleInSelection(a));
expect(editor.current.selectedIds.has(a)).toBe(false);
expect(editor.current.selectedIds.size).toBe(1);
});
it('setSelection replaces the selection set with the given ids', () => {
const editor = setupEditor();
const a = editor.do((e) => e.addElement(makeImage({ src: 'a' })));
const b = editor.do((e) => e.addElement(makeImage({ src: 'b' })));
const c = editor.do((e) => e.addElement(makeImage({ src: 'c' })));
editor.do((e) => e.setSelection([a, c]));
expect(editor.current.selectedIds.size).toBe(2);
expect(editor.current.selectedIds.has(a)).toBe(true);
expect(editor.current.selectedIds.has(c)).toBe(true);
expect(editor.current.selectedIds.has(b)).toBe(false);
});
});
// ────────────────────────────────────────────────────────────────────
// deleteMany — bulk delete + selection cleanup.
// ────────────────────────────────────────────────────────────────────
describe('useDesignEditor — deleteMany', () => {
it('removes all listed ids as a single history entry', () => {
const editor = setupEditor();
const a = editor.do((e) => e.addElement(makeImage({ src: 'a' })));
editor.do((e) => e.addElement(makeImage({ src: 'b' })));
const c = editor.do((e) => e.addElement(makeImage({ src: 'c' })));
editor.do((e) => e.deleteMany([a, c]));
expect(editor.current.elements).toHaveLength(1);
expect(editor.current.elements[0].src).toBe('b');
// A single undo restores both. If it took two undos that would
// mean deleteMany pushed two history entries — wrong, since it's
// documented as a single-entry operation.
editor.do((e) => e.undo());
expect(editor.current.elements).toHaveLength(3);
});
it('clears deleted ids from selection', () => {
const editor = setupEditor();
const a = editor.do((e) => e.addElement(makeImage({ src: 'a' })));
const b = editor.do((e) => e.addElement(makeImage({ src: 'b' })));
editor.do((e) => e.setSelection([a, b]));
editor.do((e) => e.deleteMany([a]));
expect(editor.current.selectedIds.has(a)).toBe(false);
expect(editor.current.selectedIds.has(b)).toBe(true);
});
it('is a no-op for an empty ids array', () => {
const editor = setupEditor();
editor.do((e) => e.addElement(makeImage()));
const before = editor.current.elements;
editor.do((e) => e.deleteMany([]));
expect(editor.current.elements).toBe(before);
});
});

View File

@@ -1,66 +0,0 @@
import { useState, useCallback } from 'react';
/**
* Server export pipeline wrapper.
*
* Posts the design payload to /api/export, receives back a URL to the
* 4500×4500 PNG render, and (by default) triggers a browser download.
*
* `autoDownload` option
* ─────────────────────
* Save and the test-download button want auto-download. Print-preview
* wants the URL but no download. Threading a flag through `exportDesign`
* is cleaner than maintaining two parallel pipelines.
*/
export function useExport() {
const [exporting, setExporting] = useState(false);
const [progress, setProgress] = useState(0);
const [exportUrl, setExportUrl] = useState(null);
const [error, setError] = useState(null);
const exportDesign = useCallback(async (
elements,
designName = 'design',
template = null,
{ autoDownload = true } = {},
) => {
setExporting(true); setProgress(0); setError(null); setExportUrl(null);
let progressInterval = null;
try {
progressInterval = setInterval(() => { setProgress((prev) => Math.min(prev + 10, 90)); }, 200);
const response = await fetch('/api/export', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ elements, designName, template }),
});
if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new Error(errorData.error || 'Export failed'); }
const data = await response.json();
setProgress(100);
setExportUrl(data.export.url);
if (autoDownload) {
const link = document.createElement('a');
link.href = data.export.url;
link.download = data.export.filename;
link.click();
}
return data;
} catch (err) {
console.error('Export failed:', err);
setError(err.message);
throw err;
} finally {
if (progressInterval) clearInterval(progressInterval);
setExporting(false);
}
}, []);
const clearExport = useCallback(() => { setExportUrl(null); setError(null); }, []);
return { exporting, progress, exportUrl, error, exportDesign, clearExport };
}

View File

@@ -1,91 +0,0 @@
import { useEffect, useRef } from 'react';
/**
* Focus-trap hook for modal-like surfaces (S24).
*
* Pass `active` (the modal's open state) and a ref to the container
* element. While active:
*
* • Focus is moved into the container on mount (first tabbable child,
* or the container itself if it has tabindex).
* • Tab and Shift+Tab cycle through tabbable descendants only — they
* can't escape to elements behind the modal.
* • On deactivation (close), focus is returned to wherever it was
* before the modal opened.
*
* The set of tabbable selectors here covers the common interactive
* elements; we intentionally exclude `area` and `iframe` since they're
* unusual in a modal context. `[tabindex="-1"]` is excluded — those are
* focus targets but not Tab targets.
*
* Reading focused elements via `document.activeElement` is the standard
* pattern; React refs aren't enough because focus may live on an element
* inside a third-party library (e.g. Filerobot) that doesn't expose its
* own ref.
*/
const TABBABLE_SELECTOR = [
'a[href]',
'button:not([disabled])',
'input:not([disabled]):not([type="hidden"])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(',');
export function useFocusTrap(active, containerRef) {
const previousFocusRef = useRef(null);
useEffect(() => {
if (!active) return undefined;
// Remember where focus was before so we can restore it on close.
previousFocusRef.current = document.activeElement;
const container = containerRef.current;
if (!container) return undefined;
// Move focus to the first tabbable element inside the container,
// falling back to the container itself if it's focusable.
const focusables = container.querySelectorAll(TABBABLE_SELECTOR);
const first = focusables[0];
if (first instanceof HTMLElement) {
first.focus();
} else if (container.tabIndex >= 0) {
container.focus();
}
const handleKeyDown = (e) => {
if (e.key !== 'Tab') return;
const live = container.querySelectorAll(TABBABLE_SELECTOR);
if (live.length === 0) {
// Nothing tabbable — keep focus on the container.
e.preventDefault();
if (container.tabIndex >= 0) container.focus();
return;
}
const firstEl = live[0];
const lastEl = live[live.length - 1];
const activeEl = document.activeElement;
if (e.shiftKey && activeEl === firstEl) {
e.preventDefault();
lastEl.focus();
} else if (!e.shiftKey && activeEl === lastEl) {
e.preventDefault();
firstEl.focus();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
// Restore focus to where it was before, if that element still
// exists in the DOM. Wrapped in a try/catch because focusing a
// detached node throws in some browsers.
const prev = previousFocusRef.current;
if (prev && typeof prev.focus === 'function') {
try { prev.focus(); } catch { /* ignore */ }
}
};
}, [active, containerRef]);
}

View File

@@ -1,50 +0,0 @@
import { useEffect, useState } from 'react';
/**
* Trigger a re-render once web fonts have finished loading.
*
* Why
* ───
* `measureTextWidth` (used by the canvas-based text measurement utilities)
* asks the browser for a rendered glyph width with a specific `font-family`.
* Before the font's `.woff2` arrives over the network and is parsed, the
* browser falls back to a system font and reports its width — almost
* always wrong.
*
* On a fast connection that happens once at app start, before the user
* notices. On a slow connection, or for fonts loaded via `@font-face`
* with `font-display: swap`, the measurement can lag the eventual render
* by several hundred milliseconds — long enough for arc-text paths and
* bounds-violation flags to be computed against fallback metrics.
*
* The fix is to re-render once `document.fonts.ready` resolves, so any
* `measureTextWidth` calls in render bodies retry with the now-loaded
* fonts. This hook returns a boolean for components that want to
* conditionally render a placeholder, but most callers can just call it
* for the side effect (the hook subscription causes the re-render).
*
* Resolves once per app lifetime
* ──────────────────────────────
* `document.fonts.ready` resolves once and stays resolved. After that,
* this hook does nothing — no listener overhead. If new fonts are loaded
* dynamically later (e.g. user picks a font we have to fetch on demand),
* the consuming component would need its own re-render trigger.
*/
export function useFontsReady() {
const [ready, setReady] = useState(() => {
if (typeof document === 'undefined' || !document.fonts) return true;
return document.fonts.status === 'loaded';
});
useEffect(() => {
if (ready) return;
if (typeof document === 'undefined' || !document.fonts) return;
let cancelled = false;
document.fonts.ready.then(() => {
if (!cancelled) setReady(true);
});
return () => { cancelled = true; };
}, [ready]);
return ready;
}

View File

@@ -1,61 +0,0 @@
import { useState, useCallback, useRef } from 'react';
import { loadImageDimensions } from '../utils/imageLoading';
export function calculateAutoCrop(imageSize, slotSize) {
const imageRatio = imageSize.width / imageSize.height;
const slotRatio = slotSize.width / slotSize.height;
let sx, sy, sWidth, sHeight;
if (imageRatio > slotRatio) { sHeight = imageSize.height; sWidth = imageSize.height * slotRatio; sx = (imageSize.width - sWidth) / 2; sy = 0; }
else { sWidth = imageSize.width; sHeight = imageSize.width / slotRatio; sx = 0; sy = (imageSize.height - sHeight) / 2; }
return { sx, sy, sWidth, sHeight };
}
export function useTemplate(templates = []) {
const [currentTemplateId, setCurrentTemplateId] = useState(null);
const templateRef = useRef(null);
const currentTemplate = templates.find(t => t.id === currentTemplateId) || null;
const getSlots = useCallback(() => currentTemplate?.slots || [], [currentTemplate]);
const loadTemplate = useCallback((templateId) => {
const template = templates.find(t => t.id === templateId);
if (template) { setCurrentTemplateId(templateId); templateRef.current = template; return true; }
return false;
}, [templates]);
const clearTemplate = useCallback(() => { setCurrentTemplateId(null); templateRef.current = null; }, []);
// Build the element data for an image dropped into a slot. Resolves only after
// the image has loaded so the auto-crop reflects the image's real dimensions
// (returning early with crop:null lets the caller create an element that never
// gets its crop populated, since the mutation arrives after React already
// captured the value). Caller is responsible for adding the returned element
// to the editor state, which makes that state the single source of truth for
// "is this slot filled" — see DesignCanvas, which derives slot occupancy from
// the elements array.
const assignImageToSlot = useCallback(async (slotId, imageData) => {
const slots = getSlots();
const slot = slots.find(s => s.id === slotId);
if (!slot) return null;
const dimensions = await loadImageDimensions(imageData);
const crop = calculateAutoCrop(
{ width: dimensions.width, height: dimensions.height },
{ width: slot.bounds.width, height: slot.bounds.height },
);
return {
type: 'image',
src: imageData,
x: slot.bounds.x,
y: slot.bounds.y,
width: slot.bounds.width,
height: slot.bounds.height,
slotId,
crop,
};
}, [getSlots]);
return { currentTemplateId, currentTemplate, loadTemplate, clearTemplate, getSlots, assignImageToSlot };
}

View File

@@ -1,62 +0,0 @@
import { useCallback, useEffect, useRef } from 'react';
/**
* useTimer — auto-cleaning setTimeout helper.
*
* Background
* ──────────
* `App.jsx` had two timer use cases (clear-canvas two-tap arming and toast
* auto-dismiss) that each followed the same shape:
*
* const timerRef = useRef(null);
* // schedule:
* if (timerRef.current) clearTimeout(timerRef.current);
* timerRef.current = setTimeout(() => { ...; timerRef.current = null }, ms);
* // unmount cleanup:
* useEffect(() => () => {
* if (timerRef.current) clearTimeout(timerRef.current);
* }, []);
*
* The same boilerplate, three side-effects (arm, cancel-and-rearm, cleanup),
* one bug surface — forgetting any of them strands a timer or fails to
* cancel a previous one. This hook collapses it to:
*
* const timer = useTimer();
* timer.schedule(() => { ... }, 3000); // auto-cancels any pending one
* timer.cancel(); // explicit early cancellation
*
* Cleanup on unmount is automatic.
*
* Why not setInterval-style
* ─────────────────────────
* setTimeout is one-shot, which matches both current callers' semantics:
* "after N ms unless something else cancels me first". An interval helper
* would be a different shape — leaving it for when there's a second
* caller that needs it.
*/
export function useTimer() {
const idRef = useRef(null);
const cancel = useCallback(() => {
if (idRef.current !== null) {
clearTimeout(idRef.current);
idRef.current = null;
}
}, []);
const schedule = useCallback((fn, ms) => {
cancel();
idRef.current = setTimeout(() => {
idRef.current = null;
fn();
}, ms);
}, [cancel]);
// Unmount cleanup. Returning the cleanup directly from useEffect rather
// than capturing `cancel` in deps because the ref-based cancel is
// identity-stable, so the effect can run once on mount and once on
// unmount.
useEffect(() => () => cancel(), [cancel]);
return { schedule, cancel };
}

View File

@@ -1,144 +1,109 @@
/**
* Message catalog (S23).
* Host message catalog.
*
* Flat key → string map. Intentionally not nested — the dot in a key is
* just punctuation, the lookup is a plain object access. Flat keys are
* easier to grep ("where do we use this string?"), easier to diff in
* code review, and avoid the bikeshedding around when to nest vs flatten
* a category. They also work transparently with the lightweight `t()`
* helper in `./t.js` — no recursion, no path syntax to learn.
* Scope
* ─────
* HOST-OWNED strings only — the chrome around the editor (header,
* page title, PWA install banner, offline indicator, share-sheet
* title). Strings inside the goods-editor module (sidebar tabs,
* toolbar buttons, toasts, modal copy) have their OWN catalog
* inside the module and are not re-exported. That separation is
* deliberate: the host shouldn't be able to silently change the
* module's user-facing copy, and the module shouldn't be able to
* silently change the host's.
*
* Adding a key:
* 1. Append it to the `en` map below with a key that matches the
* strings in code (kebab namespace, e.g. `header.save-button`).
* 2. Use it via `t('header.save-button')` in the component.
* 3. When future locales are added, copy the en map to a new file
* (es.js, fr.js…) and translate the values.
* Convention — flat keys
* ──────────────────────
* The dot in `header.save` is punctuation, not nesting. The lookup
* is a single object access. This makes:
* • grepping ("where do we use this string?") trivial,
* • diffs readable in code review,
* • the t() helper a one-liner with no recursion or path syntax.
*
* Parameter syntax: `{name}` placeholders inside a value are replaced by
* the matching key in the params object passed to t():
* Adding a key
* ────────────
* 1. Append below with a key matching the namespace it belongs to
* (header.*, pwa.*, offline.*, share.*).
* 2. Use it via t('namespace.key') in the component.
* 3. When new locales land, copy this file (es.js, fr.js…) and
* translate the values. The t() helper picks the active catalog
* via the import in t.js.
*
* 'cart.items-count': 'You have {count} items in your cart.'
* t('cart.items-count', { count: 3 }) → 'You have 3 items in your cart.'
* Placeholders
* ────────────
* `{name}` placeholders are substituted with the matching key in the
* params object passed to t():
*
* If a key is missing, t() returns the key string itself — that's
* obvious in the UI ("header.save-button" is not English) so missing
* keys surface immediately in QA rather than silently rendering empty.
* 'header.logo-home-aria': '{app} home'
* t('header.logo-home-aria', { app: 'Pawfectly Yours' }) → 'Pawfectly Yours home'
*
* Pluralization
* ─────────────
* No ICU plurals — we use the explicit `.singular` / `.plural` key
* convention, same pattern as the module's `bounds.singular` /
* `bounds.plural`. Caller picks the key based on count:
*
* t(count === 1 ? 'header.cart-aria.singular' : 'header.cart-aria.plural',
* { count })
*
* If we ever need real plural rules (Polish, Arabic, etc.) we'll move
* to formatjs/intl-messageformat then. For en-only at this scale,
* .singular/.plural is enough.
*/
export const en = {
// Header
// ── Header ──────────────────────────────────────────────────
// Save / Clear / Menu were removed from the header chrome during the
// module split — Save was redundant with Download (same export
// pipeline), Clear needs a module-side `clear()` method on the editor
// ref before it can be reintroduced, and Menu opened the host's own
// bottom sheet which is now inside the module. Their i18n keys are
// gone with them; readd if you put any of those buttons back.
'header.app-name': 'Pawfectly Yours',
'header.app-tagline': 'Wear Your Pet. Share Your Story.',
'header.page-title': 'Customize Your Shirt',
'header.undo': 'Undo',
'header.undo-tooltip': 'Undo (\u2318Z)',
'header.redo': 'Redo',
'header.redo-tooltip': 'Redo (\u2318\u21E7Z)',
'header.clear': 'Clear canvas',
'header.clear-tooltip': 'Clear all elements',
'header.logo-home-aria': '{app} home',
'header.preview': 'Print preview',
'header.preview-tooltip': 'See your design at print resolution',
'header.download': 'Download print file',
'header.download-tooltip': 'Download the print-resolution PNG',
'header.save': 'Save',
'header.share': 'Share',
'header.cart-aria': 'Cart, {count} {plural}',
'header.menu': 'Menu',
// Cart aria-label has two forms because pluralization affects the
// whole phrase. The caller picks the key based on count.
'header.cart-aria.singular': 'Cart, {count} item',
'header.cart-aria.plural': 'Cart, {count} items',
// Toasts
'toast.add-something-first': 'Add something to your design first.',
'toast.cart-empty': 'Your cart is empty. Customize your shirt and add it to cart!',
'toast.cart-items': 'You have {count} {plural} in your cart.',
// ── Offline indicator ───────────────────────────────────────
'offline.message': 'You\u2019re offline \u2014 changes are saved locally',
// ── PWA install / update prompts ────────────────────────────
'pwa.install-message': 'Install Apparel Designer for offline access!',
'pwa.install': 'Install',
'pwa.dismiss': 'Later',
'pwa.update-message': 'New version available!',
'pwa.refresh': 'Refresh',
'pwa.close': 'Close',
// ── Share sheet ─────────────────────────────────────────────
// Title and body for the OS share sheet (iOS / Android / Web Share
// API). `share.title` is also passed to <ApparelDesigner shareTitle=…/>
// so the module uses the same string when invoking navigator.share()
// from inside its own save / share flows.
'share.title': 'My Pawfectly Yours design',
'share.text': 'Check out the shirt I made!',
// ── Toasts ──────────────────────────────────────────────────
// The host's toast surface (see src/components/Toast.jsx). The
// editor module has its own toast catalog (`toast.crop-…` etc.)
// inside the module — these are only the ones the HOST surfaces.
// Kept narrowly scoped so we don't drift from the module's copy
// for the same situation.
'toast.dismiss': 'Dismiss',
'toast.link-copied': 'Link copied to clipboard \u2728',
// 'toast.copy-failed' interpolates the URL so the user can read
// and copy it manually when both navigator.share and
// navigator.clipboard are unavailable. Longer auto-dismiss (8s)
// because the user needs time to read and copy.
'toast.copy-failed': 'Couldn\u2019t auto-copy. URL: {url}',
'toast.cleared': 'Canvas cleared. Use Undo to restore.',
'toast.clear-confirm': 'Click Clear again to confirm.',
'toast.unknown-template': 'Unknown template: {id}',
'toast.image-load-failed': 'Failed to load image. Try a different file.',
'toast.save-failed': 'Save failed: {error}',
'toast.saved': 'Saved!',
// Crop refuses on rotated elements because the overlay (dim mask,
// crop rect, transformer anchors) is axis-aligned and wouldn't
// visually match a rotated image. Surfaced as an info toast rather
// than silently hiding the Crop button — the user gets a clear
// explanation of why nothing happened and what to try instead.
// 4-second duration in App.jsx so the user has time to read the
// suggested action.
'toast.crop-rotation-blocked': 'Reset rotation to 0° before cropping. Press ⌘Z to undo any rotation first.',
// Apply Crop fired before the image had finished loading its pixel
// data into the Konva node — we can't compute the source crop
// region without natural dimensions, so the apply is a no-op and
// we surface this so the user knows to try again in a moment.
// App.jsx's handleApplyCrop also keeps crop mode active in this
// case (rather than silently closing it), so the user can simply
// click Apply again once the image has decoded.
'toast.crop-not-ready': 'Photo is still loading. Try Apply again in a moment.',
// Cart
'cart.disabled-out-of-bounds': 'Move everything inside the print area to add to cart.',
'cart.add': 'Add to Cart',
// Sidebar tabs
'sidebar.tab.upload': 'Upload Photo',
'sidebar.tab.stickers': 'Stickers',
'sidebar.tab.emoji': 'Emoji',
'sidebar.tab.text': 'Text',
'sidebar.tab.layers': 'Layers',
'sidebar.preview-on-model': 'Preview on Model',
// Text tab
'text.field.message': 'Your message',
'text.placeholder': 'e.g. Best Pup Ever',
'text.field.font': 'Font',
'text.field.size': 'Size',
'text.field.color': 'Color',
'text.field.outline': 'Outline',
'text.field.arc': 'Arc',
'text.editing-banner': 'Editing selected text',
'text.recent-colors': 'Recent',
'text.contrast-warning': 'Low contrast with shirt color \u2014 your text may be hard to see.',
'text.arc-flat': 'Flat',
'text.arc-slight-up': 'Slight curve up',
'text.arc-slight-down': 'Slight curve down',
'text.arc-medium-up': 'Medium curve up',
'text.arc-medium-down': 'Medium curve down',
'text.arc-strong-up': 'Strong curve up',
'text.arc-strong-down': 'Strong curve down',
'text.arc-reset': 'Reset to flat',
'text.preview-fallback': 'Preview',
'text.add-button': 'Add text to canvas',
// Element toolbar
'toolbar.delete': 'Delete',
'toolbar.duplicate': 'Duplicate',
'toolbar.bring-forward': 'Bring forward',
'toolbar.send-backward': 'Send backward',
'toolbar.flip-h': 'Flip horizontally',
'toolbar.flip-v': 'Flip vertically',
'toolbar.lock': 'Lock',
'toolbar.unlock': 'Unlock',
'toolbar.opacity': 'Opacity',
'toolbar.size': 'Size',
'toolbar.edit-photo': 'Edit Photo',
// Layers
'layers.title': 'Layers ({count})',
'layers.empty': 'No elements yet. Add images, text, or stickers to your design.',
'layers.delete-many': 'Delete {count} selected',
'layers.dragHandle-tooltip': 'Drag to reorder',
// Bounds warning
'bounds.singular': '1 element is outside the print area and won\u2019t be printed.',
'bounds.plural': '{count} elements are outside the print area and won\u2019t be printed.',
// Preview modal
'preview.title': 'Print preview',
'preview.close': 'Close',
'preview.loading': 'Rendering at print resolution\u2026',
'preview.error': 'Couldn\u2019t render preview: {error}',
'preview.hint': 'This is what will print on your shirt \u2014 4500\u00d74500 at 300 DPI.',
'preview.keep-editing': 'Keep editing',
'preview.download-png': 'Download PNG',
'toast.upload-failed': 'Upload failed: {error}',
};

View File

@@ -1,33 +1,38 @@
import { en } from './messages';
/**
* Tiny i18n helper (S23).
* Tiny i18n helper for the host application.
*
* Looks up `key` in the active catalog and substitutes any `{name}`
* placeholders with values from `params`. Returns the key itself if no
* entry exists, so missing translations are visible in the UI rather
* than silently rendering empty strings.
* placeholders with values from `params`. Returns the key itself if
* no entry exists, so missing translations are visible in the UI
* rather than silently rendering empty strings.
*
* Single-locale for now — `en` is the only catalog. Adding more is a
* matter of importing them here and exposing a `setLocale` hook (or
* reading from the browser's `navigator.language`); the call sites of
* `t()` don't need to change.
* Mirrors the same helper inside the goods-editor module — kept as a
* separate file (rather than re-exported from the module) so the host
* can evolve its i18n independently. If the host ever needs different
* placeholder syntax, plural rules, or a different message format,
* it can change here without touching the module.
*
* Usage:
* import { t } from '@/i18n/t';
* t('header.save-button') // → 'Save'
* t('toast.cart-items', { count: 3, plural: 'items' }) // simple sub
* Usage
* ─────
* import { t } from './i18n/t';
* t('header.save') // \u2192 'Save'
* t('header.logo-home-aria', { app: 'Pawfectly Yours' })
*
* Why not react-intl / i18next? At this scale a flat catalog plus a
* 10-line helper covers the actual feature surface (button labels,
* toasts, modal copy) without pulling in a dependency that has its own
* upgrade story, message-format dialect, and bundle weight. If we hit
* full ICU MessageFormat needs (gender, complex plurals, RTL with
* inline number formatting), revisit the library question then.
* Why no library
* ──────────────
* react-intl / i18next solve problems we don\u2019t have yet: complex
* plural rules, gender, number/date formatting, lazy-loaded locale
* bundles, message extraction tooling. For an en-only catalog with
* ~25 keys, a 10-line helper is the right size. When we need ICU
* MessageFormat (e.g. for Polish/Arabic plural rules) or DateTime
* formatting, we revisit the library question then.
*/
// Currently we only ship one catalog. If/when more land, this would
// become a dispatch on a runtime locale value.
// Single-locale for now. When more land, this becomes a runtime
// dispatch on a locale value \u2014 see the docblock in messages.js for
// the rough shape of that evolution.
const ACTIVE_CATALOG = en;
const PLACEHOLDER_RE = /\{(\w+)\}/g;
@@ -35,7 +40,9 @@ const PLACEHOLDER_RE = /\{(\w+)\}/g;
export function t(key, params) {
const raw = ACTIVE_CATALOG[key];
if (raw === undefined) {
// Surface the missing key directly so it stands out in the UI.
// Surface the missing key directly so it stands out in the UI
// ('header.save-button' is obviously not English) rather than
// silently rendering empty strings that look like a CSS issue.
return key;
}
if (!params) return raw;

View File

@@ -122,6 +122,37 @@ body {
overflow: hidden;
}
/* The page-level chrome wrapper. Holds the header on top and the
* editor mount underneath, in a flex column that fills #root.
*
* Why this matters
* ────────────────
* Without these styles, .app-shell would render as a plain block
* div sized to its content. The <main flex: 1> child inside would
* then have no flex context to grow into, so `flex: 1` would be a
* no-op and <main> would shrink to its content's natural height.
* Since the editor inside <main> uses `height: 100%`, it would
* resolve against <main>'s zero height and collapse.
*
* Making .app-shell its own flex column gives <main> a definite
* vertical slot to grow into, which then gives the editor a
* definite height to fill. The header takes its natural height
* (no flex), <main> claims everything else (`flex: 1`), and the
* editor inside renders at exactly viewport-minus-header pixels
* — no `calc()` involved.
*
* `min-height: 0` is the flexbox shrink-fix: without it, a flex
* item's minimum size is its content's intrinsic min size, which
* means a tall editor could push the shell past 100vh even though
* #root is capped there. min-height: 0 lets <main> actually obey
* the flex distribution. */
.app-shell {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
/* ────────────────────────────────────────────────────────────────────────── */
/* Generic interactive elements */
/* ────────────────────────────────────────────────────────────────────────── */
@@ -187,17 +218,13 @@ input[type='range']::-moz-range-thumb {
}
/* ────────────────────────────────────────────────────────────────────────── */
/* Shared utilities (S13) */
/* */
/* Pulled out of individual component stylesheets so the same pattern */
/* doesn't get re-defined in 5 places. Add new utilities here when a */
/* CSS rule shows up in 3+ files unchanged. */
/* Shared utilities */
/* ────────────────────────────────────────────────────────────────────────── */
/* Visually hide content while keeping it accessible to screen readers.
* The classic 1px clipping trick. `.sr-only` is an alias because
* different parts of the codebase grew up using one or the other (S24
* audit referenced both — unifying them prevents drift). */
* different parts of the codebase grew up using one or the other
* unifying them as a single rule prevents drift. */
.visually-hidden,
.sr-only {
position: absolute;
@@ -212,9 +239,8 @@ input[type='range']::-moz-range-thumb {
}
/* Strip native <button> chrome so you can build a custom-styled clickable
* surface that still gets keyboard accessibility for free. Used by
* LayersPanel rows, Sidebar tabs, and similar. Pair with explicit
* `:focus-visible` styling so keyboard users still see focus. */
* surface that still gets keyboard accessibility for free. Pair with
* explicit `:focus-visible` styling so keyboard users still see focus. */
.btn-reset {
appearance: none;
background: transparent;
@@ -229,7 +255,7 @@ input[type='range']::-moz-range-thumb {
/* Generic fade+slide-up entrance for toasts, banners, and other
* top-anchored chips. Components that need an X-translate variant
* (e.g. .app-toast which is centered via translateX(-50%)) keep their
* (e.g. the centered .app-toast which is translateX(-50%)) keep their
* own bespoke @keyframes since this one only handles Y. */
@keyframes fade-up-in {
from { opacity: 0; transform: translateY(-6px); }
@@ -237,7 +263,11 @@ input[type='range']::-moz-range-thumb {
}
/* ────────────────────────────────────────────────────────────────────────── */
/* Spinner — used by background removal and uploads */
/* Spinner — generic small loading indicator */
/* */
/* White ring on translucent background, sized for use inside buttons or */
/* chips during async operations. The export-status pill in the editor */
/* module uses this same class (re-rendered against the host's stylesheet). */
/* ────────────────────────────────────────────────────────────────────────── */
.spinner-small {
@@ -332,66 +362,3 @@ input[type='range']::-moz-range-thumb {
gap: 0.5rem;
box-shadow: var(--shadow-md);
}
/* ────────────────────────────────────────────────────────────────────────── */
/* Background removal button (rendered inside ElementToolbar) */
/* ────────────────────────────────────────────────────────────────────────── */
.bg-removal-container {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.bg-removal-btn {
width: 100%;
padding: 0.65rem 0.85rem;
background: linear-gradient(135deg, var(--brand-pink), var(--brand-lavender));
color: #fff;
border: none;
border-radius: var(--radius-md);
font-weight: 600;
font-size: 13px;
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease, opacity 0.12s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
box-shadow: 0 4px 10px -2px rgba(236, 72, 153, 0.4);
}
.bg-removal-btn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 8px 16px -4px rgba(236, 72, 153, 0.55);
}
.bg-removal-btn:disabled {
opacity: 0.65;
cursor: not-allowed;
}
/* ────────────────────────────────────────────────────────────────────────── */
/* Filerobot photo editor modal */
/* ────────────────────────────────────────────────────────────────────────── */
.filerobot-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(31, 29, 35, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.filerobot-container {
width: 90%;
height: 90%;
background: #1e1e1e;
border-radius: var(--radius-lg);
overflow: hidden;
}

View File

@@ -1,39 +0,0 @@
.canvas-hint {
position: absolute;
top: 14px;
left: 14px;
display: inline-flex;
align-items: center;
gap: 0.5rem;
background: #fff;
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 0.5rem 0.75rem;
box-shadow: var(--shadow-xs);
color: var(--brand-pink-strong);
z-index: 5;
}
.canvas-hint__text {
display: flex;
flex-direction: column;
line-height: 1.05;
}
.canvas-hint__text strong {
font-size: 12px;
font-weight: 700;
color: var(--text-primary);
}
.canvas-hint__text span {
font-size: 10.5px;
color: var(--brand-pink);
font-weight: 500;
}
@media (max-width: 480px) {
.canvas-hint {
display: none;
}
}

View File

@@ -1,189 +0,0 @@
/* ──────────────────────────────────────────────────────────────────────────
* DesignCanvas — Pawfectly Yours
*
* The canvas frame is a soft pink stage. The wrapper inside is sized to the
* product mockup's aspect ratio (e.g. shirt = 480/540) and contains:
* 1. ModelSilhouette (z=0, conditional on Preview-on-Model)
* 2. Product mockup SVG (z=1)
* 3. Print-zone dashed border (z=3, when selected)
* 4. Konva Stage, positioned absolutely at the print zone (z=2, see JSX
* inline styles for the exact placement math)
*
* Why this layout
* ───────────────
* The Konva Stage is the editing canvas. Aligning it visually with the print
* zone of the product means the user designs *exactly* where the design will
* print. The Stage's internal coordinate system is unchanged (still 300×300
* design units with HANDLE_PADDING for transformer handles); only the visual
* scale changes via CSS, driven by ResizeObserver in DesignCanvas.jsx.
* ────────────────────────────────────────────────────────────────────────── */
.design-canvas-frame {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: var(--brand-pink-tint);
border-radius: var(--radius-xl);
overflow: hidden;
/* Subtle grid texture inside the canvas card */
background-image:
linear-gradient(rgba(236, 72, 153, 0.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(236, 72, 153, 0.05) 1px, transparent 1px);
background-size: 24px 24px;
background-position: center center;
}
/* Wrapper — the host element for both the product mockup and the Konva Stage.
*
* Width and height are set inline by JS each time the frame's available area
* changes. The JS computes the LARGEST size that fits inside the frame while
* preserving the product mockup's aspect ratio (e.g. shirt = 480/540), so
* the canvas grows to fill the entire left side of the editor on big monitors
* and shrinks gracefully on smaller ones.
*
* The transition rule below animates width/height/transform changes — BUT
* only after `.design-canvas-frame--ready` has been added to the parent
* (one frame after mount). The initial sizing pass (default-state → fitted
* size) happens in a useLayoutEffect before paint, and we don't want it to
* animate; the transition only kicks in for subsequent resize events.
*
* `will-change` hints the compositor that width/height/transform are about
* to change so it can promote this layer ahead of the first resize. We only
* apply it once `--ready` is set so we don't pay the layer-promotion cost
* for the entire app lifetime when the user never resizes the window. */
.design-canvas-wrapper {
position: relative;
/* width and height are set inline from JS — do not set them here */
transform-origin: center center;
/* Clip elements to the visible shirt. The Konva stage now extends past
* the print zone to cover the whole wrapper plus HANDLE_PADDING of
* slop on each side (so the user can drag/transform anywhere on the
* shirt). overflow:hidden ensures anything outside the wrapper itself
* is clipped — a defensive guard against rounding artifacts and a
* visual containment for elements positioned very close to the
* wrapper edge. */
overflow: hidden;
/* zoom-only transition; layout transitions are added by the --ready class */
transition: transform 0.18s ease-out;
}
.design-canvas-frame--ready .design-canvas-wrapper {
/* Animate width/height as well as transform once we're past the initial
* sizing. cubic-bezier(0.32, 0.72, 0, 1) is a soft ease-out that feels
* natural for size changes — a hair faster at the start, glides to a
* stop. 220ms is short enough to feel responsive yet long enough to read
* as an animation rather than a snap. */
transition: width 0.22s cubic-bezier(0.32, 0.72, 0, 1),
height 0.22s cubic-bezier(0.32, 0.72, 0, 1),
transform 0.18s ease-out;
will-change: width, height;
}
/* Print-zone border. Always visible at low opacity as a placement guide so
* the user knows where the design will print, even with nothing selected.
* When an element is selected the border darkens to give selection feedback.
* Position + size are set inline via percentages in JSX so the border tracks
* the print zone exactly. */
.design-canvas-border {
border-radius: var(--radius-md);
}
/* Konva Stage — absolutely positioned and CSS-scaled to overlay the print
* zone. The exact position/scale is set inline in JSX from runtime measurements;
* this rule just provides defaults for cases where inline styles haven't
* applied yet (e.g. SSR/hydration).
*
* The transition on `transform` animates the stage's CSS-scale so resizes
* are visually continuous — the stage grows/shrinks together with its
* wrapper rather than snapping to the new scale on the next render.
* Konva's INTERNAL coords are unaffected; the bound functions, drag
* positions, and export pipeline still operate in the canonical 300×300
* space throughout the animation. The `transition` here is gated behind
* the same `--ready` class as the wrapper so the initial scale doesn't
* animate. */
.design-canvas-stage {
position: absolute;
z-index: 2;
}
.design-canvas-frame--ready .design-canvas-stage {
transition: transform 0.22s cubic-bezier(0.32, 0.72, 0, 1),
left 0.22s cubic-bezier(0.32, 0.72, 0, 1),
top 0.22s cubic-bezier(0.32, 0.72, 0, 1);
}
/* During an active drag/transform, suppress the stage transition so input
* stays maximally responsive. The wrapper's transform stays animated
* (the user can still resize the window mid-edit) but the stage's CSS
* scale changes won't lag behind — they apply instantly. We rely on
* Konva adding `konvajs-content` to its container during interaction;
* during normal idle state both transitions run as defined above. */
/* Bounds-violation warning chip ------------------------------------------
*
* Pinned at the TOP of the canvas frame (Change 22), full-width inside
* the frame's padding. Previously sat at the bottom, but designs that
* extended into the lower half of the print area visually conflicted
* with the chip — the per-element yellow out-of-bounds tint behind the
* offending element + the warning chip overlapping it read as a single
* blob of yellow. Anchoring at the top gives the chip its own zone
* away from typical design placement.
*
* Visually distinct from the pink brand color because the cart
* disable that pairs with it is destructive — amber/orange reads as
* "caution" without competing with the rest of the pink UI. */
.design-canvas-warning {
position: absolute;
left: 0.75rem;
right: 0.75rem;
top: 0.75rem;
z-index: 4;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.55rem;
padding: 0.6rem 0.85rem;
border-radius: var(--radius-md);
background: rgba(255, 251, 235, 0.97);
border: 1px solid #fbbf24;
color: #92400e;
font-size: 13px;
font-weight: 600;
line-height: 1.3;
text-align: center;
box-shadow: 0 6px 14px -4px rgba(146, 64, 14, 0.18);
pointer-events: none; /* informational only — cart-bar is the action */
animation: dcw-fade-in 0.18s ease-out;
}
.design-canvas-warning svg {
flex-shrink: 0;
color: #d97706;
}
/* Fade-in slides down from above now that the chip is anchored at the
* top of the frame — visually consistent with "appears from off-screen
* top" rather than the previous "appears from off-screen bottom". */
@keyframes dcw-fade-in {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
@media (max-width: 768px) {
.design-canvas-frame {
border-radius: var(--radius-lg);
}
/* width/height are set from JS now — no media-query overrides needed.
* The fit-to-frame math automatically produces the right size on any
* viewport, including mobile portrait/landscape. */
.design-canvas-warning {
left: 0.5rem;
right: 0.5rem;
top: 0.5rem;
font-size: 12px;
padding: 0.5rem 0.7rem;
}
}

View File

@@ -1,569 +0,0 @@
.el-toolbar {
background: #fff;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-md);
padding: 0.85rem 1rem;
display: flex;
flex-direction: column;
gap: 0.85rem;
width: 100%;
max-width: 380px;
}
/* Action row --------------------------------------------------------------- */
.el-toolbar__row {
display: flex;
align-items: center;
}
.el-toolbar__actions {
gap: 0.4rem;
flex-wrap: wrap;
/* Left-justified, not space-between. With the toolbar slimmed down to
* Flip H + Flip V (+ optional mobile-only Edit-text), space-between
* pushed two buttons to opposite ends of the toolbar with a large
* empty gap in the middle, which read as broken spacing rather than
* deliberate layout. flex-start groups the action buttons together
* on the left so the empty space on the right is the natural
* unused area of a small action row. */
justify-content: flex-start;
border-bottom: 1px dashed var(--border);
padding-bottom: 0.85rem;
}
.el-toolbar__btn {
flex: 0 1 auto;
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 0.5rem 0.5rem;
min-width: 48px;
border-radius: var(--radius-sm);
border: none;
background: transparent;
color: var(--text-secondary);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.2px;
transition: background 0.12s ease, color 0.12s ease, transform 0.12s ease;
}
.el-toolbar__btn:hover {
background: var(--brand-pink-soft);
color: var(--brand-pink-strong);
}
.el-toolbar__btn:active {
transform: scale(0.96);
}
/* Busy state for action-row buttons that have an async backing
* operation (currently just Background Removal). Reduces opacity and
* disables hover so the button reads as "working, not interactive",
* matches the native :disabled semantics, and keeps the cursor as
* `wait` until the work completes. The spinner glyph below stands
* in for the normal SVG icon during this state — vertical centering
* is preserved because both icons render in roughly the same
* 18×18 region. */
.el-toolbar__btn--busy,
.el-toolbar__btn:disabled {
opacity: 0.55;
cursor: wait;
}
.el-toolbar__btn--busy:hover,
.el-toolbar__btn:disabled:hover {
background: transparent;
color: var(--text-secondary);
}
/* Spinner used inside `.el-toolbar__btn` when an async operation is
* in flight. Sized to roughly match the action-row SVG icons so the
* layout doesn't jump when swapping between the two states. */
.el-toolbar__spinner {
width: 16px;
height: 16px;
border: 2px solid var(--brand-pink-soft);
border-top-color: var(--brand-pink);
border-radius: 50%;
animation: el-toolbar-spin 0.8s linear infinite;
}
@keyframes el-toolbar-spin {
to { transform: rotate(360deg); }
}
.el-toolbar__btn--danger {
color: var(--brand-pink-strong);
}
.el-toolbar__btn--danger:hover {
background: #ffe4eb;
color: #be1259;
}
/* Active-state variant. Used by toggle-shaped buttons (currently the
* Snap-to-center toggle) to visually indicate "this mode is ON". The
* palette mirrors the filter-chip active state below so the toolbar's
* "a thing is currently turned on" affordance reads consistently no
* matter which control it's attached to. Hover lands on the same
* brand-pink-soft surface, so hovering an already-active button just
* stays active rather than flashing into a different state. */
.el-toolbar__btn--active {
background: var(--brand-pink-soft);
color: var(--brand-pink-strong);
}
.el-toolbar__btn--active:hover {
background: var(--brand-pink-soft);
color: var(--brand-pink-strong);
}
.el-toolbar__btn-label {
font-size: 10px;
line-height: 1.1;
}
/* Layer controls section (mobile only) ----------------------------------
*
* Renders below the action row, above the sliders. Contains a small
* uppercase label followed by a row of five buttons (Duplicate, Lock,
* Move above, Move below, Delete). The wrapping container stacks
* label-over-buttons; the inner row is flex with `space-between` so
* the five buttons spread across the toolbar width rather than
* clustering on the left like the action row does — the section is
* its own thing with a clear visual boundary, not a continuation of
* the actions above it.
*
* No own border/separator: the action row above already has
* `border-bottom: 1px dashed` and `padding-bottom: 0.85rem`, plus
* the parent's `gap: 0.85rem`, which together give a visible break
* between actions and this section. Adding a top border here would
* read as a doubled-up divider.
*
* The action buttons inside (.el-toolbar__btn) carry their own
* styling from the rule above. At ≤480px the existing media query
* hides `.el-toolbar__btn-label`, so on phones this row collapses
* to icons only — by design, since five labelled buttons would not
* fit on a narrow toolbar without wrapping. The icons (copy, lock,
* chevron-up-in-square, chevron-down-in-square, trash) are chosen
* to be legible without their labels. */
.el-toolbar__layer-section {
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.el-toolbar__section-title {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.5px;
text-transform: uppercase;
color: var(--text-secondary);
}
.el-toolbar__layer-actions {
gap: 0.4rem;
justify-content: space-between;
}
/* Crop-mode toolbar face --------------------------------------------------
*
* When App enters crop mode for an image, ElementToolbar replaces its
* normal layout with a title + Cancel / Apply pair. The same outer
* .el-toolbar shell is kept so the floating panel's position and
* shadow don't visibly shift when entering/leaving crop mode. */
.el-toolbar--crop {
gap: 0.75rem;
}
.el-toolbar__crop-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 13px;
font-weight: 700;
color: var(--text-primary);
letter-spacing: 0.2px;
}
.el-toolbar__crop-actions {
display: flex;
gap: 0.5rem;
}
.el-toolbar__crop-btn {
flex: 1;
padding: 0.65rem 0.8rem;
border-radius: var(--radius-md);
font-weight: 600;
font-size: 13px;
cursor: pointer;
transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease;
}
/* Primary action — Apply crop. Filled pink so the user reads it as
* "this is the action that commits the in-progress change". */
.el-toolbar__crop-btn--primary {
background: var(--brand-pink);
color: #fff;
border: 1px solid var(--brand-pink);
}
.el-toolbar__crop-btn--primary:hover {
background: var(--brand-pink-strong);
border-color: var(--brand-pink-strong);
}
/* Secondary action — Cancel. Outlined so it reads as the safe escape
* route rather than competing visually with Apply. */
.el-toolbar__crop-btn--secondary {
background: #fff;
color: var(--text-primary);
border: 1px solid var(--border);
}
.el-toolbar__crop-btn--secondary:hover {
border-color: var(--brand-pink);
color: var(--brand-pink-strong);
}
/* Sliders ------------------------------------------------------------------ */
.el-toolbar__sliders {
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.el-toolbar__slider-row {
display: grid;
/* Three columns: label | slider | end-cluster (value + optional
* reset button). The end cluster lives inside a single grid cell
* so the value text and reset button can share their column via
* an inner flex container (.el-toolbar__slider-end). Sizing the
* end column differs between mobile and desktop — see the desktop
* override below for the tightening rationale.
*
* Mobile default: fixed 72px reserves room for value (~44px) +
* gap (~4px) + reset (24px), so the slider's effective width
* doesn't jump as the user drags past a "changed from default"
* threshold and the reset button appears or disappears. A jittery
* slider under an active drag reads as a bug; the cost is ~24px
* less horizontal room for the track, accepted on the narrow
* mobile toolbar (where slider precision matters less than
* stable layout). */
grid-template-columns: 60px 1fr 72px;
align-items: center;
gap: 0.6rem;
}
/* Desktop — tighten the end column.
*
* Above the mobile breakpoint (matches App.jsx's MOBILE_QUERY of
* 768px), the slider track gets the extra room back via
* grid-template-columns ending in `auto`. The end cluster's width
* is sized to its content: ~44px when only the value is visible,
* ~72px when value + reset are both there. So:
*
* • No reset shown → slider has 28px more room than mobile.
* • Reset shown → slider has the same room as mobile.
*
* This means the slider WILL jitter ~28px when the user drags
* across the "changed from default" threshold and the reset button
* appears/disappears. Acceptable on desktop because (a) mouse
* precision makes the crossing event rare during a drag, and (b)
* the desktop bottom toolbar has more horizontal real estate that
* benefits from the extra slider width when no reset is shown.
* On mobile the jitter would be more annoying (touch is less
* precise, more likely to cross thresholds) and the toolbar is
* narrower, so the trade-off lands the other way — see above. */
@media (min-width: 769px) {
.el-toolbar__slider-row {
grid-template-columns: 60px 1fr auto;
}
}
/* End cluster — holds the numeric value and (when changed-from-
* default) the reset icon button. Flex so they sit side-by-side
* with the value right-aligned within the cluster. The min-width
* on the value (set below) keeps single/double/triple-digit
* readouts from shifting the reset button's position as the user
* drags.
*
* Fixed `min-height` matches the reset button's height (24px).
* Without this, the row collapses to the slider+value's natural
* height when no reset is visible (slider input ≈20px, value text
* ≈15px), then expands to 24px the instant the reset appears as
* the user crosses the "changed from default" threshold. That
* vertical jiggle is especially visible when a user drags an
* opacity / brightness / contrast / rotation slider back and forth
* across the default value — the toolbar visibly grows and shrinks
* by ~4px on each crossing, which reads as a glitch. Reserving the
* larger of the two heights up-front keeps the row stable. The
* extra height when no reset is showing is invisible (transparent
* empty space at the right edge of the row) and harmless. */
.el-toolbar__slider-end {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.25rem;
min-width: 0;
min-height: 24px;
}
.el-toolbar__slider-label {
font-size: 12px;
color: var(--text-secondary);
font-weight: 600;
}
.el-toolbar__slider {
margin: 0;
}
/* Slider track wrapper — holds [start-icon] [slider] [end-icon].
*
* The native <input type="range"> is the only flex item that grows;
* the two end icons take their content-width (~14px) and stay
* pinned to the slider's left and right edges. `min-width: 0` on
* this flex container lets the slider shrink with the parent grid
* column rather than overflow it (without it, the implicit
* min-content of <input type=range> is ~150px and would refuse to
* shrink below that, breaking the toolbar layout on narrow screens).
*
* The wrapper itself occupies the same grid cell the bare <input>
* used to occupy — it's a drop-in container around the existing
* slider input. */
.el-toolbar__slider-track {
display: flex;
align-items: center;
gap: 0.4rem;
min-width: 0;
}
.el-toolbar__slider-track .el-toolbar__slider {
/* Slider takes all the remaining width inside the track wrapper.
* Explicit min-width: 0 because flex items default to min-width:
* auto, which for an <input> resolves to the input's intrinsic
* min-content size and refuses to shrink. */
flex: 1 1 auto;
min-width: 0;
}
/* Slider end-icon hints (the dim/solid squares, sun glyphs, etc.
* flanking each slider). See <SliderEndIcon> in ElementToolbar.jsx
* for what gets rendered.
*
* Sized to match the reset button's footprint vertically (24px tall)
* so the slider row's min-height check doesn't fluctuate between
* "reset present" and "reset absent" states. The icon's 14×14 SVG
* sits centered inside this 24×24 box, giving ~5px of padding on
* each side — enough that the icon doesn't visually crowd the slider
* track but not so much that the row stretches horizontally.
*
* Color matches `.el-toolbar__slider-label` (text-secondary) so the
* icons read as informational labels rather than interactive
* affordances. No hover state, no cursor: pointer — these are
* decorative direction hints, not buttons.
*
* `flex: 0 0 auto` pins them to their natural width inside the flex
* track wrapper, leaving the slider input to take the remaining
* room.
*
* `pointer-events: none` so any stray click or tap intended for the
* slider track edge passes through to the slider rather than getting
* swallowed by the icon. The user expectation is that this end of
* the track behaves like the slider itself for grab-and-drag
* purposes — the icon shouldn't be a dead zone. */
.el-toolbar__slider-end-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 24px;
color: var(--text-secondary);
flex: 0 0 auto;
pointer-events: none;
}
.el-toolbar__slider-value {
font-size: 12px;
color: var(--text-primary);
font-weight: 600;
text-align: right;
/* Reserve min-width so single-digit ("5") vs triple-digit ("100")
* readouts don't push the reset button around as the user drags.
* 36px fits "100%" comfortably at the toolbar's 12px font size
* with the right-aligned text settling consistently on the
* right edge of the cell. */
min-width: 36px;
}
/* Per-slider reset button — small icon-only affordance rendered
* conditionally by ElementToolbar's <SliderResetButton> when the
* slider's current value differs from its default (see
* ElementToolbar.jsx for the detection logic). Lives inside
* `.el-toolbar__slider-end` next to the numeric value, sharing
* the slider row's end-cluster column via a flex container.
*
* No visible chrome until hover, so the button doesn't compete
* with the slider/value for visual attention; on hover it gets
* the same brand-pink-soft wash + brand-pink-strong ink that the
* action-row buttons use, keeping the toolbar's interaction
* language consistent.
*
* 24×24 square with the 14×14 SVG centered inside, leaving
* roughly 5px of padding on each side — enough that the click
* target stays comfortably tappable on mobile even though the
* icon itself is small. */
.el-toolbar__slider-reset {
background: transparent;
border: none;
padding: 0;
width: 24px;
height: 24px;
border-radius: var(--radius-sm);
color: var(--text-secondary);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.12s ease, color 0.12s ease, transform 0.12s ease;
}
.el-toolbar__slider-reset:hover {
background: var(--brand-pink-soft);
color: var(--brand-pink-strong);
}
.el-toolbar__slider-reset:active {
transform: scale(0.92);
}
/* Advanced (image-only) ---------------------------------------------------- */
.el-toolbar__advanced {
display: flex;
flex-direction: column;
gap: 0.6rem;
padding-top: 0.4rem;
border-top: 1px dashed var(--border);
}
.el-toolbar__advanced-btn {
width: 100%;
padding: 0.6rem 0.8rem;
border-radius: var(--radius-md);
border: 1px solid var(--brand-pink);
background: var(--brand-pink-soft);
color: var(--brand-pink-strong);
font-weight: 600;
font-size: 13px;
display: flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
transition: background 0.12s ease;
}
.el-toolbar__advanced-btn:hover {
background: #fad0e0;
}
/* Background removal button (rendered inside this toolbar) ---------------- */
.el-toolbar__advanced .bg-removal-container {
margin: 0;
padding: 0;
border: none;
}
/* Image filter chips (image-only) -----------------------------------------
*
* Compact horizontal row of toggleable filter presets. Selected chip uses
* the brand-pink fill; unselected chips use a neutral wash. The icon glyph
* is decorative — the label carries the meaning, since glyph aesthetics
* are arbitrary. */
.el-toolbar__filters {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.el-toolbar__filter {
flex: 1 1 auto;
min-width: 56px;
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 0.45rem 0.4rem;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: #fff;
color: var(--text-secondary);
font-weight: 600;
cursor: pointer;
transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease;
}
.el-toolbar__filter:hover {
border-color: var(--brand-pink);
color: var(--brand-pink-strong);
}
.el-toolbar__filter.is-active {
background: var(--brand-pink-soft);
border-color: var(--brand-pink);
color: var(--brand-pink-strong);
}
.el-toolbar__filter-icon {
font-size: 14px;
line-height: 1;
}
.el-toolbar__filter-label {
font-size: 10px;
letter-spacing: 0.2px;
}
@media (max-width: 480px) {
.el-toolbar {
padding: 0.7rem 0.85rem;
border-radius: var(--radius-md);
}
.el-toolbar__btn {
min-width: 42px;
padding: 0.45rem 0.35rem;
}
.el-toolbar__btn-label {
display: none;
}
}
/* Hide the Size slider on desktop.
*
* On desktop the user has fine-grained corner-drag handles on the canvas
* (the Konva transformer's keepRatio scale) plus arrow-key nudge, so the
* slider is a redundant control. Hiding it tightens the floating toolbar
* vertically without losing any capability. Mobile users can't easily
* corner-drag a small touch target on a small screen, so the slider
* stays as the primary size affordance there.
*
* The Opacity slider is intentionally kept on both desktop and mobile —
* opacity has no corner-handle equivalent and the slider is its only
* control surface. Only the Size row is hidden.
*
* Breakpoint matches App.jsx's MOBILE_QUERY (max-width: 768px) — the
* same line that switches between mobile bottom-sheet and desktop
* right-rail. Anything at ≥769px is the desktop layout, where the
* canvas is the primary surface and corner drag is available. */
@media (min-width: 769px) {
.el-toolbar__slider-row--size {
display: none;
}
}

View File

@@ -108,10 +108,6 @@
cursor: not-allowed;
}
.ph-iconbtn--menu {
display: none;
}
.ph-pillbtn {
display: inline-flex;
align-items: center;
@@ -128,16 +124,6 @@
transform: translateY(1px);
}
.ph-pillbtn--filled {
background: var(--brand-pink);
color: #fff;
box-shadow: 0 4px 12px -4px rgba(236, 72, 153, 0.55);
}
.ph-pillbtn--filled:hover {
background: var(--brand-pink-strong);
}
.ph-pillbtn--ghost {
background: var(--brand-lavender-soft);
color: #6b50a4;
@@ -222,11 +208,17 @@
.ph-title {
display: none;
}
.ph-pillbtn,
.ph-cart {
/* Hide the Share pill on mobile — we don't have an icon-only
* variant for it yet, and the cart icon button is the priority
* action on the right side of the bar. Re-adding requires a
* mobile redesign of the action cluster (e.g. converting Share
* to an icon-button, possibly moving Preview / Download into
* the editor module's mobile UI). */
.ph-pillbtn {
display: none;
}
.ph-iconbtn--menu {
display: inline-flex;
}
/* Cart stays visible on mobile — previously hidden because the
* Menu button opened a bottom sheet that contained the cart, but
* the Menu and that sheet are both gone. Cart needs a direct
* tap target on mobile. */
}

View File

@@ -1,313 +0,0 @@
/* ──────────────────────────────────────────────────────────────────────────
* LayersPanel — Pawfectly Yours
*
* Class names match the rewritten LayersPanel.jsx (S3 + S24):
* • Each row is a real <button> for keyboard access (S24).
* • A ✓ chip + ring communicates selection without relying on color (S24).
* • Drag handle (⋮⋮) per row enables HTML5 DnD reorder (S3).
* • Bulk-delete affordance appears in the titlebar when ≥2 are selected (S3).
* • Drop indicator (.layers-drop-indicator) is the pink insertion line that
* appears above or below the hovered row during a drag.
* ────────────────────────────────────────────────────────────────────────── */
.layers-empty {
padding: 1rem;
text-align: center;
color: var(--text-muted);
font-size: 12px;
}
/* Titlebar — title on the left, bulk-delete on the right. The bulk-delete
* only renders when 2+ rows are selected; in single-select state the bar
* is just the title and looks identical to before. */
.layers-titlebar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
gap: 0.5rem;
}
.layers-title {
margin: 0;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.4px;
}
.layers-bulk-delete {
appearance: none;
background: transparent;
border: 1px solid #fecaca;
color: #b91c1c;
font-size: 11px;
font-weight: 700;
padding: 0.3rem 0.6rem;
border-radius: var(--radius-pill);
cursor: pointer;
transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease;
}
.layers-bulk-delete:hover {
background: #fef2f2;
border-color: #fca5a5;
color: #991b1b;
}
.layers-list {
display: flex;
flex-direction: column;
gap: 4px;
list-style: none;
padding: 0;
margin: 0;
}
/* Row wrapper: container for the row plus optional drop-indicator lines
* above and below it. Using a wrapper rather than positioning the
* indicator inside the row makes it easier to render before/after the
* row's bbox without margin tricks. */
.layers-row-wrap {
position: relative;
display: flex;
flex-direction: column;
gap: 2px;
}
/* The row itself — a flex row holding [delete] [main button] [grip]. It's
* a div (not a button) because the row contains two distinct interactive
* elements; the row-level click target is .layers-item-main. */
.layers-item {
display: flex;
align-items: stretch;
gap: 0.25rem;
padding: 0;
background: transparent;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
transition: background 0.12s ease, border-color 0.12s ease, opacity 0.12s ease;
}
.layers-item.selected {
background: var(--accent-bg);
border-color: var(--accent);
/* Visible ring around selected rows so colorblind users can see
* selection without depending on the pink fill alone. (S24.) */
box-shadow: 0 0 0 2px rgba(236, 72, 153, 0.18);
}
/* While being dragged, dim the row so the user can see what's happening
* and so the drop indicator lines below stand out by contrast. */
.layers-item.is-dragging {
opacity: 0.4;
}
/* Drag grip — vertical dots glyph on the row's right edge. Decorative for
* keyboard users (tabIndex=-1 in the JSX); actually pickable for mouse
* via HTML5 DnD on the parent .layers-item. */
.layers-item-grip {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
flex-shrink: 0;
color: var(--text-muted);
font-size: 11px;
letter-spacing: -2px;
cursor: grab;
user-select: none;
}
.layers-item.is-dragging .layers-item-grip {
cursor: grabbing;
}
/* Main row button — wraps the check chip, icon, and name. Reset native
* button styling so it fits the row visually but inherits keyboard
* accessibility from the underlying <button>. */
.layers-item-main {
flex: 1;
display: inline-flex;
align-items: center;
gap: 0.5rem;
appearance: none;
background: transparent;
border: none;
padding: 0.5rem 0.5rem;
font: inherit;
text-align: left;
cursor: pointer;
color: inherit;
border-radius: var(--radius-sm);
min-width: 0; /* allow ellipsis truncation inside flex */
}
.layers-item-main:focus-visible {
outline: 2px solid var(--brand-pink);
outline-offset: -2px;
}
/* Selection ✓ chip — rendered as a small circle on the left of selected
* rows. Empty (no glyph, no border) when the row isn't selected so the
* left edge of unselected rows reads cleanly. (S24.) */
.layers-item-check {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
font-size: 11px;
font-weight: 700;
color: transparent;
flex-shrink: 0;
border-radius: 50%;
transition: background 0.12s ease, color 0.12s ease;
}
.layers-item-check.is-on {
background: var(--brand-pink);
color: #fff;
}
/* Icon slot — fixed 24×24 box on the left of the row's main button.
* Holds one of three things depending on element type:
* - <img> for image and sticker layers (a mini preview of the actual
* pixel content; see renderIcon in LayersPanel.jsx)
* - <svg> for text layers (a T glyph matching the Text tab nav icon)
* - <span> fallback dot for layers we don't have a special icon for
*
* `overflow: hidden` + rounded corners give image thumbnails a polished
* tile look; the soft tint background ensures transparent stickers still
* read against the row. */
.layers-item-icon {
width: 24px;
height: 24px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
overflow: hidden;
border-radius: 4px;
background: var(--brand-pink-tint);
color: var(--text-secondary);
}
/* Image / sticker thumbnail. object-fit: contain keeps the artwork's
* aspect ratio without cropping inside the square slot. */
.layers-item-icon-img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
pointer-events: none;
user-select: none;
}
/* Text-layer 'T' icon. Sized slightly smaller than the slot so it doesn't
* touch the rounded edges; inherits currentColor from the row text so it
* inverts cleanly when the row is selected. */
.layers-item-icon-svg {
width: 16px;
height: 16px;
}
/* Fallback dot for any element type without a custom icon. */
.layers-item-icon-fallback {
font-size: 14px;
line-height: 1;
color: var(--text-muted);
}
/* When the row is selected, switch the icon slot's tint to read against
* the pink selection background — a translucent white tile that lets the
* thumbnail content show through and lifts the SVG T icon's contrast. */
.layers-item.selected .layers-item-icon {
background: rgba(255, 255, 255, 0.65);
}
.layers-item-name {
flex: 1;
font-size: 12px;
color: var(--text-primary);
font-weight: 400;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.layers-item.selected .layers-item-name {
color: var(--accent);
font-weight: 600;
}
/* Per-row action buttons (Change 4). Sit on the RIGHT side of each
* row as siblings of .layers-item-main (the row's main click target),
* stacked horizontally: [main] [duplicate] [lock] [delete] [grip].
*
* Sizing matches the previous .layers-item-delete (28×row-height) so
* the row maintains its original visual weight; each button is just
* a touch slimmer than the main click target so the three icons
* don't dominate the row. Color states:
* • idle: muted grey, same as old delete X
* • hover: brand-pink tint
* • active (lock when locked): brand-pink fill + white icon —
* gives the user instant feedback that the toggle is on
* • delete hover: red, retaining the destructive-action colour
* signal from the previous design
*/
.layers-item-action {
width: 28px;
flex-shrink: 0;
appearance: none;
background: transparent;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
border-radius: var(--radius-sm);
transition: background 0.12s ease, color 0.12s ease;
}
.layers-item-action:hover {
background: var(--brand-pink-soft);
color: var(--brand-pink-strong);
}
.layers-item-action:focus-visible {
outline: 2px solid var(--brand-pink);
outline-offset: -2px;
}
.layers-item-action.is-active {
background: var(--brand-pink-soft);
color: var(--brand-pink-strong);
}
.layers-item-action.is-active:hover {
background: var(--brand-pink);
color: #fff;
}
/* Delete variant — same base + destructive-red hover so the user
* gets the same colour-coded warning the previous bare ✕ button had. */
.layers-item-action--delete:hover {
background: #fef2f2;
color: #b91c1c;
}
/* Drop indicator — a thin pink line that appears above or below the
* hovered row during a drag, communicating where the dragged row will
* land if dropped. Rendered as a sibling of the row inside the row
* wrapper so its position is unambiguous. (S3.) */
.layers-drop-indicator {
height: 2px;
background: var(--brand-pink);
border-radius: 1px;
margin: 0 4px;
box-shadow: 0 0 0 2px rgba(236, 72, 153, 0.18);
}

View File

@@ -1,102 +0,0 @@
/* ──────────────────────────────────────────────────────────────────────────
* MobileBottomSheet — slide-up modal sheet for mobile.
*
* The sheet uses transform-only animation (no width/height/top changes) so it
* stays on the compositor. Backdrop is a full-screen <button> for free
* dismiss-on-tap accessibility. When closed the sheet sits *just* below the
* viewport (translateY(100%)) so it isn't focusable but is paint-ready.
* ────────────────────────────────────────────────────────────────────────── */
.mbs {
position: fixed;
inset: 0;
z-index: 800;
pointer-events: none;
}
.mbs.is-open {
pointer-events: auto;
}
.mbs__backdrop {
position: absolute;
inset: 0;
background: rgba(31, 29, 35, 0);
border: none;
padding: 0;
cursor: pointer;
transition: background 0.22s ease;
}
.mbs.is-open .mbs__backdrop {
background: rgba(31, 29, 35, 0.45);
}
.mbs__sheet {
position: absolute;
left: 0;
right: 0;
bottom: 0;
max-height: 78vh;
background: var(--brand-cream);
border-top-left-radius: var(--radius-xl);
border-top-right-radius: var(--radius-xl);
box-shadow: 0 -16px 40px rgba(31, 29, 35, 0.18);
transform: translateY(100%);
transition: transform 0.28s cubic-bezier(0.32, 0.72, 0.16, 1), max-height 0.22s ease;
display: flex;
flex-direction: column;
/* Respect iOS home indicator. */
padding-bottom: env(safe-area-inset-bottom);
}
/* Compact mode — sheet shrinks to leave room for the canvas above. The
* `max-height` transition is on the same property as the regular sheet so
* height changes (e.g. user selecting a text element while the sheet is
* already open) animate rather than snap. */
.mbs.is-compact .mbs__sheet {
max-height: 55vh;
}
.mbs.is-open .mbs__sheet {
transform: translateY(0);
}
.mbs__handle {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
padding: 0.65rem 0;
border: none;
background: transparent;
cursor: pointer;
flex-shrink: 0;
}
.mbs__handle-grip {
display: block;
width: 44px;
height: 5px;
border-radius: 999px;
background: var(--border-strong);
transition: background 0.12s ease;
}
.mbs__handle:hover .mbs__handle-grip {
background: var(--brand-pink);
}
.mbs__content {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0; /* let internal scroll containers take over */
overflow: hidden;
}
/* Body scroll-lock helper — applied while a sheet is open. */
body.has-bottom-sheet {
overflow: hidden;
touch-action: none;
}

View File

@@ -1,16 +0,0 @@
.model-silhouette {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 0;
/* Fade in/out smoothly when the toggle flips. */
animation: model-fade-in 0.32s ease-out;
}
@keyframes model-fade-in {
from { opacity: 0; transform: scale(0.985); }
to { opacity: 1; transform: scale(1); }
}

View File

@@ -1,7 +0,0 @@
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
}

View File

@@ -1,226 +0,0 @@
/* ──────────────────────────────────────────────────────────────────────────
* PreviewModal — fullscreen print-preview overlay.
*
* Z-index 1000 to sit above the bottom sheet (800), FAB (700), and any
* canvas-area floating UI (export-toast, app-toast at 12-13). Only the
* photo-pre-editor goes higher.
*
* Body-scroll lock is applied via the `has-preview-modal` class to prevent
* pan-while-zoomed on touch devices.
* ────────────────────────────────────────────────────────────────────────── */
.preview-modal {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.preview-modal__backdrop {
position: absolute;
inset: 0;
background: rgba(31, 29, 35, 0.62);
border: none;
padding: 0;
cursor: pointer;
animation: pm-backdrop-in 0.18s ease-out;
}
@keyframes pm-backdrop-in {
from { opacity: 0; }
to { opacity: 1; }
}
.preview-modal__panel {
position: relative;
z-index: 1;
background: var(--brand-cream);
border-radius: var(--radius-xl);
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.35);
width: min(720px, 100%);
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
animation: pm-panel-in 0.22s cubic-bezier(0.32, 0.72, 0.16, 1);
}
@keyframes pm-panel-in {
from { opacity: 0; transform: translateY(8px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.preview-modal__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.9rem 1.2rem;
border-bottom: 1px solid var(--border);
background: #fff;
flex-shrink: 0;
}
.preview-modal__title {
margin: 0;
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
letter-spacing: 0.2px;
}
.preview-modal__close {
background: transparent;
border: none;
padding: 0.4rem 0.55rem;
font-size: 16px;
cursor: pointer;
color: var(--text-secondary);
border-radius: var(--radius-sm);
transition: background 0.12s ease, color 0.12s ease;
}
.preview-modal__close:hover {
background: var(--brand-pink-soft);
color: var(--brand-pink-strong);
}
.preview-modal__body {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: var(--brand-pink-tint);
padding: 1.5rem;
min-height: 280px;
/* Subtle checkerboard so transparent PNG areas read as transparent
* instead of looking like white-on-white. */
background-image:
linear-gradient(45deg, rgba(0, 0, 0, 0.04) 25%, transparent 25%),
linear-gradient(-45deg, rgba(0, 0, 0, 0.04) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, rgba(0, 0, 0, 0.04) 75%),
linear-gradient(-45deg, transparent 75%, rgba(0, 0, 0, 0.04) 75%);
background-size: 16px 16px;
background-position: 0 0, 0 8px, 8px -8px, -8px 0;
}
.preview-modal__image {
max-width: 100%;
max-height: 60vh;
object-fit: contain;
border-radius: var(--radius-md);
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.18);
background: #fff;
}
.preview-modal__loading,
.preview-modal__error {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.8rem;
color: var(--text-secondary);
font-size: 14px;
text-align: center;
}
.preview-modal__error {
color: #b91c1c;
}
.preview-modal__footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.95rem 1.2rem;
border-top: 1px solid var(--border);
background: #fff;
flex-shrink: 0;
}
.preview-modal__hint {
margin: 0;
font-size: 12px;
color: var(--text-secondary);
flex: 1;
min-width: 0;
}
.preview-modal__actions {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
}
.preview-modal__btn {
padding: 0.6rem 1rem;
border-radius: var(--radius-pill);
font-size: 13px;
font-weight: 700;
cursor: pointer;
transition: background 0.12s ease, transform 0.12s ease, box-shadow 0.12s ease;
border: 1.5px solid transparent;
}
.preview-modal__btn--ghost {
background: transparent;
color: var(--text-secondary);
border-color: var(--border-strong);
}
.preview-modal__btn--ghost:hover {
color: var(--text-primary);
background: var(--bg-secondary);
}
.preview-modal__btn--primary {
background: var(--brand-pink);
color: #fff;
border-color: var(--brand-pink);
box-shadow: 0 6px 12px -3px rgba(236, 72, 153, 0.5);
}
.preview-modal__btn--primary:hover:not(:disabled) {
background: var(--brand-pink-strong);
border-color: var(--brand-pink-strong);
transform: translateY(-1px);
}
.preview-modal__btn--primary:disabled {
opacity: 0.55;
cursor: not-allowed;
box-shadow: none;
}
body.has-preview-modal {
overflow: hidden;
touch-action: none;
}
/* Mobile — full-bleed panel, footer wraps if needed. */
@media (max-width: 600px) {
.preview-modal {
padding: 0.5rem;
}
.preview-modal__panel {
max-height: 96vh;
}
.preview-modal__footer {
flex-direction: column;
align-items: stretch;
gap: 0.6rem;
}
.preview-modal__actions {
width: 100%;
}
.preview-modal__btn {
flex: 1;
text-align: center;
}
.preview-modal__hint {
text-align: center;
}
}

View File

@@ -1,113 +0,0 @@
.properties-panel__header {
padding: 1rem;
border-bottom: 1px solid var(--border);
}
.properties-panel__title {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.properties-panel__empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
color: var(--text-muted);
font-size: 12px;
text-align: center;
}
.properties-panel__body {
flex: 1;
overflow: auto;
padding: 1rem;
}
.properties-panel__type-badge {
display: inline-block;
padding: 4px 8px;
background: var(--accent-bg);
border-radius: var(--radius-sm);
font-size: 11px;
font-weight: 600;
color: var(--accent);
text-transform: uppercase;
margin-bottom: 1rem;
}
.properties-panel__section {
margin-bottom: 1rem;
}
.properties-panel__label {
display: block;
font-size: 11px;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 0.5rem;
text-transform: uppercase;
}
.properties-panel__axis-label {
font-size: 10px;
color: var(--text-muted);
}
.properties-panel__row {
display: flex;
gap: 0.5rem;
}
.properties-panel__field {
flex: 1;
}
.properties-panel__input {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: 13px;
}
.properties-panel__range {
width: 100%;
}
.properties-panel__color-input {
width: 100%;
height: 36px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
padding: 2px;
}
.properties-panel__edit-btn {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--accent);
border-radius: var(--radius-md);
background: var(--accent-bg);
color: var(--accent);
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.properties-panel__delete-btn {
width: 100%;
padding: 0.75rem;
border: none;
border-radius: var(--radius-md);
background: var(--error);
color: #fff;
font-size: 13px;
font-weight: 600;
cursor: pointer;
margin-top: 1rem;
}

View File

@@ -1,263 +0,0 @@
.shirt-options {
background: #fff;
border-radius: var(--radius-lg);
padding: 1.25rem 1.4rem;
box-shadow: var(--shadow-sm);
border: 1px solid var(--border);
}
/* Toggle wrapper — the entire header + summary block is a single button
* so clicking anywhere on the visible (collapsed) panel expands it.
* Reset all the browser's default button chrome so it visually matches
* the rest of the panel: no background, no border, no padding, full
* width, inherit font and text alignment from the parent. */
.shirt-options__toggle {
display: block;
width: 100%;
margin: 0;
padding: 0;
border: 0;
background: none;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.shirt-options__toggle:focus-visible {
outline: 2px solid var(--brand-pink);
outline-offset: 4px;
border-radius: var(--radius-sm);
}
.shirt-options__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
/* No bottom margin when collapsed — the summary chip sits directly
* under the title. The expanded content has its own top margin that
* separates it from the header (see .shirt-options__content). */
}
.shirt-options.is-expanded .shirt-options__header {
/* When expanded, the summary chip is gone and the color row needs
* breathing room from the title. Restore the original spacing. */
margin-bottom: 1.1rem;
}
.shirt-options__title {
margin: 0;
display: flex;
align-items: center;
gap: 0.55rem;
font-size: 17px;
font-weight: 700;
color: var(--text-primary);
}
.shirt-options__title-icon {
font-size: 22px;
line-height: 1;
}
.shirt-options__price {
font-size: 22px;
font-weight: 800;
color: var(--brand-pink);
letter-spacing: -0.5px;
white-space: nowrap;
}
/* Compact summary — visible only when collapsed. A single row showing
* a mini color swatch and the color label · size text. The chevron
* affordance lives in the header (always visible) so the user has the
* same expand/collapse signal in both states. */
.shirt-options__summary {
display: flex;
align-items: center;
gap: 0.55rem;
margin-top: 0.4rem;
color: var(--text-secondary);
font-size: 13px;
font-weight: 500;
}
.shirt-options__summary-swatch {
--swatch-color: #fff;
--swatch-border: #e8e8ec;
width: 18px;
height: 18px;
border-radius: 999px;
background: var(--swatch-color);
border: 1px solid var(--swatch-border);
box-shadow: inset 0 -1px 2px rgba(0, 0, 0, 0.06);
flex-shrink: 0;
}
.shirt-options__summary-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.shirt-options__chevron {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
flex-shrink: 0;
/* Rotates 180° when the section is expanded so the chevron points
* up ("click to close") instead of down ("click to reveal"). The
* transition pairs with the content's max-height transition so the
* arrow and the content animate together. */
transition: color 0.12s ease, transform 0.18s ease;
}
.shirt-options.is-expanded .shirt-options__chevron {
transform: rotate(180deg);
}
.shirt-options__toggle:hover .shirt-options__chevron {
color: var(--brand-pink);
}
/* Expanded content wrapper. The max-height transition is what produces
* the collapse / expand animation. We render the markup regardless of
* state (rather than conditional rendering) so the transition has
* something to interpolate between.
*
* 240px comfortably fits the color swatches row + size pills row at
* typical sidebar widths. If a future redesign adds more rows this
* cap may need to grow; values much larger than the content's natural
* height slow the perceived animation because max-height interpolates
* across the full declared range. */
.shirt-options__content {
overflow: hidden;
transition: max-height 0.18s ease, opacity 0.12s ease, margin-top 0.18s ease;
}
.shirt-options.is-collapsed .shirt-options__content {
max-height: 0;
opacity: 0;
/* visibility:hidden after the transition completes pulls the content
* out of the tab order and the accessibility tree. We don't use
* `display:none` because that would prevent the transition from
* running at all. */
visibility: hidden;
margin-top: 0;
}
.shirt-options.is-expanded .shirt-options__content {
max-height: 240px;
opacity: 1;
visibility: visible;
}
.shirt-options__row + .shirt-options__row {
margin-top: 0.85rem;
}
.shirt-options__label {
display: block;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 0.5rem;
letter-spacing: 0.2px;
}
/* Color swatches ----------------------------------------------------------- */
.shirt-options__swatches {
display: flex;
gap: 0.55rem;
flex-wrap: wrap;
}
.shirt-options__swatch {
--swatch-color: #fff;
--swatch-border: #e8e8ec;
width: 30px;
height: 30px;
border-radius: 999px;
background: transparent;
border: 2px solid transparent;
padding: 2px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: transform 0.12s ease;
}
.shirt-options__swatch:hover {
transform: scale(1.06);
}
.shirt-options__swatch:active {
transform: scale(0.94);
}
.shirt-options__swatch-fill {
width: 100%;
height: 100%;
border-radius: 999px;
background: var(--swatch-color);
border: 1px solid var(--swatch-border);
box-shadow: inset 0 -2px 4px rgba(0, 0, 0, 0.06);
}
.shirt-options__swatch.is-selected {
border-color: var(--brand-pink);
}
/* Size pills --------------------------------------------------------------- */
.shirt-options__sizes {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.shirt-options__size {
min-width: 56px;
padding: 0.55rem 1rem;
border-radius: var(--radius-md);
border: 1.5px solid var(--border-strong);
background: #fff;
color: var(--text-primary);
font-weight: 600;
font-size: 14px;
letter-spacing: 0.4px;
transition: background 0.12s ease, border 0.12s ease, color 0.12s ease, transform 0.12s ease;
}
.shirt-options__size:hover {
background: var(--brand-pink-soft);
}
.shirt-options__size:active {
transform: scale(0.97);
}
.shirt-options__size.is-selected {
border-color: var(--brand-pink);
color: var(--brand-pink-strong);
background: var(--brand-pink-soft);
}
@media (max-width: 480px) {
.shirt-options {
padding: 1rem 1.1rem;
border-radius: var(--radius-md);
}
.shirt-options__title {
font-size: 16px;
}
.shirt-options__price {
font-size: 20px;
}
}

View File

@@ -1,248 +0,0 @@
/* ──────────────────────────────────────────────────────────────────────────
* Right panel ("rp") — Pawfectly Yours
*
* Vertically scrollable container with:
* - Shirt Options card (top)
* - Tab nav + active tab panel (middle, fills available space)
* - Sticky Add-to-Cart bar (bottom)
* ────────────────────────────────────────────────────────────────────────── */
.rp {
display: flex;
flex-direction: column;
height: 100%;
background: transparent;
border-left: none;
overflow: hidden;
}
.rp__scroll {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 0.85rem;
display: flex;
flex-direction: column;
gap: 0.85rem;
scrollbar-width: thin;
scrollbar-color: var(--brand-pink-soft) transparent;
}
/* Direct children of .rp__scroll must NOT shrink.
*
* Without this, every section in the scroll column (Shirt Options card,
* the .rp__tools tab card, .rp__layers) is a flex item with the default
* `flex-shrink: 1` plus `min-height: auto`. When the total intrinsic
* height of those sections exceeds .rp__scroll's available height, flex
* layout tries to SHRINK them proportionally to fit — rather than
* leaving them at natural size and letting the container scroll. That
* collides badly with .rp__tools having its own `overflow: hidden`:
* once .rp__tools is forced shorter than its intrinsic content, the
* bottom of the active tab (e.g. the Text tab's "Add text to canvas"
* submit button) gets clipped by the card's own overflow, even though
* .rp__scroll would happily provide a scrollbar if asked.
*
* Setting `flex-shrink: 0` keeps each section at its natural height.
* The combined column then legitimately overflows .rp__scroll, the
* container's `overflow-y: auto` kicks in, and the user scrolls to
* reach the bottom of the tab card. This invariant only became
* visible after Change 13 in [[Refinements_2026-05-20_Part2]] gave
* .rp__scroll a definite height for the first time — before that,
* the chain was all min-height and the scroll container just expanded
* to fit. */
.rp__scroll > * {
flex-shrink: 0;
}
.rp__scroll::-webkit-scrollbar { width: 8px; }
.rp__scroll::-webkit-scrollbar-track { background: transparent; }
.rp__scroll::-webkit-scrollbar-thumb {
background: var(--brand-pink-soft);
border-radius: 999px;
}
.rp__tools {
background: #fff;
border-radius: var(--radius-lg);
border: 1px solid var(--border);
box-shadow: var(--shadow-sm);
overflow: hidden;
}
/* Layers card — sibling of .rp__tools, sits below it inside .rp__scroll.
* Same card chrome (white bg, rounded, hairline border, soft shadow) so
* the two surfaces read as a stacked pair. Padding matches the
* .rp__tabpanel inset so the LayersPanel's titlebar lines up with the
* tab panel content visually. The LayersPanel itself handles its own
* empty state and titlebar, so this section just provides the frame. */
.rp__layers {
background: #fff;
border-radius: var(--radius-lg);
border: 1px solid var(--border);
box-shadow: var(--shadow-sm);
padding: 1rem 1.1rem 1.2rem;
}
.visually-hidden {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Tabs --------------------------------------------------------------------- */
.rp__tabs {
display: flex;
align-items: stretch;
border-bottom: 1px solid var(--border);
background: var(--bg-secondary);
overflow-x: auto;
scrollbar-width: none;
}
.rp__tabs::-webkit-scrollbar { display: none; }
.rp__tab {
flex: 1 1 auto;
min-width: 84px;
padding: 0.85rem 0.4rem 0.7rem;
border: none;
background: transparent;
color: var(--text-secondary);
font-size: 12px;
font-weight: 600;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.3rem;
border-bottom: 2.5px solid transparent;
transition: color 0.12s ease, border-color 0.12s ease, background 0.12s ease;
white-space: nowrap;
}
.rp__tab svg {
color: var(--text-muted);
transition: color 0.12s ease;
}
.rp__tab:hover {
color: var(--brand-pink-strong);
background: rgba(236, 72, 153, 0.04);
}
.rp__tab:hover svg {
color: var(--brand-pink-strong);
}
.rp__tab.is-active {
color: var(--brand-pink);
border-bottom-color: var(--brand-pink);
background: #fff;
}
.rp__tab.is-active svg {
color: var(--brand-pink);
}
.rp__tabpanel {
padding: 1rem 1.1rem 1.2rem;
}
/* Sticky cart bar ---------------------------------------------------------- */
.rp__cart-bar {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 0.6rem;
padding: 0.85rem;
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border-top: 1px solid var(--border);
}
.rp__add-to-cart {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.65rem;
padding: 0.95rem 1.4rem;
border: none;
border-radius: var(--radius-pill);
background: var(--brand-pink);
color: #fff;
font-weight: 700;
font-size: 15px;
letter-spacing: 0.2px;
box-shadow: 0 8px 16px -4px rgba(236, 72, 153, 0.55);
transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.12s ease;
}
.rp__add-to-cart:hover {
background: var(--brand-pink-strong);
transform: translateY(-1px);
box-shadow: 0 10px 20px -6px rgba(236, 72, 153, 0.6);
}
.rp__add-to-cart:active {
transform: translateY(1px);
}
/* Disabled state — paired with bounds-violation warning. The button is
* dimmed, not-allowed cursor, and the colored shadow drops away so it
* stops looking like a primary CTA. The :hover and :active rules above
* are overridden so the user gets no visual response that suggests
* clicking it would do something. */
.rp__add-to-cart:disabled,
.rp__add-to-cart.is-disabled {
background: #e5e7eb;
color: #9ca3af;
box-shadow: none;
cursor: not-allowed;
pointer-events: auto; /* keep the title tooltip working on hover */
}
.rp__add-to-cart:disabled:hover,
.rp__add-to-cart.is-disabled:hover {
background: #e5e7eb;
transform: none;
box-shadow: none;
}
.rp__add-to-cart:disabled:active,
.rp__add-to-cart.is-disabled:active {
transform: none;
}
/* Reason caption shown above the disabled button. */
.rp__cart-reason {
margin: 0;
padding: 0.5rem 0.7rem;
border-radius: var(--radius-sm);
background: rgba(255, 251, 235, 0.95);
border: 1px solid #fbbf24;
color: #92400e;
font-size: 12px;
font-weight: 600;
line-height: 1.35;
text-align: center;
}
.rp__add-to-cart-price {
font-weight: 800;
margin-left: 0.5rem;
letter-spacing: 0;
}
@media (max-width: 480px) {
.rp__scroll { padding: 0.6rem; gap: 0.6rem; }
.rp__tabpanel { padding: 0.85rem 0.85rem 1rem; }
.rp__layers { padding: 0.85rem 0.85rem 1rem; }
.rp__tab { min-width: 76px; font-size: 11.5px; }
.rp__tab svg { width: 16px; height: 16px; }
}

View File

@@ -1,234 +0,0 @@
/* Stickers / Emoji tabs.
*
* Two tabs share these styles: <StickersTab> (image stickers from
* public/stickers/) and <EmojiTab> (rasterized emoji glyphs). They render
* the same grid layout; only the cell contents differ. Image stickers use
* `<img>` inside the cell button; emoji use a `<span>` with a glyph.
*
* Class naming convention: `.st__*` everywhere; `.st--full` modifier on
* the Stickers tab root for header / category bar, `.st--emoji` modifier
* on the Emoji tab root. The grid modifier `.st__grid--emoji` tightens
* the grid for the higher-density emoji layout.
*/
.st {
display: flex;
flex-direction: column;
gap: 0.85rem;
}
/* Header — Stickers tab only ---------------------------------------------- */
.st__header {
display: flex;
align-items: baseline;
justify-content: space-between;
}
.st__title {
margin: 0;
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
}
/* Empty-state copy for when public/stickers/ has no images yet. Centered
* and muted so it sits in the panel quietly rather than reading as an
* error. */
.st__empty {
margin: 0;
padding: 1.5rem 0.5rem;
text-align: center;
color: var(--text-muted);
font-size: 12.5px;
line-height: 1.5;
}
/* Category pills (Stickers tab only) -------------------------------------
*
* Pills wrap to as many rows as needed (`flex-wrap: wrap`) and a max-height
* + vertical scroll kicks in only when there are *so many* categories that
* even wrapped they'd consume too much of the panel. This avoids the prior
* behavior of horizontal overflow that hid pills off-screen on the right.
*/
.st__categories {
display: flex;
gap: 0.45rem;
flex-wrap: wrap;
max-height: 96px;
overflow-y: auto;
padding-bottom: 4px;
scrollbar-width: thin;
scrollbar-color: var(--brand-pink-soft) transparent;
}
.st__categories::-webkit-scrollbar { width: 6px; }
.st__categories::-webkit-scrollbar-track { background: transparent; }
.st__categories::-webkit-scrollbar-thumb {
background: var(--brand-pink-soft);
border-radius: 999px;
}
.st__category {
flex-shrink: 0;
padding: 0.4rem 0.95rem;
border: 1.5px solid var(--border-strong);
border-radius: var(--radius-pill);
background: #fff;
color: var(--text-secondary);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease;
}
.st__category:hover {
background: var(--brand-pink-soft);
color: var(--brand-pink-strong);
border-color: var(--brand-pink);
}
.st__category.is-active {
background: var(--brand-pink);
border-color: var(--brand-pink);
color: #fff;
}
/* Grid --------------------------------------------------------------------
*
* The grid has its OWN scroll container (max-height + overflow-y: auto) so
* loading hundreds of stickers doesn't expand the right panel and force the
* main area to scroll.
*
* Image stickers (.st__grid in <StickersTab>) lay out as 47×47 fixed-size
* cells via `auto-fill` columns — the source artwork is 47px native, so
* rendering thumbnails larger would just upscale a small bitmap.
* Emoji stickers (.st__grid--emoji in <EmojiTab>) keep the original 6-up
* fluid grid: emoji glyphs are vector-rendered by the browser and read
* better at a larger cell size. */
.st__grid {
display: grid;
grid-template-columns: repeat(auto-fill, 47px);
justify-content: start;
gap: 0.55rem;
max-height: 320px;
overflow-y: auto;
padding-right: 4px;
scrollbar-width: thin;
scrollbar-color: var(--brand-pink-soft) transparent;
}
.st__grid::-webkit-scrollbar { width: 6px; }
.st__grid::-webkit-scrollbar-track { background: transparent; }
.st__grid::-webkit-scrollbar-thumb {
background: var(--brand-pink-soft);
border-radius: 999px;
}
.st__grid--emoji {
/* Emoji grid overrides the image-grid layout. Glyphs render best at
* a larger cell size, so we keep the original fluid 6-column grid
* here rather than the 47px fixed cells used for image stickers.
*
* `minmax(0, 1fr)` (rather than plain `1fr`) is important: `1fr`
* tracks size to their items' max-content by default, and our
* `.st__sticker` base rule sets `width: 47px` — so `1fr` would
* resolve to 47px per track and the cells would crowd at the
* left of the grid. The `0` minimum lets the tracks shrink below
* their items' intrinsic size, so the six tracks actually
* partition the grid's full width equally. */
grid-template-columns: repeat(6, minmax(0, 1fr));
justify-content: stretch;
gap: 0.4rem;
}
/* Cell --------------------------------------------------------------------
*
* Image-sticker cells are pinned to 47×47 (the source asset's native
* size). Emoji cells inherit the cell base styles but override with
* `aspect-ratio: 1 / 1` so they fill whatever 1fr column width they get.
* Background is a soft pink tint so transparent stickers have something
* to read against. Hover lifts the cell slightly via a scale transform
* and a stronger background tint. */
.st__sticker {
width: 47px;
height: 47px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid transparent;
border-radius: var(--radius-md);
background: var(--brand-pink-tint);
cursor: pointer;
transition: transform 0.12s ease, background 0.12s ease, border-color 0.12s ease;
padding: 0;
/* `overflow: hidden` so the inner <img> can use object-fit without
* spilling past the rounded corners on hover. */
overflow: hidden;
}
.st__grid--emoji .st__sticker {
/* Emoji cells fill their fluid grid columns rather than being
* pinned to the 47px used for image-sticker tiles. With
* `grid-template-columns: repeat(6, minmax(0, 1fr))` on the
* grid above, each track is now a real fluid width, so the cell
* just needs `width: auto` to stretch into it (grid items stretch
* to fill their tracks by default when `width` isn't explicit).
* `aspect-ratio: 1/1` derives the cell height from its rendered
* width — height stays `auto` for the aspect rule to take over. */
width: auto;
height: auto;
aspect-ratio: 1 / 1;
}
.st__sticker:hover {
background: var(--brand-pink-soft);
border-color: var(--brand-pink);
transform: scale(1.06);
}
.st__sticker:active {
transform: scale(0.96);
}
.st__sticker-emoji {
font-size: 28px;
line-height: 1;
}
.st__grid--emoji .st__sticker-emoji {
font-size: 22px;
}
/* Image sticker thumbnails. `object-fit: contain` keeps the artwork's
* aspect ratio without cropping; at 47×47 the source asset's native
* size we fill the full cell rather than insetting (the inset was
* needed when cells were ~5060px and the image was meant to read as
* "sticker on a pink tile"). */
.st__sticker-img {
width: 100%;
height: 100%;
object-fit: contain;
/* The browser sometimes runs an implicit fade-in when a lazy image
* decodes; opt out for snappier feel. */
display: block;
/* Prevent the image from being dragged out of the panel as a
* standalone DOM drag — the user's drag should reach the parent
* button's click handler instead. */
pointer-events: none;
user-select: none;
-webkit-user-drag: none;
}
@media (max-width: 480px) {
.st__grid {
/* Fixed 47px cells already wrap naturally via auto-fill, so no
* mobile-specific override is needed here. */
}
.st__grid--emoji {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
}

View File

@@ -1,10 +0,0 @@
.tshirt-svg {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
z-index: 0;
width: 100%;
height: auto;
}

View File

@@ -1,149 +0,0 @@
/* ──────────────────────────────────────────────────────────────────────────
* TemplatesTab — Pawfectly Yours pink restyle.
* Class names already match the JSX (legacy .templates-* / .template-*),
* so this just re-skins them in the brand palette.
* ────────────────────────────────────────────────────────────────────────── */
.templates-hidden-input {
display: none;
}
.templates-title {
margin: 0 0 0.4rem 0;
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
}
.templates-description {
font-size: 12px;
color: var(--text-muted);
margin-bottom: 0.85rem;
line-height: 1.4;
}
.templates-list {
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.template-btn {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.7rem;
border: 1.5px solid var(--border-strong);
border-radius: var(--radius-md);
background: #fff;
cursor: pointer;
text-align: left;
transition: background 0.12s ease, border-color 0.12s ease, transform 0.12s ease;
}
.template-btn:hover {
background: var(--brand-pink-wash);
border-color: var(--brand-pink);
}
.template-btn.selected {
background: var(--brand-pink-soft);
border-color: var(--brand-pink);
box-shadow: 0 0 0 3px rgba(236, 72, 153, 0.12);
}
.template-thumbnail {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
background: var(--brand-pink-tint);
border-radius: var(--radius-sm);
font-size: 22px;
flex-shrink: 0;
}
.template-info {
flex: 1;
min-width: 0;
}
.template-name {
font-size: 13px;
font-weight: 700;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.template-desc {
font-size: 11px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 2px;
}
.template-slots-badge {
font-size: 10px;
padding: 2px 7px;
background: var(--brand-pink);
color: #fff;
border-radius: var(--radius-pill);
font-weight: 700;
letter-spacing: 0.4px;
}
.template-slots-section {
margin-top: 0.85rem;
padding: 0.85rem;
background: var(--brand-pink-wash);
border-radius: var(--radius-md);
border: 1px dashed var(--brand-pink);
}
.template-slots-title {
margin: 0 0 0.55rem 0;
font-size: 12px;
font-weight: 700;
color: var(--text-primary);
}
.template-slots-list {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.template-slot-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.55rem 0.75rem;
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
background: #fff;
cursor: pointer;
font-size: 12px;
color: var(--text-primary);
transition: background 0.12s ease, border-color 0.12s ease;
}
.template-slot-btn:hover {
background: var(--brand-pink-soft);
border-color: var(--brand-pink);
}
.template-slot-icon {
font-size: 16px;
}
.template-slot-dimensions {
font-size: 10px;
color: var(--text-muted);
margin-left: auto;
font-weight: 600;
}

View File

@@ -1,472 +0,0 @@
/* ──────────────────────────────────────────────────────────────────────────
* TextTab — Pawfectly Yours
*
* Class names match the .tt__ scheme used in TextTab.jsx.
* ────────────────────────────────────────────────────────────────────────── */
.tt {
display: flex;
flex-direction: column;
gap: 0.85rem;
}
/* Edit-mode banner — small pink chip that appears above the form when a
* text element is selected on the canvas. Tells the user the form's edits
* will live-update the selection. */
.tt__mode-banner {
display: inline-flex;
align-items: center;
gap: 0.45rem;
align-self: flex-start;
padding: 0.35rem 0.7rem;
border-radius: var(--radius-pill);
background: var(--brand-pink-soft);
color: var(--brand-pink-strong);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.4px;
text-transform: uppercase;
}
.tt__mode-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--brand-pink);
box-shadow: 0 0 0 3px rgba(236, 72, 153, 0.18);
animation: tt-pulse 1.6s ease-in-out infinite;
}
@keyframes tt-pulse {
0%, 100% { box-shadow: 0 0 0 3px rgba(236, 72, 153, 0.18); }
50% { box-shadow: 0 0 0 5px rgba(236, 72, 153, 0.08); }
}
.tt__field {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.tt__label {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
letter-spacing: 0.2px;
}
.tt__label-row {
display: flex;
align-items: baseline;
justify-content: space-between;
}
.tt__value {
font-size: 12px;
color: var(--text-primary);
font-weight: 600;
}
.tt__textarea {
width: 100%;
padding: 0.7rem 0.85rem;
border: 1.5px solid var(--border-strong);
border-radius: var(--radius-md);
font-size: 14px;
font-family: var(--font-body);
resize: vertical;
background: #fff;
color: var(--text-primary);
transition: border-color 0.12s ease, box-shadow 0.12s ease;
}
.tt__textarea:focus {
outline: none;
border-color: var(--brand-pink);
box-shadow: 0 0 0 3px var(--brand-pink-soft);
}
.tt__select {
width: 100%;
padding: 0.7rem 0.85rem;
border: 1.5px solid var(--border-strong);
border-radius: var(--radius-md);
font-size: 14px;
font-family: inherit;
background: #fff;
color: var(--text-primary);
cursor: pointer;
transition: border-color 0.12s ease, box-shadow 0.12s ease;
}
.tt__select:focus {
outline: none;
border-color: var(--brand-pink);
box-shadow: 0 0 0 3px var(--brand-pink-soft);
}
/* Color row --------------------------------------------------------------- */
.tt__colors {
display: flex;
gap: 0.45rem;
flex-wrap: wrap;
}
.tt__colors--inline {
flex-wrap: nowrap;
overflow-x: auto;
scrollbar-width: none;
/* Breathing room so the selected swatch's pink ring (a 2px outward
* box-shadow) isn't clipped by the scroll container's edges.
* `overflow-x: auto` implicitly forces `overflow-y` to auto/hidden
* per the CSS spec, so without padding the ring gets sliced on all
* four sides — top, bottom, and at the leftmost/rightmost swatches
* (which are pinned against the container's content edges).
* 3px > 2px ring width gives the shadow room to render fully. */
padding: 3px 4px;
}
.tt__colors--inline::-webkit-scrollbar { display: none; }
.tt__color {
width: 28px;
height: 28px;
border-radius: 999px;
border: 2px solid #fff;
box-shadow: 0 0 0 1px var(--border-strong);
cursor: pointer;
padding: 0;
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.tt__color--small {
width: 22px;
height: 22px;
flex-shrink: 0;
}
.tt__color:hover {
transform: scale(1.06);
}
.tt__color.is-selected {
box-shadow: 0 0 0 2px var(--brand-pink);
}
.tt__color--custom {
background: linear-gradient(135deg, #fff, var(--brand-pink-soft));
display: inline-flex;
align-items: center;
justify-content: center;
position: relative;
font-size: 16px;
color: var(--brand-pink-strong);
font-weight: 700;
}
.tt__color--custom input {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
}
/* Eyedropper button — sized like a swatch but with a stroke icon and a
* cool/lavender gradient that's distinct from both the curated palette
* and the custom-color square. Hover reveals the brand-pink to match
* other interactive controls. */
.tt__color--eyedropper {
background: linear-gradient(135deg, #fff, var(--brand-lavender-soft, #e9defc));
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
transition: transform 0.12s ease, box-shadow 0.12s ease, color 0.12s ease;
}
.tt__color--eyedropper:hover {
color: var(--brand-pink-strong);
box-shadow: 0 0 0 2px var(--brand-pink);
}
/* Recent-colors row — sits above the QUICK_COLORS swatches, separated by a
* dashed underline so the two sets of swatches read as distinct: "colors
* you've used" vs "the curated palette". */
.tt__recent-row {
display: flex;
align-items: center;
gap: 0.55rem;
margin-bottom: 0.4rem;
padding-bottom: 0.5rem;
border-bottom: 1px dashed var(--border);
}
.tt__recent-caption {
font-size: 10px;
color: var(--text-secondary);
font-weight: 700;
letter-spacing: 0.5px;
text-transform: uppercase;
flex-shrink: 0;
}
/* Outline (stroke) controls (S8) ----------------------------------------- */
.tt__outline-toggle {
position: relative;
display: inline-block;
width: 38px;
height: 22px;
cursor: pointer;
}
.tt__outline-toggle input {
position: absolute;
opacity: 0;
inset: 0;
cursor: pointer;
margin: 0;
}
.tt__outline-toggle-track {
display: block;
width: 100%;
height: 100%;
background: var(--border-strong);
border-radius: 999px;
transition: background 0.15s ease;
}
.tt__outline-toggle-thumb {
position: absolute;
top: 2px;
left: 2px;
width: 18px;
height: 18px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.18);
transition: transform 0.15s ease;
}
.tt__outline-toggle input:checked ~ .tt__outline-toggle-track {
background: var(--brand-pink);
}
.tt__outline-toggle input:checked ~ .tt__outline-toggle-track .tt__outline-toggle-thumb {
transform: translateX(16px);
}
.tt__outline-controls {
display: grid;
grid-template-columns: 32px 1fr auto;
align-items: center;
gap: 0.6rem;
margin-top: 0.4rem;
}
.tt__outline-swatch {
width: 28px;
height: 28px;
position: relative;
cursor: pointer;
}
.tt__outline-swatch-fill {
position: absolute;
inset: 4px;
border-radius: 999px;
border: 1px solid rgba(0, 0, 0, 0.12);
}
.tt__outline-slider {
width: 100%;
}
.tt__outline-width {
font-size: 12px;
color: var(--text-primary);
font-weight: 600;
min-width: 2.5em;
text-align: right;
}
/* Arc controls -----------------------------------------------------------
*
* Three-button picker. Each button stacks an SVG curve icon over a
* short text label so the user can read both at-a-glance. The buttons
* are a segmented control: equal widths, mutually exclusive selection,
* one always active. Selected button uses the brand-pink fill + white
* foreground so it reads as the chosen state without ambiguity.
*
* Replaced an earlier bidirectional slider. Horizontal sliders don't
* have an innate up/down semantic, so requiring users to internalize
* "drag right = curve down" was a learnability tax. The buttons make
* the mapping obvious from the icon shapes alone. */
.tt__arc-buttons {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 0.45rem;
}
.tt__arc-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.3rem;
padding: 0.6rem 0.4rem;
border: 1.5px solid var(--border-strong);
border-radius: var(--radius-md);
background: #fff;
color: var(--text-secondary);
font-size: 11px;
font-weight: 600;
cursor: pointer;
transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease, transform 0.08s ease;
}
.tt__arc-btn:hover {
border-color: var(--brand-pink);
color: var(--brand-pink-strong);
background: var(--brand-pink-soft);
}
.tt__arc-btn:active {
transform: translateY(1px);
}
.tt__arc-btn.is-selected {
background: var(--brand-pink);
border-color: var(--brand-pink);
color: #fff;
}
/* Icon takes its color from the button's currentColor so it inverts
* cleanly on selected state. Fixed dimensions keep the three buttons
* vertically aligned regardless of label length. */
.tt__arc-btn-icon {
width: 32px;
height: 18px;
flex-shrink: 0;
}
.tt__arc-btn-label {
line-height: 1;
/* Tiny letter-spacing makes the all-caps-ish labels read cleanly at
* 11px without looking cramped against the icon above. */
letter-spacing: 0.2px;
white-space: nowrap;
}
/* Label-row variant that holds the label alongside a small inline
* widget on the LEFT side of the row (rather than the right). The
* base `.tt__label-row` uses `space-between` to push the secondary
* widget to the right — that's right for the Outline row (label +
* toggle), and for the Size row (label + value). For the Color row
* the indicator sits IMMEDIATELY next to the label as a left-aligned
* pair, so we wrap them together in this container and let the row
* itself handle no further layout. Centered alignment keeps a small
* swatch (~14px) visually balanced with the label text. */
.tt__label-with-swatch {
display: flex;
align-items: center;
gap: 0.5rem;
}
/* Current-color indicator next to the "Color" label. Small filled
* circle with a thin border so a white fill stays distinguishable
* from the white sidebar surface (otherwise white-on-white reads as
* "no indicator at all"). The border colour matches the curated
* palette swatches' outer ring so the indicator and palette read as
* the same visual family. Inline `background-color` is set by the
* React component from the current `fill` value. */
.tt__current-swatch {
display: inline-block;
width: 14px;
height: 14px;
border-radius: 50%;
border: 1px solid var(--border-strong);
flex-shrink: 0;
/* Smooth swap when the user picks a different colour — same
* timing as the preview swatch's background transition so the
* indicator and preview animate in lockstep when both update. */
transition: background-color 150ms ease;
}
/* Preview ----------------------------------------------------------------- */
.tt__preview {
display: flex;
align-items: center;
justify-content: center;
min-height: 64px;
padding: 1rem;
/* Brand pink wash. Same colour used as the soft pink surface
* elsewhere in the app, kept stable here for ALL fills so the
* preview section doesn't visually pop relative to the rest of
* the Text tab.
*
* Earlier iterations explored swapping the background to a
* darker pink when the user's chosen fill had bad contrast
* against the wash (white on near-white, peach on near-white,
* etc). The contrast was great, but the saturated dark pink
* drew too much attention to the section and broke the quiet
* "swatch confirming styling" role the preview is supposed to
* play. The fix moved to TextTab.jsx: when the fill has low
* contrast against this wash, the React component applies a
* soft dark `text-shadow` to the preview text itself (see the
* LOW_CONTRAST_TEXT_SHADOW constant). The shadow gives each
* glyph a visible silhouette without recolouring the surface. */
background: var(--brand-pink-wash);
border: 1px dashed var(--border-strong);
border-radius: var(--radius-md);
text-align: center;
word-break: break-word;
line-height: 1.2;
}
/* Submit ------------------------------------------------------------------ */
.tt__submit {
width: 100%;
padding: 0.85rem;
border: none;
border-radius: var(--radius-pill);
background: var(--brand-pink);
color: #fff;
font-size: 14px;
font-weight: 700;
cursor: pointer;
transition: background 0.12s ease, transform 0.12s ease;
}
.tt__submit:hover {
background: var(--brand-pink-strong);
}
.tt__submit:active {
transform: translateY(1px);
}
/* Hide the font-size slider on desktop.
*
* On desktop the user has fine-grained corner-drag handles on the canvas
* (the Konva transformer's keepRatio scale) plus arrow-key nudge, so the
* slider is a redundant control that just takes vertical space in the
* right-rail Text tab. Mobile users can't easily corner-drag a small
* touch target on a small screen, so the slider stays as the primary
* size affordance there.
*
* Breakpoint matches App.jsx's MOBILE_QUERY (max-width: 768px) — the
* same line that switches the layout between mobile bottom-sheet and
* desktop right-rail. Anything at ≥769px is the desktop layout, where
* the canvas is the primary surface and corner drag is available. */
@media (min-width: 769px) {
.tt__field--size {
display: none;
}
}

98
src/styles/Toast.css Normal file
View File

@@ -0,0 +1,98 @@
/* ──────────────────────────────────────────────────────────────────────────
* Toast — host-level notification pill
*
* Top-center, fixed positioning so it sits above the editor's own canvas
* UI without occluding the action it's commenting on. The editor module
* has its own internal toast surface for editor concerns (rendered
* inside the canvas area); this one is anchored to the viewport so
* host-originated toasts are visually distinct from editor-originated
* ones.
*
* Z-index sits just under the PWA install banner (1000) and above the
* canvas. The offline indicator (z 9999) intentionally outranks toasts
* — being offline is more important than transient feedback.
* ────────────────────────────────────────────────────────────────────────── */
.app-toast-wrap {
position: fixed;
top: calc(var(--header-height) + 1rem);
left: 50%;
transform: translateX(-50%);
z-index: 900;
pointer-events: none;
/* Wrap is pointer-events:none so it doesn't intercept clicks on the
* area behind it; the inner pill re-enables pointer events for its
* own dismiss button. */
}
.app-toast {
display: inline-flex;
align-items: center;
gap: 0.75rem;
padding: 0.65rem 0.9rem 0.65rem 1.1rem;
background: var(--bg-primary);
border: 1px solid var(--border-strong);
border-radius: var(--radius-pill);
box-shadow: var(--shadow-pop);
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
pointer-events: auto;
max-width: min(560px, calc(100vw - 2rem));
animation: app-toast-in 0.22s ease-out;
}
@keyframes app-toast-in {
from { opacity: 0; transform: translateY(-6px); }
to { opacity: 1; transform: translateY(0); }
}
.app-toast__message {
flex: 1;
min-width: 0;
}
.app-toast__close {
flex: 0 0 auto;
width: 24px;
height: 24px;
border-radius: 999px;
border: none;
background: transparent;
color: var(--text-muted);
font-size: 14px;
line-height: 1;
cursor: pointer;
transition: background 0.12s ease, color 0.12s ease;
}
.app-toast__close:hover {
background: var(--brand-pink-soft);
color: var(--brand-pink-strong);
}
/* Variants — info is the default styling above; success and error tint
* the border + add a subtle background wash so the kind reads at a
* glance without leaning on color alone. */
.app-toast--success {
border-color: rgba(34, 197, 94, 0.4);
background: linear-gradient(180deg, rgba(34, 197, 94, 0.06), var(--bg-primary));
}
.app-toast--error {
border-color: rgba(239, 68, 68, 0.4);
background: linear-gradient(180deg, rgba(239, 68, 68, 0.06), var(--bg-primary));
color: var(--text-primary);
}
/* Mobile — the toast lives below the smaller mobile header. */
@media (max-width: 768px) {
.app-toast-wrap {
top: calc(var(--header-height-mobile) + 0.75rem);
}
.app-toast {
font-size: 13px;
padding: 0.55rem 0.75rem 0.55rem 0.95rem;
}
}

View File

@@ -1,201 +0,0 @@
.ut {
display: flex;
flex-direction: column;
gap: 1rem;
}
/* Drop zone --------------------------------------------------------------- */
.ut__dropzone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.4rem;
padding: 2rem 1rem;
background: var(--brand-pink-wash);
border: 2px dashed var(--brand-pink);
border-radius: var(--radius-md);
color: var(--text-secondary);
cursor: pointer;
transition: background 0.12s ease, border-color 0.12s ease, transform 0.12s ease;
outline: none;
}
.ut__dropzone:hover,
.ut__dropzone:focus-visible {
background: var(--brand-pink-soft);
border-color: var(--brand-pink-strong);
}
.ut__dropzone.is-dragging {
background: var(--brand-pink-soft);
border-color: var(--brand-pink-strong);
transform: scale(1.01);
}
.ut__dropzone-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: 999px;
background: #fff;
color: var(--brand-pink);
margin-bottom: 0.25rem;
box-shadow: 0 2px 6px rgba(236, 72, 153, 0.18);
}
.ut__dropzone-text {
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
}
.ut__dropzone-hint {
font-size: 13px;
color: var(--text-secondary);
}
.ut__browse {
color: var(--brand-pink);
font-weight: 700;
text-decoration: underline;
text-underline-offset: 2px;
}
.ut__hidden-input {
display: none;
}
/* Status ------------------------------------------------------------------ */
.ut__status {
padding: 0.6rem 0.8rem;
background: var(--brand-pink-soft);
border-radius: var(--radius-sm);
font-size: 12px;
color: var(--brand-pink-strong);
text-align: center;
font-weight: 600;
}
/* Recent Uploads ---------------------------------------------------------- */
.ut__recent {
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.ut__recent-header {
display: flex;
align-items: baseline;
justify-content: space-between;
}
.ut__recent-title {
font-size: 13px;
font-weight: 700;
color: var(--text-primary);
}
.ut__see-all {
background: transparent;
border: none;
color: var(--brand-pink);
font-size: 12px;
font-weight: 600;
padding: 0;
}
.ut__see-all:hover {
color: var(--brand-pink-strong);
text-decoration: underline;
}
.ut__recent-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 0.5rem;
}
/* Card wrapper so the thumb and the delete bar read as a single unit.
* Hover lifts and tints the whole card; the delete button still gets its
* own red-tint hover on top of that so the destructive affordance is
* obvious. */
.ut__recent-item {
display: flex;
flex-direction: column;
transition: transform 0.12s ease;
}
.ut__recent-item:hover {
transform: scale(1.04);
}
.ut__recent-thumb {
position: relative;
width: 100%;
aspect-ratio: 1 / 1;
overflow: hidden;
padding: 0;
border: 2px solid transparent;
border-bottom: none;
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
background: var(--brand-pink-tint);
cursor: pointer;
transition: border-color 0.12s ease;
}
.ut__recent-item:hover .ut__recent-thumb,
.ut__recent-item:focus-within .ut__recent-thumb {
border-color: var(--brand-pink);
}
.ut__recent-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.ut__recent-delete {
width: 100%;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
padding: 0.3rem 0.25rem;
background: var(--brand-pink-soft);
border: 2px solid transparent;
border-top: none;
border-radius: 0 0 var(--radius-sm) var(--radius-sm);
color: var(--text-secondary);
font-size: 11px;
font-weight: 600;
line-height: 1;
cursor: pointer;
transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease;
}
.ut__recent-item:hover .ut__recent-delete,
.ut__recent-item:focus-within .ut__recent-delete {
border-color: var(--brand-pink);
}
.ut__recent-delete:hover,
.ut__recent-delete:focus-visible {
background: #fee2e2;
color: #b91c1c;
}
@media (max-width: 480px) {
.ut__recent-grid {
grid-template-columns: repeat(4, 1fr);
}
.ut__dropzone {
padding: 1.5rem 0.85rem;
}
}

View File

@@ -1,66 +0,0 @@
.zoom-controls {
display: inline-flex;
align-items: center;
gap: 0;
background: #fff;
border: 1px solid var(--border);
border-radius: var(--radius-pill);
padding: 4px;
box-shadow: var(--shadow-xs);
}
.zoom-controls__btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 999px;
background: transparent;
border: none;
color: var(--text-secondary);
}
.zoom-controls__btn:hover:not(:disabled) {
background: var(--brand-pink-soft);
color: var(--brand-pink-strong);
}
.zoom-controls__btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.zoom-controls__value {
min-width: 48px;
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
text-align: center;
}
/* Divider between zoom controls and snap toggle (Change 5). 1px hairline
* in the border colour, sized to match the height of the buttons minus a
* little vertical padding so it doesn't reach all the way to the pill's
* rounded edge. */
.zoom-controls__divider {
display: inline-block;
width: 1px;
height: 18px;
background: var(--border);
margin: 0 4px;
}
/* Snap toggle. Reuses the base zoom-controls__btn styling for size /
* hover, plus an active variant so the user can see at a glance
* whether snap is on. Active = soft pink fill + strong pink icon,
* matching the rest of the editor's "selected" / "active" affordances. */
.zoom-controls__btn--snap.is-active {
background: var(--brand-pink-soft);
color: var(--brand-pink-strong);
}
.zoom-controls__btn--snap.is-active:hover:not(:disabled) {
background: var(--brand-pink);
color: #fff;
}

View File

@@ -1,45 +0,0 @@
// Vitest setup file — runs once per test file, before any test.
//
// Two responsibilities:
//
// 1. Register @testing-library/jest-dom's custom matchers so tests
// can use `expect(node).toBeInTheDocument()`, `toHaveTextContent()`,
// `toHaveAttribute()`, etc. Without this import, those matchers
// throw "expected jest-dom matcher" at assertion time.
//
// 2. Run RTL's `cleanup()` after every test. RTL renders into a
// shared DOM container; without explicit cleanup, components
// from one test stick around for the next, producing
// false-positive "found multiple instances" errors and
// memory-grow bugs in long suites. Vitest with `globals: true`
// exposes `afterEach` so the import below works without
// naming it on the import side.
//
// What's NOT here (intentionally)
// ─────────────────────────────────
// • localStorage clearing. jsdom gives each test file a fresh
// localStorage, and our tests that touch localStorage (history
// debug flag) explicitly set+clear their own keys per test.
// A blanket `localStorage.clear()` in afterEach would mask
// setup bugs in tests that depend on a clean key.
//
// • Konva mocking. We don't yet have tests that mount React-Konva
// components — those will need Stage/Layer stubs and will live
// in a follow-up batch once we know the test setup story for
// canvas-dependent code. For pure utility and hook tests
// covered in this initial batch, Konva isn't loaded.
//
// • Timer mocking globals. Individual tests that need
// `vi.useFakeTimers()` (debounce tests for updateElement, the
// auto-dismiss toast timer, etc.) call it themselves and pair
// with `vi.useRealTimers()` in their own afterEach. Keeping
// it test-local rather than global makes the timing intent
// visible at the test site.
import '@testing-library/jest-dom/vitest';
import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
afterEach(() => {
cleanup();
});

View File

@@ -1,36 +0,0 @@
/**
* Blob URL helpers — used by the photo edit flow (which creates blob: URLs
* from cropped images and needs to revoke them once the canvas swaps to a
* new src) and by persistence (which strips blob: URLs out of the saved
* blob because they don't survive a reload).
*
* Before this module these checks were inline string-prefix tests —
* `previousSrc.startsWith('blob:')`, `el.src.startsWith('blob:')`. Wrapping
* the prefix check in a name makes it greppable and prevents accidental
* "starts with 'blob'" false positives (matching e.g. "blob_url_str").
*/
/**
* True if the given value is a string that looks like a `blob:` URL.
* Returns false for non-strings rather than throwing — callers usually
* test optional fields like `element.src` and shouldn't have to null-check
* twice.
*/
export function isBlobUrl(url) {
return typeof url === 'string' && url.startsWith('blob:');
}
/**
* Revoke a blob: URL if it is one; no-op for any other input. The actual
* `URL.revokeObjectURL` call is wrapped in try/catch because some browsers
* throw on already-revoked URLs and we don't want to crash the photo-edit
* cleanup path over a double-revoke.
*/
export function revokeBlobUrl(url) {
if (!isBlobUrl(url)) return;
try {
URL.revokeObjectURL(url);
} catch {
/* Already-revoked, missing URL global, etc. — ignore. */
}
}

View File

@@ -1,103 +0,0 @@
// Tests for blobUrl.js — the isBlobUrl predicate and revokeBlobUrl
// wrapper used by persistence stripping and the photo-edit cleanup
// path.
//
// What this guards
// ────────────────
// isBlobUrl is consulted in two places that both have correctness
// consequences:
//
// 1. `persistence.savePersistedState` strips elements whose `src`
// is a blob: URL before saving to localStorage. Blob URLs go
// stale on reload — keeping them would produce broken-image
// elements on next session restore. If isBlobUrl returns false
// for a real blob URL, those broken images appear after refresh.
//
// 2. The photo-edit flow revokes the previous blob URL when the
// user applies an edit so the browser frees the underlying
// memory. If isBlobUrl returns true for a non-blob URL, we'd
// try to revoke a regular https:// URL, which is at best a no-op
// and at worst a deopt the browser logs.
//
// Both call sites benefit from a stable predicate, so the tests pin
// the exact behavior: string-typed AND starts with literal "blob:".
import { isBlobUrl, revokeBlobUrl } from './blobUrl';
describe('isBlobUrl', () => {
it('returns true for a string starting with "blob:"', () => {
// Real blob URLs look like blob:https://example.com/<uuid>. The
// predicate only checks the prefix, not the suffix shape — any
// string starting with "blob:" matches.
expect(isBlobUrl('blob:https://example.com/abc-123')).toBe(true);
expect(isBlobUrl('blob:')).toBe(true);
});
it('returns false for non-blob URLs', () => {
// These are the URL shapes we DON'T want to revoke:
// - data: URLs (survive a reload, persist fine)
// - https:// URLs (CDN-hosted images)
// - file:// URLs (local file pickers)
// - relative paths
expect(isBlobUrl('data:image/png;base64,iVBORw0KG')).toBe(false);
expect(isBlobUrl('https://example.com/photo.jpg')).toBe(false);
expect(isBlobUrl('file:///Users/khalid/photo.jpg')).toBe(false);
expect(isBlobUrl('/uploads/photo.jpg')).toBe(false);
expect(isBlobUrl('photo.jpg')).toBe(false);
});
it('returns false for non-strings (no throw)', () => {
// The docblock specifies: "Returns false for non-strings rather
// than throwing — callers usually test optional fields like
// `element.src` and shouldn't have to null-check twice." This
// is the regression test for that contract.
expect(isBlobUrl(null)).toBe(false);
expect(isBlobUrl(undefined)).toBe(false);
expect(isBlobUrl(42)).toBe(false);
expect(isBlobUrl({})).toBe(false);
expect(isBlobUrl([])).toBe(false);
});
it('returns false for the empty string', () => {
expect(isBlobUrl('')).toBe(false);
});
it('is case-sensitive ("BLOB:" is not "blob:")', () => {
// Real browser-issued blob URLs are always lowercase, so this
// is the desirable behavior. Pinning it here so a future
// "normalize case" refactor doesn't silently expand the
// predicate's surface to include uppercase variants we never
// intended to support.
expect(isBlobUrl('BLOB:https://example.com/abc')).toBe(false);
expect(isBlobUrl('Blob:https://example.com/abc')).toBe(false);
});
});
describe('revokeBlobUrl', () => {
// jsdom does not provide URL.revokeObjectURL by default. The
// function under test wraps the call in try/catch precisely to
// handle environments where it's missing (or where the URL has
// already been revoked). These tests verify the no-throw contract
// across the various inputs callers actually send.
it('does not throw for a blob: URL even if revokeObjectURL is missing', () => {
// The cleanup path in the photo editor will call this on every
// edit. It MUST NOT throw — that would interrupt the edit flow
// and leave the user staring at a half-applied state.
expect(() => revokeBlobUrl('blob:https://example.com/abc')).not.toThrow();
});
it('does not throw for non-blob URLs (no-op)', () => {
// Defensive: if a caller accidentally passes a data: URL, we
// shouldn't try to revoke it. The isBlobUrl guard inside
// revokeBlobUrl short-circuits this case.
expect(() => revokeBlobUrl('data:image/png;base64,abc')).not.toThrow();
expect(() => revokeBlobUrl('https://example.com/photo.jpg')).not.toThrow();
});
it('does not throw for non-string inputs', () => {
expect(() => revokeBlobUrl(null)).not.toThrow();
expect(() => revokeBlobUrl(undefined)).not.toThrow();
expect(() => revokeBlobUrl(42)).not.toThrow();
});
});

View File

@@ -1,206 +0,0 @@
/**
* Color utilities for the editor.
*
* Currently exports:
* • `darkenHexColor` — used by the Text tab to derive a default
* outline color from the user's chosen fill color.
* • `relativeLuminance` / `getContrastRatio` — WCAG-style contrast
* math, used by the Text tab's preview swatch to pick a readable
* background based on the current fill (light pink wash by
* default; darker pink fallback when the fill would be invisible
* against the wash).
* • `LOW_CONTRAST_THRESHOLD` / `isLowContrast` — convenience
* predicate + threshold for "these two colors are too similar to
* be safely distinguishable." No production callers as of writing;
* kept as a public API surface for future warning paths so the
* threshold lives in one canonical place rather than being
* redefined per call site. The Text tab uses a locally-named
* `LOW_PREVIEW_CONTRAST_THRESHOLD` (same 2.0 value) for its own
* preview-surface check — leaving that local because the preview
* threshold and a global "warn the user" threshold may diverge.
*
* We keep these in their own module rather than inlining them because
* the same transforms may end up wanted elsewhere (hover states,
* accessibility checks on imported palettes, gradient stops, etc.),
* and a one-line import keeps downstream call sites tidy.
*
* History
* ───────
* Pre-May 23 2026 the contrast logic was split across two files:
* `colorUtils.js` (this file, with darkenHexColor + getContrastRatio +
* relativeLuminance) and `colorContrast.js` (a separate module with a
* redundant `contrastRatio` plus `isLowContrast` and the threshold
* constant). The two contrast implementations differed only in their
* malformed-input fallback — colorUtils returned NaN ("unknown, caller
* decides"), colorContrast returned 1 ("treat as identical"). The
* split was speculative API; the only production caller (TextTab) used
* colorUtils' NaN-aware path. Merged into this file with NaN as the
* canonical fallback (see `isLowContrast` below).
*/
/**
* Darken a hex color by scaling each RGB channel toward zero.
*
* Why RGB scaling and not HSL lightness reduction:
* • Visually equivalent for the small (≤ 30%) darkening this app
* uses — the perceptual difference between HSL-based and
* RGB-scaled darkening is negligible below ~40%.
* • Cheaper: no RGB → HSL → RGB round-trip, no math.atan2 calls.
* • Handles edge cases naturally: pure black stays pure black
* (0 × anything = 0), pure white darkens uniformly to grey,
* coloured fills keep their hue cleanly because every channel
* scales by the same factor.
*
* Input must be a 3- or 6-digit hex string with or without the
* leading `#`. Anything else is returned unchanged so a malformed
* color in state can't crash a render — the caller's UI just
* shows the original color and the user can pick a new one.
*
* `amount` is a fraction in [0, 1]:
* amount = 0 → returns the input unchanged
* amount = 0.2 → 20% darker (the Text tab's outline default)
* amount = 1 → returns #000000
*
* Values outside [0, 1] are clamped so a typo in a caller can't
* produce out-of-gamut values like negative channels.
*/
export function darkenHexColor(hex, amount = 0.2) {
if (typeof hex !== 'string') return hex;
let h = hex.trim();
if (h.startsWith('#')) h = h.slice(1);
// Expand short-form (#abc) to long-form (#aabbcc) so the parse below
// is uniform. Browsers accept both; canvas APIs sometimes don't.
if (h.length === 3) h = h.split('').map((c) => c + c).join('');
if (h.length !== 6 || /[^0-9a-fA-F]/.test(h)) return hex;
const clamped = Math.max(0, Math.min(1, amount));
const factor = 1 - clamped;
const r = parseInt(h.slice(0, 2), 16);
const g = parseInt(h.slice(2, 4), 16);
const b = parseInt(h.slice(4, 6), 16);
const nr = Math.round(r * factor);
const ng = Math.round(g * factor);
const nb = Math.round(b * factor);
const toHex = (v) => v.toString(16).padStart(2, '0');
return `#${toHex(nr)}${toHex(ng)}${toHex(nb)}`;
}
/**
* Parse a hex color string into a canonical 6-char hex (no #).
* Returns null on malformed input. Shared by relativeLuminance and
* any future callers that need normalized hex.
*/
function normalizeHex(hex) {
if (typeof hex !== 'string') return null;
let h = hex.trim();
if (h.startsWith('#')) h = h.slice(1);
if (h.length === 3) h = h.split('').map((c) => c + c).join('');
if (h.length !== 6 || /[^0-9a-fA-F]/.test(h)) return null;
return h;
}
/**
* Relative luminance of an sRGB color per the WCAG 2.x formula.
*
* Returns a value in [0, 1]: 0 = black, 1 = white, mid-grey ≈ 0.18.
* The non-linear `channel` transform is the sRGB gamma curve — it
* converts each 0255 channel into linear-light space before the
* weighted sum, which is what gives WCAG contrast ratios their
* perceptual accuracy. Skipping the gamma step (i.e. using raw
* RGB averages) under-estimates contrast on light backgrounds and
* over-estimates it on dark ones.
*
* Returns NaN for malformed input. Callers should treat NaN as
* "unknown" and either bail or fall back to a safe default rather
* than passing the value into further math.
*/
export function relativeLuminance(hex) {
const h = normalizeHex(hex);
if (!h) return NaN;
// sRGB → linear-light gamma curve (the WCAG canonical form).
const channel = (v) => {
const s = v / 255;
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
};
const r = channel(parseInt(h.slice(0, 2), 16));
const g = channel(parseInt(h.slice(2, 4), 16));
const b = channel(parseInt(h.slice(4, 6), 16));
// Weighting coefficients are the standard sRGB → luminance matrix
// row — green dominates because the human eye is most sensitive to
// mid-green wavelengths, blue contributes the least.
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
/**
* WCAG contrast ratio between two colors.
*
* Returns a value in [1, 21]:
* 1 = identical luminance (invisible)
* 21 = pure black on pure white (theoretical max)
*
* WCAG AA passing thresholds:
* 4.5:1 for normal text
* 3.0:1 for large text (18pt+ regular, or 14pt+ bold)
*
* Returns NaN if either input is malformed (so the caller can pick
* a safe fallback rather than producing garbage UI). Order of
* arguments doesn't matter — the formula is symmetric, with the
* lighter colour always going on top.
*/
export function getContrastRatio(hex1, hex2) {
const l1 = relativeLuminance(hex1);
const l2 = relativeLuminance(hex2);
if (Number.isNaN(l1) || Number.isNaN(l2)) return NaN;
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}
/**
* Threshold below which two colors are considered "too similar to
* safely distinguish." WCAG AA proper requires ≥4.5:1 for body text
* and ≥3.0:1 for large text; 2.0 is a relaxed threshold appropriate
* for decorative print where the user is making an aesthetic choice
* rather than a readability-critical one. "You can still see it but
* it's getting hard" is the right level for a warning surface; a
* proper AA threshold would warn so frequently on stylish colour
* combinations that users would tune it out.
*
* Lives here rather than in a caller-local constant so any future
* call site that wants "the project's low-contrast threshold" has
* one canonical reference. Call sites with a more specific need
* (e.g. TextTab's preview-surface check) can still define their
* own.
*/
export const LOW_CONTRAST_THRESHOLD = 2.0;
/**
* Predicate: do these two colors fall below the low-contrast threshold?
*
* NaN handling
* ────────────
* Returns FALSE when either color is malformed (getContrastRatio
* returns NaN). "Unknown contrast" reads as "don't warn" rather than
* "warn just in case" — a malformed colour in state is more likely
* to be a stale value the user is about to overwrite than a real
* low-contrast pair, and warning on it would produce a false positive
* the user can't act on (they didn't pick a colour; they're picking
* one right now). The TextTab caller pattern
* (`!Number.isNaN(contrast) && contrast < threshold`) had the same
* semantics inline; consolidating it here makes the choice explicit.
*
* Argument order is `(fg, bg)` by convention — foreground first —
* but the underlying WCAG math is symmetric, so callers passing them
* in the other order get the same answer.
*/
export function isLowContrast(fg, bg, threshold = LOW_CONTRAST_THRESHOLD) {
const ratio = getContrastRatio(fg, bg);
if (Number.isNaN(ratio)) return false;
return ratio < threshold;
}

View File

@@ -1,392 +0,0 @@
// Tests for colorUtils.js — darkenHexColor, relativeLuminance, getContrastRatio.
//
// Why this file matters for the v1 cleanup
// ────────────────────────────────────────
// `colorUtils.js` and `colorContrast.js` (tested separately in
// colorContrast.test.js) BOTH implement the WCAG relative-luminance
// and contrast-ratio formulas, with subtly different APIs:
//
// | colorUtils | colorContrast
// ─────────────────┼─────────────────────────────┼──────────────────────────
// relativeLuminance| takes hex string | takes parsed {r,g,b}
// | returns NaN on bad input | (internal helper only)
// contrast getter | getContrastRatio (NaN) | contrastRatio (returns 1)
// threshold helper | (none) | isLowContrast(default 2)
// color transforms | darkenHexColor | (none)
//
// Same underlying WCAG math, two slightly different surfaces. The
// upcoming cleanup is to consolidate them into a single module. These
// tests (plus the colorContrast.test.js sibling) pin BOTH current
// behaviors so the consolidation can be done as a green refactor:
// either adopt one module's API and update the other's callers, or
// introduce a single new API and migrate both — in either case the
// test suite tells us when behavior matches.
//
// Specifically the "returns NaN on bad input" vs "returns 1 on bad
// input" divergence: callers of colorUtils.getContrastRatio currently
// expect to test for NaN; callers of colorContrast.contrastRatio
// currently expect 1 as a safe-fallback value. Whichever direction
// we pick during cleanup, the side that changes will need its callers
// audited. The malformed-input tests below flag this divergence.
//
// UPDATE (May 23 2026): the consolidation landed. colorContrast.js
// and colorContrast.test.js are deleted; everything lives here and
// NaN-on-bad-input is the canonical fallback throughout. The header
// above is preserved so commits referencing "the divergence" still
// make sense; the post-merge behaviour is documented in
// colorUtils.js's module docblock.
import {
darkenHexColor,
relativeLuminance,
getContrastRatio,
LOW_CONTRAST_THRESHOLD,
isLowContrast,
} from './colorUtils';
// ────────────────────────────────────────────────────────────────────
// darkenHexColor — RGB-scaling darken.
// ────────────────────────────────────────────────────────────────────
describe('darkenHexColor', () => {
describe('boundary amounts', () => {
it('amount=0 returns the input unchanged', () => {
expect(darkenHexColor('#ff0000', 0)).toBe('#ff0000');
expect(darkenHexColor('#abc123', 0)).toBe('#abc123');
});
it('amount=1 returns #000000 (everything scaled to zero)', () => {
expect(darkenHexColor('#ff0000', 1)).toBe('#000000');
expect(darkenHexColor('#ffffff', 1)).toBe('#000000');
expect(darkenHexColor('#abc123', 1)).toBe('#000000');
});
it('default amount is 0.2 (matches the Text tab outline darken)', () => {
// Caller-side: `darkenHexColor(fill)` produces the default outline
// colour for the Text tab. The default amount must stay 0.2 or
// the visual relationship between fill and outline changes.
expect(darkenHexColor('#ffffff')).toBe('#cccccc');
// 0xff * 0.8 = 204 = 0xcc.
});
it('clamps amounts above 1 to 1', () => {
expect(darkenHexColor('#ff0000', 5)).toBe('#000000');
expect(darkenHexColor('#ff0000', 1.5)).toBe('#000000');
});
it('clamps negative amounts to 0', () => {
// A typo in a caller passing a negative amount shouldn't produce
// out-of-gamut channels (e.g. `-0.2` would lighten if we didn't
// clamp). Clamping to 0 preserves input.
expect(darkenHexColor('#ff0000', -0.2)).toBe('#ff0000');
expect(darkenHexColor('#ff0000', -1)).toBe('#ff0000');
});
});
describe('channel math', () => {
it('20% darker on pure red is #cc0000', () => {
// 0xff (255) * 0.8 = 204 = 0xcc.
expect(darkenHexColor('#ff0000', 0.2)).toBe('#cc0000');
});
it('20% darker on pure white is #cccccc', () => {
expect(darkenHexColor('#ffffff', 0.2)).toBe('#cccccc');
});
it('pure black stays pure black at any amount', () => {
// 0 * anything = 0. This is a useful invariant — callers that
// chain darken operations shouldn't ever produce a non-zero
// channel from a zero source.
expect(darkenHexColor('#000000', 0.5)).toBe('#000000');
expect(darkenHexColor('#000000', 1)).toBe('#000000');
});
it('rounds channel values rather than truncating', () => {
// Math.round, not Math.floor — 0xab (171) * 0.8 = 136.8 should
// round to 137 (0x89), not floor to 136 (0x88).
expect(darkenHexColor('#ababab', 0.2)).toBe('#898989');
});
it('treats channels independently (preserves hue at small darken amounts)', () => {
// Every channel scales by the same factor, so the ratios between
// them are preserved. A 50%-red, 50%-green-blue color stays
// 50%-red, 50%-green-blue after darkening.
const result = darkenHexColor('#80c0ff', 0.5);
// 0x80 = 128 → 64 = 0x40
// 0xc0 = 192 → 96 = 0x60
// 0xff = 255 → 128 (round of 127.5) = 0x80
expect(result).toBe('#406080');
});
});
describe('input formats', () => {
it('accepts hex with leading #', () => {
expect(darkenHexColor('#ff0000', 0.2)).toBe('#cc0000');
});
it('accepts hex without leading # and adds # to output', () => {
// The output always has a leading # for consistency, even if
// the input didn't. Callers serializing back to CSS or hex
// pickers should not have to test which case they got.
expect(darkenHexColor('ff0000', 0.2)).toBe('#cc0000');
});
it('expands 3-char hex shorthand to 6-char before darkening', () => {
// #abc → expand to #aabbcc, then darken.
// 0xaa=170 * 0.8 = 136 = 0x88
// 0xbb=187 * 0.8 = 149.6 → 150 = 0x96
// 0xcc=204 * 0.8 = 163.2 → 163 = 0xa3
expect(darkenHexColor('#abc', 0.2)).toBe('#8896a3');
});
it('accepts uppercase hex and emits lowercase', () => {
// The browser and Konva both accept either case, but a single
// canonical case in storage makes equality checks on `fill ===
// stroke` reliable (the sharedStyle comparison in TextTab.jsx
// depends on this).
expect(darkenHexColor('#FF0000', 0.2)).toBe('#cc0000');
});
it('trims whitespace before parsing', () => {
expect(darkenHexColor(' #ff0000 ', 0.2)).toBe('#cc0000');
});
});
describe('malformed input passthrough', () => {
// Per docblock: malformed input is returned UNCHANGED rather than
// throwing or returning a sentinel. The caller's UI shows the
// original (broken) color and the user can pick a new one. This
// is a deliberate non-failure mode — never crash a render over a
// typo in state.
it('non-string input is returned unchanged', () => {
expect(darkenHexColor(null, 0.2)).toBe(null);
expect(darkenHexColor(undefined, 0.2)).toBe(undefined);
expect(darkenHexColor(42, 0.2)).toBe(42);
expect(darkenHexColor({}, 0.2)).toEqual({});
});
it('hex with wrong length is returned unchanged', () => {
// 4 chars, 5 chars, 7 chars — none are valid hex.
expect(darkenHexColor('#abcd', 0.2)).toBe('#abcd');
expect(darkenHexColor('#abcde', 0.2)).toBe('#abcde');
expect(darkenHexColor('#abcdef0', 0.2)).toBe('#abcdef0');
});
it('hex containing non-hex characters is returned unchanged', () => {
expect(darkenHexColor('#gghhii', 0.2)).toBe('#gghhii');
expect(darkenHexColor('#zz0000', 0.2)).toBe('#zz0000');
});
it('empty string is returned unchanged', () => {
// After stripping #, the empty string has length 0 — not 3, not 6.
expect(darkenHexColor('', 0.2)).toBe('');
});
});
});
// ────────────────────────────────────────────────────────────────────
// relativeLuminance — WCAG canonical formula.
// ────────────────────────────────────────────────────────────────────
describe('relativeLuminance', () => {
it('pure black is 0', () => {
expect(relativeLuminance('#000000')).toBe(0);
});
it('pure white is 1', () => {
expect(relativeLuminance('#ffffff')).toBe(1);
});
it('mid-grey is approximately 0.215', () => {
// The exact WCAG-canonical mid-grey (#808080) luminance, computed
// via the sRGB → linear-light gamma curve. Useful as a known
// intermediate to confirm the gamma transform is wired correctly —
// a non-gamma-corrected formula would give ~0.5 here.
const result = relativeLuminance('#808080');
expect(result).toBeGreaterThan(0.21);
expect(result).toBeLessThan(0.22);
});
it('channel weighting: pure green > pure red > pure blue', () => {
// The luminance weights are G=0.7152, R=0.2126, B=0.0722. The
// ordering reflects human perception (green-dominant cones), and
// confirms the per-channel coefficients aren't swapped — a bug
// we'd want to catch.
const lG = relativeLuminance('#00ff00');
const lR = relativeLuminance('#ff0000');
const lB = relativeLuminance('#0000ff');
expect(lG).toBeGreaterThan(lR);
expect(lR).toBeGreaterThan(lB);
expect(lG).toBeCloseTo(0.7152, 4);
expect(lR).toBeCloseTo(0.2126, 4);
expect(lB).toBeCloseTo(0.0722, 4);
});
it('accepts shorthand 3-char hex (expands to 6-char)', () => {
expect(relativeLuminance('#fff')).toBe(1);
expect(relativeLuminance('#000')).toBe(0);
});
it('returns NaN for malformed input', () => {
// colorUtils' convention: NaN means "unknown — bail or fall back".
// Compare with colorContrast.relativeLuminance which uses parseHex
// and returns from a higher-level helper.
expect(relativeLuminance('not a color')).toBeNaN();
expect(relativeLuminance('#zz0000')).toBeNaN();
expect(relativeLuminance(null)).toBeNaN();
expect(relativeLuminance(undefined)).toBeNaN();
expect(relativeLuminance('')).toBeNaN();
});
});
// ────────────────────────────────────────────────────────────────────
// getContrastRatio — WCAG contrast formula.
// ────────────────────────────────────────────────────────────────────
describe('getContrastRatio', () => {
it('pure black on pure white is 21 (theoretical max)', () => {
// (1 + 0.05) / (0 + 0.05) = 21
expect(getContrastRatio('#000000', '#ffffff')).toBe(21);
});
it('identical colors have ratio 1', () => {
expect(getContrastRatio('#ff0000', '#ff0000')).toBe(1);
expect(getContrastRatio('#808080', '#808080')).toBe(1);
expect(getContrastRatio('#000000', '#000000')).toBe(1);
});
it('is symmetric (order of arguments does not matter)', () => {
// The formula picks max/min internally, so swapping args is a
// no-op. Worth pinning because a "swap them later" optimization
// could accidentally break symmetry.
const a = getContrastRatio('#ff0000', '#00ff00');
const b = getContrastRatio('#00ff00', '#ff0000');
expect(a).toBe(b);
});
it('returns NaN if either input is malformed', () => {
// This is colorUtils' convention. colorContrast.contrastRatio
// returns 1 instead — that divergence is flagged in this file's
// header comment.
//
// Be careful picking "malformed" inputs: 3-char strings like 'bad',
// 'fed', 'cab', 'dad', etc. are VALID hex shorthand because b, a,
// d, c, e, f are all hex digits — they parse to #bbaadd, #ffeedd,
// etc. and produce real contrast ratios. The malformed-input
// assertions need strings that contain non-hex characters or
// wrong-length sequences.
expect(getContrastRatio('#ff0000', 'not a color')).toBeNaN();
expect(getContrastRatio('not a color', '#ff0000')).toBeNaN();
expect(getContrastRatio('xyz', 'qrs')).toBeNaN(); // 3 chars but non-hex
expect(getContrastRatio('zzzzzz', '#ff0000')).toBeNaN(); // 6 chars but non-hex
expect(getContrastRatio(null, '#000000')).toBeNaN();
expect(getContrastRatio(undefined, '#000000')).toBeNaN();
});
it('black on mid-grey is between WCAG AA-large (3:1) and AA-normal (4.5:1) thresholds', () => {
// (~0.215 + 0.05) / (0 + 0.05) = ~5.3 — passes both AA thresholds.
// Range check rather than exact value so jsdom precision quirks
// don't break the test.
const ratio = getContrastRatio('#000000', '#808080');
expect(ratio).toBeGreaterThan(5);
expect(ratio).toBeLessThan(6);
});
});
// ─────────────────────────────────────────────────────────────────
// LOW_CONTRAST_THRESHOLD + isLowContrast — merged from colorContrast.js
// on May 23 2026. The semantic carried over from colorContrast was the
// 2.0 threshold value; the NaN-aware fallback is new (the old
// isLowContrast returned `true` on bad input because the old
// contrastRatio returned 1, which the threshold check then flagged
// as low contrast). See colorUtils.js's isLowContrast docblock for
// the full rationale on switching to NaN-returns-false.
// ─────────────────────────────────────────────────────────────────
describe('LOW_CONTRAST_THRESHOLD', () => {
it('is 2.0', () => {
// Pinned because the value affects which color combinations
// trigger downstream warning surfaces. Bumping it (or a future
// caller using a different threshold) is a deliberate decision,
// not a stealth refactor.
expect(LOW_CONTRAST_THRESHOLD).toBe(2.0);
});
});
describe('isLowContrast', () => {
describe('threshold behavior', () => {
it('returns true when the ratio is below the default threshold', () => {
// Very-similar colors. Two mid-greys with a small luminance
// delta produce a low ratio well below 2.0.
expect(isLowContrast('#777777', '#888888')).toBe(true);
});
it('returns false when the ratio meets or exceeds the threshold', () => {
// Pure black on pure white has ratio 21 — nowhere near low.
expect(isLowContrast('#000000', '#ffffff')).toBe(false);
// Black on mid-grey is ~5.3, still well above 2.0.
expect(isLowContrast('#000000', '#808080')).toBe(false);
});
it('returns true for identical colors (ratio 1)', () => {
// Identical colors have ratio exactly 1, which is < 2.0.
// Useful as a guard against "the user picked the same colour
// for foreground and background" — the strictest version of
// the low-contrast case.
expect(isLowContrast('#ec4899', '#ec4899')).toBe(true);
});
it('accepts a custom threshold', () => {
// A stricter check (WCAG AA for normal text is 4.5) flags more
// combinations as low-contrast.
const blackOnGrey = getContrastRatio('#000000', '#808080');
// ~5.3, so AA-normal (4.5) lets it pass, but a strict 6.0
// threshold flags it as low.
expect(isLowContrast('#000000', '#808080', 4.5)).toBe(false);
expect(isLowContrast('#000000', '#808080', 6.0)).toBe(true);
expect(blackOnGrey).toBeGreaterThan(4.5);
expect(blackOnGrey).toBeLessThan(6.0);
});
});
describe('NaN-aware fallback (regression for the merge)', () => {
// The headline behavioral change from the pre-merge colorContrast
// module. Old colorContrast.isLowContrast returned TRUE on bad
// input (contrastRatio fell back to 1 → 1 < 2.0 → true). New
// isLowContrast returns FALSE on bad input — "unknown contrast,
// don't warn" rather than "unknown, warn just in case." These
// tests pin the new behaviour; a future change that switches
// back to true-on-NaN would fail here.
it('returns false when fg is malformed', () => {
expect(isLowContrast('not a color', '#ffffff')).toBe(false);
expect(isLowContrast('zzzzzz', '#ffffff')).toBe(false);
expect(isLowContrast(null, '#ffffff')).toBe(false);
expect(isLowContrast(undefined, '#ffffff')).toBe(false);
});
it('returns false when bg is malformed', () => {
expect(isLowContrast('#000000', 'not a color')).toBe(false);
expect(isLowContrast('#000000', null)).toBe(false);
});
it('returns false when both are malformed', () => {
expect(isLowContrast('xyz', 'qrs')).toBe(false); // 3 chars but non-hex
expect(isLowContrast(null, undefined)).toBe(false);
});
});
describe('symmetry', () => {
it('argument order does not matter (mirrors getContrastRatio symmetry)', () => {
// The function's docblock pins (fg, bg) as the canonical order,
// but the WCAG math is symmetric so swapping must give the same
// answer. A future refactor that introduced order-dependent
// logic (e.g. checking foreground luminance specifically) would
// break this and need to update the docblock.
const a = isLowContrast('#777777', '#888888');
const b = isLowContrast('#888888', '#777777');
expect(a).toBe(b);
});
});
});

View File

@@ -1,183 +0,0 @@
// Pure crop math for image elements.
//
// Why this exists
// ───────────────
// Until May 22 2026 this math lived inline in App.jsx's
// `handleApplyCrop` callback. The function intermingled three concerns:
//
// 1. Resolving the natural image dimensions via a Konva.stages walk
// (impure, depends on the DOM).
// 2. Reading the live `cropping` state and exiting crop mode
// (React state, impure).
// 3. The actual math: canvas-coords cropRect + current element +
// naturals → new crop record + tracking fields (pure).
//
// (1) and (2) can't move out of App — they depend on React state and
// the live Konva stage. But (3) is a closed-form function: same
// inputs always produce the same outputs, no side effects. Moving it
// here makes it testable without mounting React or a real canvas,
// which matters because the math is responsible for two bug-prone
// behaviors:
//
// • flipX/flipY mirror correction. The crop rect comes from a
// canvas-coord overlay that doesn't know about the element's
// flip state, so the local-coord conversion has to undo the
// mirror. Pre-extraction, getting this wrong meant the user
// would visually crop to the cat's face and end up with a crop
// pointing at the cat's tail.
//
// • Multi-crop composition. Cropping an already-cropped image
// means the new crop's source-image coordinates have to be
// expressed relative to the FULL natural source, not the
// current crop window. The scaleX/Y calculation handles this
// by reading the current element.crop's sWidth/sHeight rather
// than naturalW/H when present.
//
// What this function returns
// ──────────────────────────
// The full `attrs` patch that the caller should pass to
// `updateAndCommit`. Includes:
//
// • Geometry (x, y, width, height) — the element takes the crop
// rect's position and size.
// • crop — the new source-image sub-region.
// • preCrop, postCrop, previousCrop — the tracking fields that
// power the "Undo crop" button's single-step rollback. See the
// App-side docblock on those fields in handleApplyCrop for the
// full mental model; the short version is "remember enough to
// go back one step, even after intervening resizes."
//
// What it does NOT do
// ───────────────────
// • No rounding. Konva.Image accepts sub-pixel source coords;
// rounding here would accumulate drift across multi-crop
// sequences. The caller can round the element bounds if needed
// for cleaner storage.
// • No clamping to natural dims. We trust the cropRect to be
// within the element's bounds — the crop overlay's boundBoxFunc
// enforces that at drag time. A cropRect that pokes outside
// would produce a sub-region partially outside the source
// image; Konva's draw handles that gracefully by clipping.
// • No validation of element shape (image vs sticker vs text).
// Caller is responsible for filtering by type. The math itself
// is type-agnostic — it just needs x/y/width/height/optional
// crop/optional flip flags.
/**
* Compute the update patch for applying a crop to an image-like element.
*
* @param {Object} params
* @param {Object} params.element Element being cropped. Required fields:
* `x`, `y`, `width`, `height`. Optional:
* `flipX`, `flipY`, `crop` (with
* `sx`/`sy`/`sWidth`/`sHeight`).
* @param {Object} params.cropRect Crop rectangle in canvas/design coords.
* `{x, y, width, height}`.
* @param {number} params.naturalW Source image's natural width (px).
* @param {number} params.naturalH Source image's natural height (px).
*
* @returns {Object|null} Patch for `updateAndCommit`, or `null` if any
* required input is missing or zero-dimensioned.
* The null path matches what handleApplyCrop
* should do when its natural-dims resolution
* fails — bail without committing.
*/
export function computeCropUpdate({ element, cropRect, naturalW, naturalH }) {
// Guard inputs. Returning null tells the caller "couldn't compute,
// don't commit a partial patch." Equivalent to handleApplyCrop's
// bail paths for "image still loading" and "element vanished."
if (!element || !cropRect) return null;
if (!naturalW || !naturalH) return null;
if (!element.width || !element.height) return null;
if (!cropRect.width || !cropRect.height) return null;
// Read the current source-image sub-region. If the element has no
// crop yet, the "current" sub-region is the whole natural image —
// sx/sy start at 0, sWidth/sHeight at naturalW/H.
const currentSrcX = element.crop?.sx ?? 0;
const currentSrcY = element.crop?.sy ?? 0;
const currentSrcW = element.crop?.sWidth ?? naturalW;
const currentSrcH = element.crop?.sHeight ?? naturalH;
// Per-axis scale from canvas (element-local) coords to source-image
// coords. For an uncropped element this equals naturalW/element.width
// and naturalH/element.height — the element is just a scaled
// rendering of the full source. For an already-cropped element, the
// scale is currentSrcW/element.width — the element renders the
// current sub-region at the element's displayed size.
const scaleX = currentSrcW / element.width;
const scaleY = currentSrcH / element.height;
// Convert cropRect from canvas coords to element-local coords.
// localX/localY are the upper-left of the crop within the element's
// (0..element.width, 0..element.height) box.
const localX = cropRect.x - element.x;
const localY = cropRect.y - element.y;
// Flip correction. The crop overlay renders in canvas coords, so a
// user clicking on the visually-displayed "left ear" of a horizontally
// flipped image is selecting the corresponding region in CANVAS space.
// But the source image isn't flipped — Konva applies the flip at
// render time. To select the right region of the SOURCE we have to
// mirror back across the local axis.
//
// The mirror formula for X: with the element's local box being
// [0..element.width], the canvas-space localX corresponds to
// source-space (element.width - localX - cropRect.width) — the
// crop's RIGHT edge in canvas space is its LEFT edge in source
// space (and vice versa for the width). Same logic for Y when
// flipY is set.
const localCropX = element.flipX
? element.width - localX - cropRect.width
: localX;
const localCropY = element.flipY
? element.height - localY - cropRect.height
: localY;
// Compose with currentSrcX/Y so multi-crop sequences land in the
// right source-coord region. Without the addition, a second crop
// would treat its localCropX as an offset from sx=0 instead of
// from the previous crop's sx — the new crop would jump back to
// the upper-left of the SOURCE rather than narrowing into the
// previous crop's window.
const newCrop = {
sx: currentSrcX + localCropX * scaleX,
sy: currentSrcY + localCropY * scaleY,
sWidth: cropRect.width * scaleX,
sHeight: cropRect.height * scaleY,
};
return {
// The element's display geometry becomes the crop rect — same
// x/y/width/height as the user dragged. The cropped sub-region
// (crop) renders inside this box.
x: cropRect.x,
y: cropRect.y,
width: cropRect.width,
height: cropRect.height,
crop: newCrop,
// Tracking fields powering the "Undo crop" button's single-step
// rollback. See App-side handleApplyCrop / handleUndoCrop for
// the full rationale; the short version:
//
// preCrop: the dims immediately BEFORE this crop. Used by
// "Undo crop" to restore bounds when the user hasn't
// resized since this crop apply.
//
// postCrop: the dims immediately AFTER this crop. Compared
// against current bounds at undo time to decide whether the
// user resized between the crop and the undo — if they did,
// undo falls through to the fit-to-bounds branch instead of
// literal restore.
//
// previousCrop: the crop value that was active immediately
// before this apply (null for a first-time crop). Restored
// on undo so multi-crop sequences go back ONE level rather
// than jumping to fully-uncropped.
preCrop: { width: element.width, height: element.height },
postCrop: { width: cropRect.width, height: cropRect.height },
previousCrop: element.crop ?? null,
};
}

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