Compare commits

..

39 Commits

Author SHA1 Message Date
khalid@traclabs.com
840a5dbcb3 v0.8: auth integration, design persistence (#23), protected sweep (#24)
Server (spans the v0.6→v0.8 arc in one file):
- template-manager mounted with the productsStore catalog, PNG-only
  mockup multer, facet personalization config (§R10, FACET_KEY-gated,
  inert when unset), and the marketing-preview drainer (2s tick, one
  render per tick, .unref()).
- auth-manager bootstrap: DATABASE_URL gates the pg Pool, migrations,
  and mountAuth (magic-link customer + Google SSO admin + facet);
  absent DATABASE_URL logs a warning and the host boots without auth.
- goods-editor design persistence (#23): idempotent 001_designs.sql
  applied at boot, db handle passed to mountEditorApi so /api/designs
  routes mount; without Postgres the client falls back to
  cart-without-design_id.
- reference-protected TTL sweep (#24): before each pass, every
  /uploads/ basename referenced by template-manager JSON or persisted
  designs is collected and protected, and exports/designs (db-driven
  lifecycle, unrecoverable sources) is skipped entirely. Sweep kickoff
  moves below the bootstrap (pgPool TDZ). sweep-worker.mjs is the
  standalone process variant for production.

Client: main.jsx wraps the tree in AuthProvider (single /api/auth/me
fetch shared by useCustomer/useAdmin, degrades on 404) and mounts
AppRouter. eslint bumps server.js to ecmaVersion 2022 (top-level
await); .env.example documents the full v0.8 env surface.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 23:29:16 -05:00
khalid@traclabs.com
dfa1f90a16 v0.8: self-hosted fonts replace the Google Fonts CDN link
index.html now loads /api/fonts/fonts.css, synthesized at request time
by goods-editor's mountEditorApi from the host's fonts/ directory
(populated via fetch-fonts in goods-editor-module — the host-local
fetch script shrinks to a pointer). Shipped synchronously in <head>
on purpose: the module's runtime injector races first paint and the
editor's text measurement gives wrong answers against fallback
metrics until real fonts load.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 23:29:04 -05:00
khalid@traclabs.com
6599a5e68f v0.7: shop storefront + routed app
AppRouter replaces the single-page App as the mounted root: shop routes
(/ and /shop → ShopHome, /shop/:productSlug → ProductPage, /cart →
CartStub, /customize → CustomizeRoute) plus the template-manager admin
surfaces (/admin/templates|products|featured|trash, v0.8 adds
/admin/inventory + RequireAdmin wrapping). App.jsx stays on disk one
release as the documented legacy fallback — nothing imports it.

New shop UI: components/shop/ (ShopHome/Hero/ProductGrid/ProductCard/
ProductPage/ProductGallery/ProductInfo/ProductBuyPanel/CartStub/
ShopHeader/TemplateGalleryForProduct), hooks (useShopProducts/
useShopProduct/useCart/useFacetCatalog), and shop.css + admin.css.

Header gains the module-gated Clear button (onCanClearChange /
onElementCountChange mirror the editor module's history-commit
signals); i18n keys follow. vite proxies /products to the Node server
(template-manager serves product imagery — without it the SPA fallback
returns index.html as an image) and adds a template-manager chunk.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 23:28:58 -05:00
khalid@traclabs.com
aba2dbedfb v0.6: template-manager owns the product catalog
The host no longer carries a runtime product catalog. products.js
shrinks to a legacy single-product stub; the canonical catalog lives in
template-manager's productsStore, seeded ONCE on first boot from the
new seedProducts.js (after that the store under templates/ is
authoritative and the seed is ignored — intentional, so restarts never
rewrite an admin-managed catalog). phoneModels.js and
templateCategories.js are the supporting constant sets, and
docs/template-schema.md documents the v4 schema.

templates/ is the admin-managed store itself (_products_index.json +
products/<id>/product.json, JSON only) — committed so the configured
catalog (sizes, pricing, options built up in the admin UI) survives a
fresh checkout rather than regressing to the seed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 23:28:48 -05:00
khalid@traclabs.com
b5b8808251 deps: local module workspace + v0.8 runtime deps
goods-editor moves from the git+ssh tag to file:../goods-editor-module,
joined by file: deps on template-manager and @khalidadil/auth-manager
(scoped name — published to the Gitea registry as 0.2.0; the file:
protocol stays for local development, the key must match the scoped
name). pg lands for the auth/design-persistence Postgres pool and
react-router-dom for the v0.7 routed app.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 23:28:39 -05:00
khalid@traclabs.com
87f5753804 Fix uploads and stickers caching rules 2026-05-24 11:06:10 -05:00
khalid@traclabs.com
e4e9459c86 Update to add externalized dependencies for the goods-editor-module 2026-05-24 10:56:43 -05:00
khalid@traclabs.com
846560f81a Update package-lock json 2026-05-24 10:33:34 -05:00
khalid@traclabs.com
937cb7528a Change port to correct SSH port 2026-05-24 10:32:33 -05:00
khalid@traclabs.com
1fe0ea1a4b More key troubleshooting 2026-05-24 09:51:02 -05:00
khalid@traclabs.com
9d7c9d7a3e Logging key verification values 2026-05-24 09:30:43 -05:00
khalid@traclabs.com
f3327d29d4 Update to use a base64 encoded key 2026-05-24 09:18:06 -05:00
khalid@traclabs.com
9fe0828986 Fix ssh key issues 2026-05-24 09:07:09 -05:00
khalid@traclabs.com
6d63775800 Fix ssh from host not working in docker 2026-05-24 08:49:27 -05:00
khalid@traclabs.com
070c95e254 Break editor into a module 2026-05-24 08:44:29 -05:00
khalid@traclabs.com
1d01442a39 Update vita-plugin-pwa 2026-05-23 04:15:57 -05:00
khalid@traclabs.com
3277bbe5ec Update to use a function 2026-05-23 04:10:22 -05:00
khalid@traclabs.com
8294c47f74 Spit dependencies into chunks 2026-05-23 04:05:03 -05:00
khalid@traclabs.com
6127e7b781 Fix docker file 2026-05-23 03:42:05 -05:00
khalid@traclabs.com
b1fb6fa3aa Major update for v1 and tests 2026-05-23 03:28:58 -05:00
khalid@traclabs.com
628a6765f4 Fix module issues, fix styling, add conditions to when the background removal and edit controls are shown 2026-04-23 08:48:11 -05:00
khalid@traclabs.com
4d19363d58 Flatted and issues fixed with Claude Desktop. 2026-04-22 06:21:02 -05:00
Khalid A
66bd69efe7 Consolidate to single server with unified package.json
- Merge client and server dependencies into root package.json
- Remove separate client/package.json and server/package.json
- Update server/index.js to serve built client static files
- Simplify Dockerfile to single build + production stage
- Update dev scripts for unified development workflow
- SPA routing serves index.html for non-API routes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 22:24:20 -05:00
Khalid A
009557c249 Implement template system and PWA enhancements
Phase 6 - Template System:
- Add TemplateLayer component for background/overlay rendering
- Add SlotPlaceholder component with visual indicators for empty slots
- Add useTemplate hook with auto-crop and drag constraint functions
- Update templates.js with slot definitions for team-sport template
- Integrate template system into DesignCanvas and App
- Add slot upload UI in TemplatesTab sidebar

Phase 9 - PWA Improvements:
- Add Workbox caching rules for HuggingFace LFS, templates, and API
- Change registerType to 'prompt' for update notifications
- Add service worker update handler in main.jsx
- Add refresh prompt UI in PWAInstall component

Phase 10 - Responsive and Accessibility:
- Add responsive CSS media queries for tablet/mobile layouts
- Add OfflineIndicator component with online/offline detection
- Add focus trap and keyboard navigation to PhotoPreEditor
- Add aria labels and screen reader support to modal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 22:08:22 -05:00
Khalid A
304a6b247b Add canvas dependency to server package.json (Phase 8 requirement) 2026-04-21 21:55:18 -05:00
Khalid A
4ca7910465 Phases 7-10: Complete remaining features and optimizations
Phase 7.2 - Debounce undo/redo history:
- Add 300ms debounce timer for rapid drag/transform changes
- Commit history on dragEnd/transformEnd events only
- Prevents history bloat during continuous interactions

Phase 8.3 - Template-aware export:
- Render template background layer first
- Apply slot crop regions for image elements
- Render template overlay layer last
- Support nonPrintable flag for guides/watermarks

Phase 9 - PWA icons:
- Add pwa-192x192.svg and pwa-512x512.svg icons
- Update vite.config.js manifest configuration

Phase 10.3 - Performance optimizations:
- Add React.memo to canvas components (ImageElement, TextElement, DesignCanvas)
- Add React.memo to panel components (LayersPanel, PropertiesPanel)
- Prevent unnecessary re-renders during canvas interactions

Phase 10.6 - Template documentation:
- Document template JSON schema in docs/template-schema.md
- Include element properties, slot definitions, and examples
- Describe background/overlay layer structure
2026-04-21 21:50:33 -05:00
Khalid A
a02f020d4c Phase 10: Polish & QA
- Added comprehensive README.md with:
  - Feature list and tech stack
  - Installation and Docker instructions
  - Project structure documentation
  - API endpoint reference
  - Build plan status checklist
- Verified all 10 phases complete
- Code review passed - no security vulnerabilities found
- Ready for production deployment

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 01:53:58 -05:00
Khalid A
d42a497ae8 Phase 9: PWA & Workbox Caching (merged) 2026-04-21 01:52:43 -05:00
Khalid A
5164b08c1c Phase 9: PWA & Workbox Caching
- Added vite-plugin-pwa and workbox-window dependencies
- PWA manifest with icons and standalone display mode
- Workbox runtime caching for:
  - Transformers.js models (30 days)
  - Uploaded images (7 days)
  - Google Fonts (1 year)
- PWAInstall component with install prompt banner
- Offline support for cached assets
- Auto-update registration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 01:51:43 -05:00
Khalid A
29e30ec368 Phase 8: High-Resolution Export (merge)
- Integrated export functionality with LayersPanel
- Server-side node-canvas export at 4500x4500px (300 DPI)
- Undo/Redo buttons and export button in canvas header
- Merged with remote Phase 3 changes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 01:33:16 -05:00
Khalid A
8a4b653019 Phase 8: High-Resolution Export
- Server-side export endpoint using node-canvas
- 300x300px preview -> 4500x4500px export (15"x15" @ 300 DPI)
- Exports images and text elements with proper scaling
- useExport hook with progress indicator
- Export button in canvas header
- Automatic download of exported PNG
- Error handling with dismissible error banner

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 01:29:41 -05:00
Khalid A
72a1967333 Phase 3: Sidebar & Properties Panel (merge with remote) 2026-04-21 01:28:46 -05:00
Khalid A
fd11a36d93 Phase 3: Sidebar & Properties Panel
Implemented full editor UI with three-column layout:
- Sidebar with 4 tabs (Upload, Stickers, Text, Templates)
- UploadTab with drag-and-drop file upload, wires to POST /api/upload
- StickersTab with 96 emoji stickers across 6 categories
- TextTab with font picker (20 Google Fonts), size slider, color picker
- TemplatesTab placeholder for future template system
- LayersPanel showing all elements with select/delete
- PropertiesPanel with position, size, rotation controls

Also added:
- Constants for fonts and stickers
- Enhanced CSS with editor-layout, sidebar, properties-panel classes
- Updated App.jsx to integrate all components
2026-04-21 01:27:59 -05:00
Khalid A
537cfd572d Phase 7: Undo/Redo
- History tracking with 50-state limit in useDesignEditor hook
- Undo: Ctrl/Cmd + Z keyboard shortcut
- Redo: Ctrl/Cmd + Shift + Z or Ctrl/Cmd + Y
- Undo/Redo buttons in canvas header
- History saves state after add, update, delete, reorder operations
- Disabled button states when history is exhausted

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 01:27:51 -05:00
Khalid A
72495fec3e Phase 6: Template System
- Added 8 pre-designed templates across 8 categories
- Templates: Team Sport, Band Merch, Minimal Quote, Funny Cat,
  Gradient Vibes, Vintage Badge, Nature Lover, Tech Geek
- Templates tab with category filter pills
- Template preview cards with 2-column grid
- One-click template application to canvas

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 01:24:03 -05:00
Khalid A
7bf9ce3a9c Phase 5: Photo Pre-Editor (Filerobot)
- Added react-filerobot-image-editor dependency
- PhotoPreEditor component with full editing capabilities
- Crop, filters, adjustments, annotations, watermark tabs
- Opens after image upload, before adding to canvas
- Exports edited image as PNG for canvas use

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 01:20:11 -05:00
Khalid A
4a735e2f2e Phase 4: Background Removal (Transformers.js)
- Added @xenova/transformers dependency
- useBackgroundRemoval hook with RMBG-1.4 model
- Client-side background removal with progress indicator
- Background removal button in properties panel (image elements only)
- ~170MB model cached after first download

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 01:19:03 -05:00
Khalid A
2acf674aaa Phase 3: Sidebar & Properties Panel
- Three-column layout (sidebar/canvas/properties)
- Sidebar with tabs: Upload, Stickers, Text
- Upload tab with drag-and-drop and click-to-upload
- Stickers tab with 6 categories (40+ emojis)
- Text tab with font selector, size slider, color picker
- Properties panel with position, size, rotation controls
- Delete button for selected element
- Responsive layout for mobile

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 01:17:47 -05:00
Khalid A
e67017b259 Phase 2: Canvas Editor Core
Implements the core canvas editor with react-konva:

- Added dependencies: react-konva, konva, use-image
- DesignCanvas component: 300×300px Stage with T-shirt SVG overlay
- TShirtSVG component: Visual t-shirt outline with print zone indicator
- ImageElement: Draggable/resizable image with Transformer handles
- TextElement: Draggable/resizable text with Transformer handles
- useDesignEditor hook: Element CRUD, selection, reordering
- Keyboard shortcut: Delete/Backspace removes selected element
- Test image added on mount for Phase 2 verification

Canvas info bar shows: "Design Area: 15" × 15" • Export: 4500 × 4500px @ 300 DPI"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 01:03:33 -05:00
1731 changed files with 27045 additions and 4836 deletions

View File

@@ -10,3 +10,5 @@ exports/*
!exports/.gitkeep !exports/.gitkeep
dist dist
.cache .cache
.env
.env.*

View File

@@ -1,6 +1,215 @@
# Server Configuration # Example environment file for apparel-designer.
#
# Copy this to `.env` and adjust values as needed. Compose reads
# `.env` automatically for variable substitution in docker-compose.yml
# AND passes the values as runtime environment to the running
# container, so a single file covers both build-time and runtime.
#
# ──────────────────────────────────────────────────────────────────
# Runtime — values the server reads at startup
# ──────────────────────────────────────────────────────────────────
# HTTP port the Express server binds to. Matches the host-side port
# in docker-compose.yml's `ports:` mapping.
PORT=3001 PORT=3001
# `production` enables prod-only behavior in server.js (production
# stickers path under dist/, production-mode logger). Use
# `development` locally to read stickers from public/ instead.
NODE_ENV=development NODE_ENV=development
# Client Configuration # Pino log level. Comment out for `info` (default). Useful values:
VITE_API_URL=http://localhost:3001 # trace, debug, info, warn, error, fatal
# Set to `debug` when diagnosing a problem; revert before deploy.
# LOG_LEVEL=info
# CORS — uncomment and set when the frontend is served from a
# different origin than this API. Single origin or comma-separated.
# CORS_ORIGIN=https://your-domain.com
# Upload / export TTL and sweep interval, in milliseconds.
# Defaults (24h TTL, 1h sweep) work for most cases. Lower the TTL
# if disk space is tight; lower the interval if uploads churn fast
# and you want stale files cleaned sooner.
# FILE_TTL_MS=86400000 # 24 hours
# CLEANUP_INTERVAL_MS=3600000 # 1 hour
# ==================================================================
# auth-manager (v0.8) -- runtime
# ==================================================================
#
# auth-manager mounts customer magic-link + admin Google SSO + the
# account shell. If DATABASE_URL is NOT set, auth-manager is disabled
# gracefully and the rest of the host still boots -- useful when
# working on goods-editor / template-manager features without
# provisioning Postgres locally.
# Postgres connection string. Required to enable auth-manager.
# Examples:
# Local Docker: postgres://postgres:postgres@localhost:5432/apparel
# Dokploy: postgres://<user>:<pass>@<service>:5432/<db>
# DATABASE_URL=postgres://postgres:postgres@localhost:5432/apparel
# Fully-qualified base used to build:
# - the magic-link URL we email to customers (and print to console)
# - the Google OAuth redirect URI for admin SSO
# Must match the redirect URI registered in Google Cloud Console.
# In dev, set to http://localhost:3001. In prod, your storefront URL.
# Falls back to http://localhost:${PORT} if unset (dev only -- won't
# work behind a reverse proxy).
# PUBLIC_BASE_URL=http://localhost:3001
# Comma-separated allowlist of Google accounts that can sign in via
# admin SSO. Two or more entries strongly recommended for break-glass
# recovery (locked decision #4 in v08_Auth_v1.md).
# ADMIN_EMAILS=you@gmail.com,backup@gmail.com
# Google OAuth 2.0 Web client credentials. Required for admin SSO.
# Create in Google Cloud Console -> APIs & Services -> Credentials ->
# Create credentials -> OAuth client ID -> Web application. Authorized
# redirect URI must be ${PUBLIC_BASE_URL}/auth/admin/google/callback.
# GOOGLE_CLIENT_ID=...apps.googleusercontent.com
# GOOGLE_CLIENT_SECRET=...
# Optional: SMTP relay for magic-link emails. If unset, magic links
# ONLY print to the server's stdout (still fully functional for dev
# -- just copy/paste the link from your terminal).
#
# Recommended dev setup: Mailpit (https://mailpit.axllent.org).
# docker run -d -p 1025:1025 -p 8025:8025 axllent/mailpit
# then visit http://localhost:8025 to read sent mail.
#
# Resend integration is deferred to v2 -- see v08_Auth_v1.md
# "Email delivery in v1" section for rationale.
# SMTP_HOST=localhost
# SMTP_PORT=1025
# SMTP_USER=
# SMTP_PASS=
# SMTP_FROM=no-reply@localhost
# Optional: cookie domain for cross-subdomain session sharing
# (e.g. '.yourdomain.com' so checkout.yourdomain.com sees the same
# session as yourdomain.com). Usually leave unset -- SureCart's WP
# sidecar uses its own customer identity (passed via metadata at
# checkout creation), not auth-manager's session.
# COOKIE_DOMAIN=
# ==================================================================
# Personalization facet (Project doc §R10) -- runtime
# ==================================================================
#
# Generic "customers filter the shop by a facet" feature. For THIS
# deployment the facet is "Pet Type" (dog/cat/reptile/…); other shops
# configure a different facet, or none. The facet VALUES are managed by
# an admin inside template-manager; only the DEFINITION is set here.
#
# FACET_KEY is the master switch. Leave it UNSET to run a plain,
# unfiltered shop: no sign-in prompt, no template filtering, the editor
# shows all stickers, and the account/admin facet UIs stay hidden.
# The facet's stable key. Setting it turns the feature ON. Becomes the
# facet_key on customer_facet_values rows + the audienceTags scope.
# FACET_KEY=pet_type
# Display label shown in the UI ("Choose your {LABEL}", account heading,
# admin section title).
# FACET_LABEL=Pet Type
# Selection mode: `multi` (default) lets a customer pick several values
# (e.g. dog + cat); `single` restricts to one.
# FACET_SELECTION_MODE=multi
# Require an admin to categorize every template before saving it
# (>=1 facet value, or marked General/universal). Default false. Turn
# this ON whenever the shop filters strictly, so an untagged template
# is never hidden silently. THIS shop: true.
# REQUIRE_TEMPLATE_CATEGORY=true
# Require a signed-in customer to choose at least one value before
# browsing. Default false. THIS shop: false (the prompt nudges but is
# skippable; choosing nothing shows everything).
# REQUIRE_FACET_SELECTION=false
# ==================================================================
# Image upscale service (goods-editor, 2026-06-10) -- runtime
# ==================================================================
#
# Optional REST service that upscales low-resolution customer photos
# before they hit the print render (goods-editor's /api/export
# pipeline). goods-editor reads these variables itself
# (imageUpscalerFromEnv inside mountEditorApi) -- no server.js wiring
# needed; setting UPSCALE_SERVICE_URL is the master switch. Unset =
# feature fully dormant, renders byte-identical to before.
#
# The editor independently FLAGS low-res images to customers/admins
# in the UI regardless of this setting -- the service only improves
# what actually prints. Upscaling is best-effort: if the service is
# down or slow, the render proceeds with the original pixels.
#
# Expected contract (adapt goods-editor's src/server/lib/
# imageUpscaler.js if your service differs -- it's the single point
# of adaptation):
# POST $UPSCALE_SERVICE_URL with JSON
# { "image": "<base64>", "mime": "image/png", "scale": 2 }
# -> 200 with an image/* binary body, OR JSON { "image": "<base64>" }
# Master switch. e.g. a Real-ESRGAN container in the same Dokploy
# project: http://upscaler:5000/upscale
# UPSCALE_SERVICE_URL=
# Optional credential. Sent as 'Authorization: Bearer <key>' unless
# UPSCALE_SERVICE_AUTH_HEADER names a custom header (e.g. X-Api-Key),
# in which case the bare key is sent in that header.
# UPSCALE_SERVICE_API_KEY=
# UPSCALE_SERVICE_AUTH_HEADER=Authorization
# Per-request abort + the service's integer scale ceiling.
# UPSCALE_SERVICE_TIMEOUT_MS=30000
# UPSCALE_SERVICE_MAX_SCALE=4
# ──────────────────────────────────────────────────────────────────
# Build-time — values the `docker compose build` step uses
# ──────────────────────────────────────────────────────────────────
# Only needed when BUILDING the image, not when running it. The
# image talks to git.kadil.dev to fetch the `goods-editor` git+ssh:
# npm dependency during `npm install`. See the Dockerfile docblock
# for the full mechanism.
#
# The Dockerfile expects a BASE64-ENCODED private key (not raw PEM)
# because that's the format Dokploy's Build-time Secrets textarea
# requires — it's dotenv-parsed and can't handle multi-line values.
# We use base64 for local builds too so the Dockerfile has one input
# format. Encode like this:
#
# base64 < ~/.ssh/goods-editor-deploy | tr -d '\n' > /tmp/key.b64
#
# Recommended: generate a dedicated read-only deploy key rather than
# reusing a personal key (see Dockerfile docblock for the ssh-keygen
# + Gitea deploy-key registration steps).
#
# Path to a base64-encoded key file. Uncomment one for local builds:
# SSH_KEY_FILE=/Users/khalid/.ssh/goods-editor-deploy.b64
# SSH_KEY_FILE=/home/deploy/.ssh/goods-editor-deploy.b64
# Alternative: env-var-based (CI). To use this mode, also flip
# docker-compose.yml's `secrets.ssh_key` block from `file:` to
# `environment:` (see comments there). Set the base64 contents via
# the shell or your CI secret store:
#
# SSH_KEY_B64="$(base64 < ~/.ssh/goods-editor-deploy | tr -d '\n')" \
# docker compose build
#
# Don't put the actual base64 in this .env file. The string is long
# enough that dotenv parsers may struggle, and committing real key
# material — even base64 — to a repo is a leak waiting to happen.
#
# ── Dokploy ───────────────────────────────────────────────────────
# The entries above DON'T apply to Dokploy. In Dokploy, paste the
# base64 directly into the application's Environment → Build-time
# Secrets textarea as a single line:
#
# ssh_key=<that base64 string>
#
# Dokploy parses that textarea as dotenv and passes the value into
# the build as a BuildKit secret named `ssh_key`.

33
.gitignore vendored
View File

@@ -1,33 +1,24 @@
# Dependencies
node_modules/ node_modules/
*/node_modules/
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Build outputs
dist/ dist/
*/dist/ npm-debug.log*
# Environment
.env .env
.env.local .env.local
.env.*.local .env.*.local
# OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# IDE
.idea/ .idea/
.vscode/ .vscode/
*.swp *.swp
*.swo *.swo
uploads/*
!uploads/.gitkeep
exports/*
!exports/.gitkeep
# Uploads and exports (keep .gitkeep) # Server-side font files — fetched on demand via `npm run fetch-fonts`. We don't
server/uploads/* # commit them because TTFs are large binaries and the source is reproducible
!server/uploads/.gitkeep # from the script. The Dockerfile runs the fetch at build time so production
server/exports/* # images carry the fonts.
!server/exports/.gitkeep fonts/*
!fonts/.gitkeep
!fonts/README.md

View File

@@ -1,48 +1,236 @@
# Stage 1: Build client # Multi-stage Dockerfile.
FROM node:20-alpine AS client-builder #
# How to build
# ────────────
# This Dockerfile needs SSH access to git.kadil.dev during the
# `npm install` step (the `goods-editor` dependency is a git+ssh:
# URL). We pass a base64-encoded SSH private key as a BuildKit
# secret named `ssh_key`, then decode it inside the build.
#
# Why base64-encoded rather than raw PEM
# ───────────────────────────────────────
# The primary deploy target is Dokploy, whose Build-time Secrets UI
# is a dotenv-parsed textarea (one KEY=VALUE per line). PEM keys are
# multi-line and break dotenv parsing. Base64 collapses the key to a
# single line of safe ASCII that any dotenv parser accepts. We use
# base64 everywhere (Dokploy and local builds) so the Dockerfile has
# exactly one input format — no branching logic for raw vs encoded.
#
# Register a deploy key first
# ─────────────────────────────
# Generate a read-only deploy key dedicated to this build and add
# its public half to Gitea (repo Settings → Deploy Keys):
# ssh-keygen -t ed25519 -f ~/.ssh/goods-editor-deploy -N "" -C "deploy"
# cat ~/.ssh/goods-editor-deploy.pub # → paste into Gitea
#
# Then base64-encode the PRIVATE half for build-time use:
# base64 < ~/.ssh/goods-editor-deploy | tr -d '\n' | pbcopy
# The `tr -d '\n'` strips line wrapping so the output is a single
# physical line, which the Dokploy textarea (and dotenv parsers in
# general) require.
#
# Dokploy
# ───────
# Paste into the application's Environment → Build-time Secrets
# textarea as a single line:
# ssh_key=<that base64 string>
# Then redeploy.
#
# Gitea SSH port note
# ───────────────────
# Our self-hosted Gitea is configured with SSH on port 222, not the
# default 22. Port 22 on git.kadil.dev hits the Docker host's
# OpenSSH (not Gitea), so deploy keys added through Gitea's UI
# wouldn't authorize anything if we connected on 22. The package.json
# git+ssh URL specifies `:222` in the host portion to route through
# Gitea's own SSH server, and the `ssh-keyscan -p 222` below scans
# the matching port so the known_hosts entry uses the same
# [host]:port form that the URL resolves to.
#
# Local build with the same approach
# ───────────────────────────────────
# base64 < ~/.ssh/goods-editor-deploy | tr -d '\n' > /tmp/key.b64
# docker build --secret id=ssh_key,src=/tmp/key.b64 -t apparel-designer .
# rm /tmp/key.b64
#
# Or via env var:
# SSH_KEY_B64="$(base64 < ~/.ssh/goods-editor-deploy | tr -d '\n')" \
# docker build --secret id=ssh_key,env=SSH_KEY_B64 -t apparel-designer .
#
# Requires Docker 23+ (BuildKit is the default; older Docker needs
# `DOCKER_BUILDKIT=1` exported in the environment).
#
# Why two stages
# ──────────────
# `canvas` is a native-binding npm package. It needs Cairo/Pango/etc.
# at runtime, and python3+make+g++ at install-time to compile its
# native bindings (no prebuilt binary covers the alpine musl+napi+linux
# combination at this canvas version; prebuild-install falls back to
# node-gyp, which needs the compile toolchain).
#
# The naive single-stage approach would either:
# (a) keep python3+make+g++ in the final image — inflates the image
# by ~200MB of build tools nothing uses at runtime, or
# (b) try to install --omit=dev in a runtime-only image — fails
# because canvas tries to compile and has no python.
#
# Two-stage solves both: install + compile + prune dev deps in the
# builder (which has the toolchain), then COPY the resulting
# node_modules into a slim runtime that only carries the C runtime
# libs canvas needs at execution time.
WORKDIR /app/client # ─────────────────────────────────────────────────────────────────
# Stage 1: builder
# Installs all deps (including dev), compiles native bindings,
# builds the Vite bundle, then prunes dev deps so node_modules is
# production-ready before we copy it.
# ─────────────────────────────────────────────────────────────────
FROM node:20-alpine AS builder
COPY client/package*.json ./ # Build-time packages, grouped by why they're here:
RUN npm install #
# Native-binding compile toolchain (for `canvas` + node-gyp):
COPY client/ ./ # python3, make, g++
RUN npm run build #
# Native-binding headers (canvas links against these at compile
# Stage 2: Production server # time; the runtime stage carries the matching shared libs):
FROM node:20-alpine # cairo-dev, pango-dev, libjpeg-turbo-dev, giflib-dev,
# librsvg-dev, pixman-dev
# Install system dependencies for node-canvas and sharp #
# Git over SSH (for the `goods-editor` git+ssh: dependency):
# git, openssh-client
#
# git is npm's transport for git+ssh URLs; openssh-client provides
# the ssh binary that git invokes plus the ssh-keyscan utility we
# use below to populate known_hosts.
RUN apk add --no-cache \ RUN apk add --no-cache \
cairo-dev \ cairo-dev pango-dev libjpeg-turbo-dev giflib-dev librsvg-dev pixman-dev \
pango-dev \ python3 make g++ \
libjpeg-turbo-dev \ git openssh-client
giflib-dev \
librsvg-dev \
pixman-dev \
python3 \
make \
g++
WORKDIR /app WORKDIR /app
# Copy server package files and install # Copy package manifests first so docker can cache the npm install
COPY server/package*.json ./server/ # layer when only application source changes.
RUN cd server && npm install --production COPY package*.json ./
# Copy server source # Install dependencies using an SSH key passed in as a build secret.
COPY server/ ./server/ #
# `--mount=type=secret,id=ssh_key` is a BuildKit feature that exposes
# a host-provided secret at /run/secrets/ssh_key inside this single
# RUN step. The secret is NEVER copied into any image layer; once
# the RUN command finishes, the mount disappears and nothing about
# the key remains in the built image.
#
# Dokploy-specific note: Dokploy's Build-time Secrets UI is a single
# textarea that gets parsed as dotenv (one KEY=VALUE per line),
# which doesn't tolerate multi-line values. PEM-formatted SSH keys
# ARE multi-line. The workaround is to base64-encode the key before
# pasting into Dokploy and decode here at use-time. Base64 produces
# a single line of ASCII with no characters that confuse dotenv
# parsers.
#
# Encode locally:
# base64 < ~/.ssh/goods-editor-deploy | tr -d '\n' | pbcopy
# Then paste in Dokploy as:
# ssh_key=<that base64 string>
#
# Steps in this RUN:
# 1. mkdir ~/.ssh with 0700 (ssh refuses world-readable config dirs)
# 2. ssh-keyscan git.kadil.dev so we don't hang on the
# "continue connecting?" prompt
# 3. base64-decode /run/secrets/ssh_key into /tmp/ssh_key. The
# decoded file holds the original PEM private key.
# 4. chmod 0600 the decoded key so ssh accepts it.
# 5. Run npm install with GIT_SSH_COMMAND pointing at the decoded
# key. IdentitiesOnly=yes prevents ssh from trying any other
# key it might find.
# 6. rm the decoded key. Since we created it in the same RUN, it
# never gets committed to a layer.
#
# If the build aborts here:
# • "Permission denied (publickey)" → the key in Dokploy doesn't
# match the deploy key registered in Gitea. Re-base64-encode and
# re-paste, OR re-check the Gitea deploy key.
# • "Identity file /tmp/ssh_key not accessible: No such file or
# directory" → the secret wasn't mounted. Verify the Dokploy
# Build-time Secrets textarea contains exactly `ssh_key=...` as
# a single line.
# • "base64: invalid input" → the Dokploy textarea has trailing
# whitespace, line breaks inside the base64, or extra content.
# Re-paste from a fresh `base64 < key | tr -d '\n' | pbcopy`.
RUN --mount=type=secret,id=ssh_key \
mkdir -p -m 0700 ~/.ssh && \
ssh-keyscan -p 222 git.kadil.dev >> ~/.ssh/known_hosts 2>/dev/null && \
base64 -d /run/secrets/ssh_key > /tmp/ssh_key && \
chmod 0600 /tmp/ssh_key && \
GIT_SSH_COMMAND="ssh -i /tmp/ssh_key -o IdentitiesOnly=yes -o UserKnownHostsFile=/root/.ssh/known_hosts" \
npm install && \
rm -f /tmp/ssh_key
# Copy built client from builder COPY . .
COPY --from=client-builder /app/client/dist ./server/dist
# Create data directories RUN npm run build
RUN mkdir -p /app/server/uploads /app/server/exports
# Drop dev dependencies (eslint, etc.) so the node_modules we copy
# to runtime is production-only. Without this step, the runtime
# stage would either ship dev deps it doesn't use, or re-run
# `npm install --omit=dev` in an image without the compile
# toolchain — which would fail when canvas tries to rebuild.
RUN npm prune --production
# ─────────────────────────────────────────────────────────────────
# Stage 2: runtime
# Carries only the runtime C libraries canvas links against plus
# system fonts. node_modules is copied whole from the builder;
# no `npm install` happens here, so no SSH is needed at runtime.
# ─────────────────────────────────────────────────────────────────
FROM node:20-alpine
# Runtime packages:
#
# Native-binding runtime libs (matching the -dev headers in the
# builder stage):
# cairo, pango, libjpeg-turbo, giflib, librsvg, pixman
#
# System fonts for the export pipeline. node-canvas's text rendering
# uses fontconfig to resolve family names; these provide free
# equivalents of the proprietary template families (Impact, Times,
# Courier, Arial) plus emoji support for sticker exports. The
# editor module's own bundled fonts (under node_modules/goods-editor/
# fonts/) are registered explicitly via registerFont() — they don't
# go through fontconfig and don't need to live in /usr/share/fonts.
RUN apk add --no-cache \
cairo pango libjpeg-turbo giflib librsvg pixman \
ttf-liberation ttf-dejavu font-noto font-noto-emoji \
fontconfig
WORKDIR /app
# Bring the production node_modules over from the builder. This
# carries the already-compiled canvas .node binary AND the editor
# module's own fonts (under node_modules/goods-editor/fonts/), so
# no separate font-copy step is needed.
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package*.json ./
# Application files. server.js is the entry point; dist/ is the
# Vite build output served as static.
COPY server.js ./
COPY --from=builder /app/dist ./dist
RUN mkdir -p /app/uploads /app/exports
# Refresh fontconfig's cache so the apk-installed system fonts are
# discoverable. The module's own fonts under node_modules are
# registered explicitly by the module's server code (via
# registerFont() — see the host's server.js comment about font
# registration moving to the module), so they don't need to be
# in fontconfig's index.
RUN fc-cache -f || true
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3001/api/health || exit 1 CMD wget --no-verbose --tries=1 --spider http://localhost:3001/api/health || exit 1
EXPOSE 3001 EXPOSE 3001
ENV NODE_ENV=production
CMD ["node", "server/index.js"] CMD ["node", "server.js"]

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.

97
README.md Normal file
View File

@@ -0,0 +1,97 @@
# Apparel Designer
T-shirt customization editor with drag-and-drop design, background removal, and high-resolution export.
## Features
- **Canvas Editor** — React-Konva based drag/drop/resize/rotate for images and text
- **Background Removal** — Client-side AI using Transformers.js (RMBG-1.4, 8-bit quantized)
- **Photo Pre-Editor** — Filerobot integration for crop, filters, and adjustments
- **Stickers** — 140+ emoji stickers across 6 categories
- **Text Tool** — 20 Google Fonts, sizes, and colors
- **Templates** — 8 pre-designed templates across various categories
- **Undo/Redo** — Full history with keyboard shortcuts (Ctrl+Z / Ctrl+Shift+Z)
- **High-Res Export** — 4500×4500px PNG @ 300 DPI (15"×15" print size)
- **PWA Support** — Offline caching for models, fonts, and assets
## Tech Stack
| Layer | Choice |
|-------|--------|
| Frontend | React 19, Vite, react-konva 19, Konva 10 |
| Background Removal | @huggingface/transformers, RMBG-1.4 (q8) |
| Photo Editor | Filerobot Image Editor |
| PWA | Workbox via vite-plugin-pwa |
| Server | Express, Multer, Sharp |
| Export | node-canvas (4500×4500 server-side render) |
## Getting Started
```bash
# Install dependencies
npm install
# Optional but recommended for accurate exports: fetch the Google Fonts the
# editor uses, so node-canvas can render them server-side. Skipping this means
# exports fall back to whatever fonts the host system provides.
npm run fetch-fonts
# Start development (client on :3000, server on :3001)
npm run dev
# macOS Sharp fix is built into the dev script
# On Windows use:
npm run dev:win
```
## Docker
```bash
docker compose up --build
```
## Project Structure
```
apparel-designer/
├── server.js # Express API (upload, export, health)
├── vite.config.js # Vite + PWA config
├── package.json # Single package — all deps
├── index.html # Entry HTML with Google Fonts
├── scripts/
│ └── fetch-fonts.mjs # Downloads TTFs for server-side rendering
├── src/
│ ├── main.jsx # React entry + SW registration
│ ├── App.jsx # Root layout (sidebar / canvas / properties)
│ ├── App.css
│ ├── index.css # Design tokens + layout styles
│ ├── components/
│ │ ├── canvas/ # DesignCanvas, ImageElement, TextElement, TShirtSVG, TemplateLayer, SlotPlaceholder
│ │ ├── sidebar/ # Sidebar, UploadTab, StickersTab, TextTab, TemplatesTab, BackgroundRemovalButton
│ │ ├── panels/ # LayersPanel, PropertiesPanel
│ │ ├── editor/ # PhotoPreEditor (Filerobot wrapper)
│ │ ├── PWAInstall.jsx
│ │ └── OfflineIndicator.jsx
│ ├── hooks/ # useDesignEditor, useBackgroundRemoval, useExport, useTemplate
│ └── constants/ # fonts, stickers, templates
├── public/ # Favicon, PWA icons
├── fonts/ # Server-side TTFs (gitignored, populated by fetch-fonts)
├── uploads/ # User uploads (gitignored)
├── exports/ # Exported PNGs (gitignored)
├── docs/ # Bug log, suggestions, change notes, template schema
├── Dockerfile
└── docker-compose.yml
```
## API Endpoints
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/health` | Health check |
| POST | `/api/upload` | Upload image (20MB max, JPEG/PNG/WebP) |
| POST | `/api/export` | Export design as 4500×4500 PNG |
| GET | `/api/download/:filename` | Download exported file |
## License
MIT

24
client/.gitignore vendored
View File

@@ -1,24 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -1,16 +0,0 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.

View File

@@ -1,29 +0,0 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
},
])

View File

@@ -1,19 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Apparel Designer</title>
<!-- Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&family=Open+Sans:wght@400;600;700&family=Lato:wght@400;700&family=Montserrat:wght@400;600;700&family=Oswald:wght@400;500;700&family=Raleway:wght@400;600;700&family=Poppins:wght@400;500;600;700&family=Roboto+Condensed:wght@400;700&family=Source+Sans+3:wght@400;600;700&family=Roboto+Slab:wght@400;700&family=Merriweather:wght@400;700&family=Ubuntu:wght@400;500;700&family=Playfair+Display:wght@400;600;700&family=Nunito:wght@400;600;700&family=Rubik:wght@400;500;600;700&family=Work+Sans:wght@400;500;600;700&family=Lora:wght@400;500;600;700&family=Fira+Sans:wght@400;500;600;700&family=Barlow:wght@400;500;600;700&family=Bebas+Neue&display=swap" rel="stylesheet">
<!-- UI Fonts -->
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

2617
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,27 +0,0 @@
{
"name": "client",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.5",
"react-dom": "^19.2.5"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.5.0",
"vite": "^8.0.9"
}
}

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

View File

@@ -1,24 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 4.9 KiB

View File

@@ -1,184 +0,0 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}

View File

@@ -1,15 +0,0 @@
function App() {
return (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<h1>Apparel Designer</h1>
<p style={{ color: 'var(--text-secondary)' }}>
T-shirt customization editor
</p>
<div style={{ marginTop: '2rem', padding: '1rem', background: 'var(--bg-secondary)', borderRadius: 'var(--radius-md)' }}>
<p>Server Status: <code id="server-status">Checking...</code></p>
</div>
</div>
);
}
export default App;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -1,68 +0,0 @@
:root {
/* Colors */
--accent: #38bdf8;
--accent-hover: #0ea5e9;
--accent-bg: rgba(56, 189, 248, 0.1);
/* Neutrals */
--bg-primary: #ffffff;
--bg-secondary: #f8fafc;
--bg-tertiary: #f1f5f9;
--border: #e2e8f0;
--border-focus: #38bdf8;
--text-primary: #0f172a;
--text-secondary: #475569;
--text-muted: #94a3b8;
/* Status colors */
--success: #22c55e;
--warning: #f59e0b;
--error: #ef4444;
/* Border radii */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
/* Shadows */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
/* Typography */
--font-body: 'DM Sans', system-ui, -apple-system, sans-serif;
--font-mono: 'Space Mono', ui-monospace, Consolas, monospace;
font-family: var(--font-body);
line-height: 1.5;
font-weight: 400;
color: var(--text-primary);
background-color: var(--bg-primary);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
}
#root {
min-height: 100vh;
}
button {
font-family: inherit;
cursor: pointer;
}
input, textarea, select {
font-family: inherit;
}

View File

@@ -1,31 +0,0 @@
import { StrictMode, useEffect, useState } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
function AppWithHealth() {
const [serverStatus, setServerStatus] = useState('Checking...');
useEffect(() => {
fetch('/api/health')
.then(res => res.ok ? setServerStatus('Connected ✓') : setServerStatus('Error'))
.catch(() => setServerStatus('Offline'));
}, []);
return (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<h1>Apparel Designer</h1>
<p style={{ color: 'var(--text-secondary)' }}>
T-shirt customization editor
</p>
<div style={{ marginTop: '2rem', padding: '1rem', background: 'var(--bg-secondary)', borderRadius: 'var(--radius-md)' }}>
<p>Server Status: <code id="server-status">{serverStatus}</code></p>
</div>
</div>
);
}
createRoot(document.getElementById('root')).render(
<StrictMode>
<AppWithHealth />
</StrictMode>,
)

View File

@@ -1,16 +0,0 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true
}
}
}
})

View File

@@ -1,26 +1,56 @@
version: '3.8'
services: services:
apparel-designer: apparel-designer:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
# Pass a base64-encoded SSH private key in as a build secret so
# npm can authenticate against git.kadil.dev for the `goods-editor`
# git+ssh: dependency. The Dockerfile decodes the base64 inside
# the build. See Dockerfile docblock for the full mechanism and
# the rationale for base64 over raw PEM.
#
# The secret is only available during the single RUN step that
# mounts it; nothing about the key ends up in the built image.
secrets:
- ssh_key
container_name: apparel-designer container_name: apparel-designer
ports: ports: ["3001:3001"]
- "3001:3001"
volumes: volumes:
- uploads_data:/app/server/uploads - uploads_data:/app/uploads
- exports_data:/app/server/exports - exports_data:/app/exports
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- PORT=3001 - PORT=3001
restart: unless-stopped restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3001/api/health"] # Build-time secret declaration.
interval: 30s #
timeout: 10s # The Dockerfile expects a BASE64-ENCODED private key (not the raw
retries: 3 # PEM). See the Dockerfile docblock for the encoding command. Two
start_period: 10s # ways to provide it, pick whichever fits where you're building from:
#
# ── A. File-based (typical local dev) ──────────────────────────────
# Encode the key to a temp file once, then point SSH_KEY_FILE at it:
#
# base64 < ~/.ssh/goods-editor-deploy | tr -d '\n' > ~/.ssh/goods-editor-deploy.b64
# SSH_KEY_FILE=$HOME/.ssh/goods-editor-deploy.b64 docker compose build
#
# The fallback `/dev/null` keeps `docker compose config` from erroring
# when SSH_KEY_FILE isn't set; actual builds against /dev/null fail
# with a clear base64-decode error rather than a cryptic compose error.
#
# ── B. Env-var-based (CI) ──────────────────────────────────────────
# Comment out the `file:` line and uncomment `environment:` below.
# Provide the base64-encoded key in SSH_KEY_B64:
#
# SSH_KEY_B64="$(base64 < ~/.ssh/goods-editor-deploy | tr -d '\n')" \
# docker compose build
#
# Requires Docker Compose v2.23+ for the `environment:` secret source.
secrets:
ssh_key:
file: ${SSH_KEY_FILE:-/dev/null}
# environment: SSH_KEY_B64
volumes: volumes:
uploads_data: uploads_data:

126
docs/BUGS.md Normal file
View File

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

436
docs/CHANGES.md Normal file
View File

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

193
docs/SUGGESTIONS.md Normal file
View File

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

266
docs/template-schema.md Normal file
View File

@@ -0,0 +1,266 @@
# Template JSON Schema
This document describes the template structure for creating custom t-shirt design templates.
> **Note (2026-07-06):** this doc predates the goods-editor extraction and the
> v4 multi-view template schema. The authoritative element schema lives in
> `goods-editor-module/CLAUDE.md` ("Element schema") and the accepted payload
> shape in `template-manager/src/server/lib/validation.js`. The property
> tables below are still accurate for the fields they list.
## Template Structure
```json
{
"id": "string (unique identifier)",
"name": "string (display name)",
"category": "string (Sports|Music|Quotes|Animals|Abstract|Vintage|Nature|Tech)",
"description": "string (short description)",
"elements": [
{
"type": "text" | "image",
"text": "string (for text elements only)",
"x": "number (x position in canvas units)",
"y": "number (y position in canvas units)",
"fontSize": "number (for text elements)",
"fontFamily": "string (Google Font name)",
"fill": "string (hex color)",
"rotation": "number (degrees, -180 to 180)",
"width": "number (for image elements)",
"height": "number (for image elements)",
"src": "string (image URL or path)"
}
]
}
```
## Element Properties
### Text Element
| Property | Type | Required | Description |
|------------|--------|----------|---------------------------------------|
| `type` | string | Yes | Must be `"text"` |
| `text` | string | Yes | The text content to display |
| `x` | number | Yes | X position on canvas (0-300) |
| `y` | number | Yes | Y position on canvas (0-300) |
| `fontSize` | number | Yes | Font size in pixels (12-120) |
| `fontFamily`| string| No | Google Font name (default: "DM Sans") |
| `fill` | string | No | Hex color (default: "#0f172a") |
| `rotation` | number | No | Rotation in degrees (default: 0) |
| `arc` | number | No | Curve amount (default: 0 = flat). `> 0` arcs up (rainbow), `< 0` arcs down (smile). See "Curved text" below. |
| `align` | string | No | `left` \| `center` \| `right` (default: `center`; multi-line) |
| `lineHeight` | number | No | Line-box ratio of fontSize (default: 1.2) |
| `letterSpacing` | number | No | Absolute tracking in canvas units (default: 0; suspended while curved) |
#### Curved text (`arc`)
- The editor's Curve slider writes values in **−100..100**; hand-authored
templates should stay in that range.
- The stored value up to **±200** is honored by the renderer and the print
exporter (`ARC_MODEL_MAX` in the goods-editor module, 2026-07-06): the
editor itself *derives* values above 100 when preserving an authored
curve on long customer text whose font size auto-shrank. Don't hand-author
above ±100 — the headroom exists for derived values.
- What is visually preserved across customer edits is the curve's
**density** (`arc · fontSize / width`), not the raw number: the editor
rescales `arc` as text, font, or size changes so the perceived sweep the
template author designed stays constant. Expect saved customer designs to
carry arc values that differ from the authored one.
- `letterSpacing` has no effect while `|arc| > 0.5` (suspension rule); the
stored value survives and reapplies when the text goes flat.
### Image Element
| Property | Type | Required | Description |
|------------|--------|----------|---------------------------------------|
| `type` | string | Yes | Must be `"image"` |
| `src` | string | Yes | Image URL or server path |
| `x` | number | Yes | X position on canvas (0-300) |
| `y` | number | Yes | Y position on canvas (0-300) |
| `width` | number | Yes | Width in canvas units (min: 20) |
| `height` | number | Yes | Height in canvas units (min: 20) |
| `rotation` | number | No | Rotation in degrees (default: 0) |
## Advanced Template Features
### Template with Background and Overlay
For templates that include background images and overlay elements:
```json
{
"id": "team-sport",
"name": "Team Sport",
"category": "Sports",
"description": "Classic team jersey with number and text",
"background": {
"type": "color" | "image",
"color": "string (hex, if type is color)",
"src": "string (URL/path, if type is image)"
},
"overlay": [
{
"type": "text" | "image",
"nonPrintable": false,
"...": "same properties as regular elements"
}
],
"elements": [...]
}
```
### Slot-Based Templates
For templates with image slots (auto-crop regions):
```json
{
"id": "classic-tee-front",
"name": "Classic Tee - Front",
"slots": [
{
"id": "chest-logo",
"bounds": { "x": 100, "y": 80, "width": 100, "height": 100 },
"aspectRatio": 1.0,
"label": "Chest Logo"
},
{
"id": "sleeve-left",
"bounds": { "x": 20, "y": 100, "width": 60, "height": 60 },
"aspectRatio": 1.0,
"label": "Left Sleeve"
}
],
"elements": []
}
```
### Slot Crop Region
When an image is assigned to a slot, the crop property is applied:
```json
{
"type": "image",
"src": "/uploads/image.png",
"x": 100,
"y": 80,
"width": 100,
"height": 100,
"crop": {
"sx": 0,
"sy": 0,
"sWidth": 500,
"sHeight": 500
}
}
```
| Property | Type | Description |
|-----------|--------|--------------------------------------|
| `sx` | number | Source x coordinate for crop |
| `sy` | number | Source y coordinate for crop |
| `sWidth` | number | Source width for crop |
| `sHeight` | number | Source height for crop |
## Non-Printable Elements
Elements can be marked as non-printable to exclude them from export:
```json
{
"type": "text",
"text": "Guide Text",
"nonPrintable": true,
"x": 150,
"y": 150,
"fontSize": 12,
"fill": "#cccccc"
}
```
## Example Templates
### Minimal Quote Template
```json
{
"id": "minimal-quote",
"name": "Minimal Quote",
"category": "Quotes",
"description": "Simple centered quote design",
"elements": [
{
"type": "text",
"text": "\"Be the change\"",
"x": 150,
"y": 130,
"fontSize": 24,
"fontFamily": "Georgia",
"fill": "#1e293b",
"rotation": 0
},
{
"type": "text",
"text": "you wish to see",
"x": 150,
"y": 160,
"fontSize": 18,
"fontFamily": "Arial",
"fill": "#64748b",
"rotation": 0
}
]
}
```
### Team Sport Template
```json
{
"id": "team-sport",
"name": "Team Sport",
"category": "Sports",
"description": "Classic team jersey with number and text",
"elements": [
{
"type": "text",
"text": "TEAM NAME",
"x": 75,
"y": 80,
"fontSize": 28,
"fontFamily": "Impact",
"fill": "#ffffff",
"rotation": 0
},
{
"type": "text",
"text": "23",
"x": 150,
"y": 150,
"fontSize": 72,
"fontFamily": "Impact",
"fill": "#ffffff",
"rotation": 0
}
]
}
```
## Canvas Coordinate System
- Canvas size: 300x300 units (preview)
- Export size: 4500x4500 pixels (300 DPI, 15"x15")
- Scale factor: 15x (export / preview)
- Origin (0,0): Top-left corner
- X increases: Left to right
- Y increases: Top to bottom
## Best Practices
1. **Keep text readable**: Use font sizes between 18-72px for main text
2. **Respect print area**: Keep elements within 0-300 canvas bounds
3. **Use contrasting colors**: Ensure text is visible on shirt colors
4. **Test at export size**: Verify designs look good at 4500x4500px
5. **Limit elements**: 5-10 elements maximum for performance

104
eslint.config.js Normal file
View File

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

33
index.html Normal file
View File

@@ -0,0 +1,33 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#fdf2f4" />
<title>Pawfectly Yours — Customize Your Shirt</title>
<!--
Fonts are served by the goods-editor module from the host's fonts/
directory (populated by `npm run fetch-fonts` in goods-editor-module).
mountEditorApi synthesizes the @font-face rules at request time
based on whatever .ttf/.otf/.woff2/.woff files are present, so
adding a font is a "drop file + restart" workflow with no code
change here. See goods-editor-module/src/server/fontRegistry.js
for the generator.
We deliberately ship this synchronously in <head> rather than
letting the module inject a <link> at mount time: the module's
runtime injector races first paint, and the editor's font-
dependent text measurement (`measureTextWidth`) gives wrong
answers against fallback metrics until the fonts load. The
host pays a tiny extra TTFB to make first paint correct.
<ApparelDesigner loadFonts={false}> in App.jsx / CustomizeRoute
tells the module not to also inject its own runtime tag.
-->
<link rel="stylesheet" href="/api/fonts/fonts.css">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

10846
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +1,61 @@
{ {
"name": "apparel-designer", "name": "apparel-designer",
"version": "1.0.0", "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, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"postinstall": "cd client && npm install && cd ../server && npm install", "dev": "concurrently \"vite\" \"node --watch server.js\"",
"dev": "concurrently \"npm run dev:client\" \"npm run dev:server\"", "dev:win": "concurrently \"vite\" \"node --watch server.js\"",
"dev:client": "cd client && npm run dev", "build": "vite build",
"dev:server": "cd server && npm run dev", "start": "node server.js",
"build": "cd client && npm run build", "lint": "eslint .",
"start": "node server/index.js" "preview": "vite preview",
"test": "echo 'Editor tests live in the goods-editor module. Run them from there.' && exit 0"
},
"dependencies": {
"goods-editor": "file:../goods-editor-module",
"template-manager": "file:../template-manager",
"@khalidadil/auth-manager": "file:../auth-manager",
"@huggingface/transformers": "^3.4.0",
"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",
"pg": "^8.13.0",
"pino": "^9.5.0",
"pino-http": "^10.3.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-router-dom": "^7.1.0",
"react-filerobot-image-editor": "^4.8.1",
"react-konva": "^19.2.3",
"sharp": "^0.33.2",
"use-image": "^1.1.1",
"uuid": "^9.0.1"
}, },
"devDependencies": { "devDependencies": {
"concurrently": "^8.2.0" "@eslint/js": "^9.39.4",
"@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",
"vite": "^8.0.9",
"vite-plugin-pwa": "^1.3.0",
"workbox-window": "^7.4.1"
},
"engines": {
"node": ">=20.0.0"
},
"overrides": {
"serialize-javascript": "^7.0.3",
"vite-plugin-pwa": {
"vite": "$vite"
}
} }
} }

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"
}
}
}

4
public/favicon.svg Normal file
View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none">
<path d="M16 4L4 12L10 16L10 40C10 41.1 10.9 42 12 42H36C37.1 42 38 41.1 38 40V16L44 12L32 4C32 4 29.5 8 24 8C18.5 8 16 4 16 4Z" fill="#38bdf8" stroke="#0ea5e9" stroke-width="2" stroke-linejoin="round"/>
<path d="M16 4C16 4 18.5 8 24 8C29.5 8 32 4 32 4" fill="none" stroke="#0ea5e9" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 432 B

1
public/icons.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg"><!-- Project icon sprites --></svg>

After

Width:  |  Height:  |  Size: 76 B

1
public/pwa-192x192.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 192 192"><rect width="192" height="192" fill="#38bdf8"/><rect x="24" y="24" width="144" height="144" rx="24" fill="#ffffff"/><text x="96" y="120" font-size="72" text-anchor="middle" fill="#38bdf8" font-family="Arial, sans-serif">T</text></svg>

After

Width:  |  Height:  |  Size: 322 B

1
public/pwa-512x512.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><rect width="512" height="512" fill="#38bdf8"/><rect x="64" y="64" width="384" height="384" rx="64" fill="#ffffff"/><text x="256" y="310" font-size="192" text-anchor="middle" fill="#38bdf8" font-family="Arial, sans-serif">T</text></svg>

After

Width:  |  Height:  |  Size: 324 B

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

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