71 Commits
Author SHA1 Message Date
tiennm99 c978238f46 test: use the tiennm99.workers.dev origin in fixtures 2026-10-08 10:03:54 +07:00
tiennm99 8e0bddc83c build(deps): upgrade to Vitest 5 2026-10-03 13:20:00 +07:00
tiennm99 a461744dbf fix(deps): update dependencies to latest compatible versions
Rebased onto current main: the original commit only touched pnpm files and
versions main has since moved past, so it is replaced by a fresh npm update
within the declared ranges plus npm audit fix.
2026-10-03 12:41:57 +07:00
tiennm99 150917a69a build: move from pnpm to npm
Replace pnpm-lock.yaml with package-lock.json. pnpm-workspace.yaml's
allowBuilds becomes package.json#allowScripts, which npm 11 gates the same way,
listing only the packages actually present in the tree.
2026-08-17 13:07:36 +07:00
tiennm99 fdab00786e chore: remove dependabot version-update config 2026-07-25 14:15:39 +07:00
tiennm99 34e85622a6 chore(deps): bump dependencies to patched versions
Bump hono to 4.12.30, pixi.js to 8.19.0, @sveltejs/vite-plugin-svelte to
7.2.0, svelte to 5.56.6, vite to 8.1.5, vitest to 4.1.10, and wrangler to
4.112.0. Declare ws as a direct dev dependency at ^8.21.0 since the test
harness imports it, replacing the vulnerable transitive resolution.

Resolves advisories for hono (<4.12.25), vite (<=8.0.15), ws (<8.21.0),
undici (<7.28.0), and esbuild (<0.28.1) via updated transitive
resolutions. Drop the package-level pnpm.onlyBuiltDependencies list,
which duplicated the workspace allowBuilds approvals.
2026-07-17 17:56:43 +07:00
dependabot[bot] ecc5808457 chore(deps-dev): bump vite from 6.4.2 to 8.0.14 (#13) 2026-05-23 18:16:55 +07:00
dependabot[bot] 6f3094ab13 chore(deps-dev): bump @sveltejs/vite-plugin-svelte from 5.1.1 to 7.1.2 (#12) 2026-05-23 18:14:29 +07:00
dependabot[bot] 4b95fe2071 chore(deps): bump the minor-and-patch group across 1 directory with 4 updates (#11) 2026-05-23 18:12:47 +07:00
dependabot[bot] 113ff09813 chore(deps-dev): bump svelte from 5.55.5 to 5.55.7 (#9) 2026-05-23 18:09:09 +07:00
tiennm99 cc366e4377 chore: drop inapplicable dependabot ecosystems (#14) 2026-05-23 11:09:11 +07:00
tiennm99 a6a06e0dc3 chore: add dependabot config (#10) 2026-05-23 10:49:09 +07:00
tiennm99 09dfa4cb3f fix: allow esbuild/sharp/workerd build scripts for Cloudflare deploy
pnpm 11 blocks postinstall scripts by default; pnpm-workspace.yaml's
allowBuilds is required to permit workerd/esbuild/sharp native builds.
2026-05-13 15:47:55 +07:00
tiennm99 e3bcebad98 chore: migrate from npm to pnpm 2026-05-13 10:23:21 +07:00
tiennm99 bc26fb21d7 plans: drop reports directory
All reports referenced plans that have shipped (Upstash→DO migration +
post-migration P1 fixes). Git history retains content if ever needed.
2026-05-11 16:43:47 +07:00
tiennm99 7ef64ecd4f plans: drop implemented plan folders
Both plans shipped to production:
  - 260509-2309-canvas-on-do-storage (Upstash → DO migration, deployed
    2026-05-10, Upstash fully removed in a977adc).
  - 260510-0232-fix-do-migration-followups (post-migration P1 fixes,
    docs cleanup, full DO test coverage — all completed 2026-05-11).

Git history retains the full content if needed; plans/reports/ kept
as-is for the original research/review artifacts.
2026-05-11 16:40:04 +07:00
tiennm99 214a00f9d7 plan(do-migration-followups): mark all 5 phases completed
All P1 atomicity/identity/WS fixes shipped (commits 3c1263a, 42d1ca1,
4f02d30); docs scrubbed (9f50237); DO surface tests landed (5765055).
156 tests pass across 12 files in ~3.3s.
2026-05-11 16:18:07 +07:00
tiennm99 5765055588 test: add DO storage + integration coverage via wrangler unstable_dev
Pure-function unit tests for chunk-storage (BLOB-grow, orphan-row read,
multi-chunk grouping) and cooldown-store (TTL math, INSERT cursor drain,
GC sampling, release/refund) cover the bugs Phase 1 fixed.

Integration suite boots a local Worker + real DO via wrangler
unstable_dev and asserts cookie issuance, cooldown isolation across
identities, Content-Length 411/413 guards, WS upgrade semantics, WS
broadcast frame shape with monotonic seq, ping/pong, per-identity WS
cap, and Origin allowlist. WS uses the ws package directly since
unstable_dev's fetch strips CF's webSocket Response field.

156 tests pass in ~3.3s; verified stable across 3 consecutive runs.
2026-05-11 16:17:35 +07:00
tiennm99 9f50237a3c docs: purge remaining Upstash references and sync to DO storage
Drop /admin/migrate-from-upstash from public API docs, replace deprecated
UPSTASH_* env vars with ALLOWED_ORIGINS/ENVIRONMENT, update Worker
conventions to reflect DO bindings (env.CANVAS_ROOM) instead of Redis
client patterns, and rewrite system-architecture security/data-flow
sections to cover cookie+IP identity, transactionSync atomicity, WS
Origin allowlist, per-identity WS cap, and broadcast sequence numbers.
2026-05-11 15:59:50 +07:00
tiennm99 4f02d30393 feat(canvas): WS hardening, client race fix, and ping/pong heartbeat
Server:
- Origin allowlist on /api/ws (env.ALLOWED_ORIGINS, comma-separated; empty
  = allow all for dev/preview)
- per-identity WS connection cap (MAX_WS_PER_IDENTITY = 5) using
  acceptWebSocket(socket, [identity]) tagging; 6th upgrade returns 429
- ws.send 'ping' triggers a {type:'pong'} reply so dead connections fire
  onclose promptly instead of waiting on TCP keepalive

Client:
- buffer WS pixels arriving during the initial canvas fetch and replay
  them after committedColors is replaced; previously the post-fetch
  Uint8Array assignment silently overwrote any pixels broadcast in the
  fetch window (the documented C2 race)
- 30s ping / 60s pong watchdog closes the socket if pong stops arriving,
  routing through the existing exponential-backoff reconnect path

Tests:
- four /api/ws cases: missing upgrade, disallowed origin, allowed origin,
  empty allowlist (dev default). Sentinel uses status 200 because undici
  rejects 101 in Node-side Response constructors.

Plan: plans/260510-0232-fix-do-migration-followups/phase-03-ws-hardening-client-race.md
2026-05-10 03:05:56 +07:00
tiennm99 42d1ca19ee feat(canvas): cookie+IP rate-limit identity and broadcast sequence numbers
- resolveIdentity prefers an opaque rplace_id cookie; falls back to a
  cf-connecting-ip hash; in production a request with neither now returns
  500 no_identity instead of bucketing all such traffic together
- /api/canvas issues Set-Cookie when no cookie is present so subsequent
  requests escape NAT-shared IP buckets (mobile/CGNAT users)
- DO maintains an in-memory monotonic broadcast counter; broadcast frames
  carry { seq } so the client can detect missed pixels and refetch
- client tracks lastSeq, refetches on gap, resets on every (re)connect

NAT/CGNAT users previously shared a single 1Hz bucket per egress IP. With
cookie identity they each get their own bucket. Cookie is HttpOnly, Secure,
SameSite=Lax, 1y Max-Age. Stripped/cleared cookies fall through to IP.

The seq counter resets on DO hibernation rehydrate; client always refetches
on reconnect, so a reset is indistinguishable from a fresh connect.

Plan: plans/260510-0232-fix-do-migration-followups/phase-02-cookie-ip-identity.md
2026-05-10 03:00:39 +07:00
tiennm99 3c1263add6 fix(canvas): make multi-chunk pixel writes atomic and refund cooldown on failure
- wrap writePixels in state.storage.transactionSync so a partial multi-chunk
  failure doesn't leave the canvas half-written or out of sync with the WS
  broadcast
- size new chunk buffer against chunkSize(chunkId) instead of the persisted
  blob's length so writes after a canvas-grow no longer silently drop OOB
  bytes in the formerly-last short chunk
- refund the cooldown row when writePixels throws so transient storage
  errors stop soft-DOSing the user (and halving image-uploader throughput)
- bound readAllChunks by chunk_id < CHUNK_COUNT and trim oversized blobs so
  orphan rows from a future shrink no longer crash GET /api/canvas
- require a positive Content-Length on /api/place (411) and reject above the
  pre-parse cap (413); previously a missing or zero header bypassed the cap
- drop String(err) from the 500 response body
- drain the INSERT cursor symmetrically with the UPDATE branch in tryAcquire
- assert CHUNK_BYTES <= 2 MB at module load (DO SQLite per-cell BLOB cap)
- correct the inverted webSocketClose comment and guard the re-close call
- add tests for missing / zero / oversized Content-Length

Plan: plans/260510-0232-fix-do-migration-followups/phase-01-do-storage-atomicity.md
2026-05-10 02:57:30 +07:00
tiennm99 a977adc62d refactor(canvas): drop Upstash entirely after successful DO migration
Production migration ran cleanly (samples_checked: 9, mismatches: []),
canvas data preserved, drawing verified by user. Phase 4 cleanup:

Removed code:
- src/lib/canvas-storage.js (Upstash GETRANGE reader)
- src/lib/redis-client.js   (Upstash REST helpers)
- src/lib/rate-limiter.js   (orphan since Phase 2)
- src/admin/migrate-from-upstash.js (one-shot importer)
- src/durable-objects/canvas-room.js #handleImport route
- src/durable-objects/lib/chunk-storage.js importFullCanvas helper
- src/worker.js /admin/migrate-from-upstash mount + import

Removed tests:
- test/lib/canvas-storage.test.js
- test/lib/redis-client.test.js
- test/integration/redis-canvas-roundtrip.test.js
- test/durable-objects/canvas-room.test.js (was skipped pending rewrite)
- vitest.integration.config.js (only Redis testcontainers used it)

Removed deps:
- @upstash/redis, ioredis, testcontainers (-184 packages)

Removed constants:
- REDIS_KEY_PREFIX, REDIS_CANVAS_KEY (only used by deleted code)

Removed package.json scripts: test:integration, test:all
Removed CF Worker secrets in production:
  UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN, MIGRATION_TOKEN

Tests: 94/94 pass (down from 112 — 18 deleted Upstash-specific).
Bundle: 71.90 KiB (down from 79.43 KiB).
Production verified: canvas data intact (38% non-zero bytes), migration
endpoint returns 404, secret list empty.
v2.0.0-do
2026-05-10 02:05:43 +07:00
tiennm99 b890dfb3b7 fix(canvas): code-review fixes + sync user-facing docs to DO storage
Fixes from code review of canvas-on-do migration (commit c3f7c02):

- worker.js /api/ws: rewrite request URL to '/ws' so the DO pathname
  switch dispatches correctly. The original c.req.raw kept '/api/ws'
  which the DO never matched → 404 on every WS upgrade.
- migrate-from-upstash.js pickSampleOffsets: use TOTAL_PIXELS - 1 for the
  last byte instead of CANVAS_WIDTH * CANVAS_WIDTH (only correct when
  the canvas is square; constants explicitly invite non-square).
- chunk-storage.js writePixels: clarify atomicity comment — the loop is
  atomic *because it has no awaits*, not because of any implicit DO
  transaction. Added guidance for future maintainers.
- cooldown-store.js tryAcquire: GC sweep wrapped in try/catch so a
  transient failure can't drop the user's allowed: true response.

Docs:
- README.md: drop Upstash from tech stack, redraw architecture,
  document new project layout (durable-objects/lib, admin/), add
  CHUNK_BYTES to configuration table.
- docs/system-architecture.md: full rewrite for DO-storage data flow,
  document SQLite schema, race-safe rate-limit pattern, free-tier table.
- docs/deployment-guide.md: drop Upstash setup, add optional one-shot
  migration runbook, update free-tier table to actual May 2026 limits.

Tests: 112 pass, 6 skipped (pending Phase 4 rewrite via
@cloudflare/vitest-pool-workers). Bundle dry-run clean.

Local wrangler dev smoke test was attempted but the sandboxed env
hangs HTTP requests at the workerd layer (TCP connects, no response).
Routing fix verified by code inspection; user must verify in their
own dev or production.
2026-05-10 00:37:27 +07:00
tiennm99 cdf2295ef6 docs(canvas): correct CF DO limits in resize doc and plan
Earlier docs assumed a 1 GB per-DO storage cap. Actual May 2026 free-tier
limits per the CF DO docs:
- 10 GB per Durable Object
- 5 GB per account (the real Free-tier ceiling)
- 2 MB per BLOB row
- 1,000 req/sec soft cap per DO

Practical effect: canvas can grow to ~70,000 × 70,000 on Free (was previously
documented as ~32K × 32K). Existing 64 KB CHUNK_BYTES is well-suited:
clustered writes (single pixels + image patches) minimize read-modify-write
amplification; bigger chunks would only help uniform-random writes which
isn't rplace's workload.

No code change. Just doc accuracy.
2026-05-10 00:07:39 +07:00
tiennm99 c3f7c02f6d feat(canvas): migrate canvas + cooldown storage to DO SQLite
Move pixel state and rate-limit cooldowns out of Upstash Redis and into
the existing CanvasRoom Durable Object's SQLite-backed storage. Worker
becomes a thin validation/proxy; the DO does atomic cooldown check +
pixel write + WS broadcast in one in-memory step.

Why: eliminate external dependency, keep $0/month free-tier forever,
exploit single-threaded actor for strong consistency without round-trips.

Architecture:
- canvas_chunks: 256 BLOB rows × 64 KB; CHUNK_COUNT derived from
  CANVAS_WIDTH × CANVAS_HEIGHT / CHUNK_BYTES so resize is config-only.
- cooldowns: user_id → expires_at, 1% sample-rate lazy GC.
- Worker forwards /api/canvas, /api/place, /api/ws to DO endpoints.
- POST /admin/migrate-from-upstash: token-gated one-shot importer.

Phases 1-3 code complete; Upstash dependency stays until prod migration
runs and 7-day rollback window passes (Phase 4).

Plan: plans/260509-2309-canvas-on-do-storage/
2026-05-09 23:53:21 +07:00
dependabot[bot] e042748b41 chore(deps): bump @xmldom/xmldom from 0.8.12 to 0.8.13 (#6)
Bumps [@xmldom/xmldom](https://github.com/xmldom/xmldom) from 0.8.12 to 0.8.13.
- [Release notes](https://github.com/xmldom/xmldom/releases)
- [Changelog](https://github.com/xmldom/xmldom/blob/master/CHANGELOG.md)
- [Commits](https://github.com/xmldom/xmldom/compare/0.8.12...0.8.13)

---
updated-dependencies:
- dependency-name: "@xmldom/xmldom"
  dependency-version: 0.8.13
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
v1.0.0-upstash
2026-05-07 13:33:13 +07:00
tiennm99 1587fff72c fix(canvas): chunk GETRANGE to stay under Upstash 10MB response cap
Single GETRANGE of the 16 MiB canvas base64-encodes to ~22 MB, exceeding
Upstash's 10 MB per-request limit on the Free plan and causing 500s on
/api/canvas. Split into 4x4 MiB parallel ranges (~5.3 MB base64 each).
2026-04-18 21:09:07 +07:00
tiennm99 ce05024858 perf(canvas): port renderer to PixiJS (GPU pan/zoom) + cap DPR at 2
Replace Canvas2D render path with pixi.js v8. Canvas stays authored
as CPU RGBA on an OffscreenCanvas; pixi uses that as a texture source
and uploads on demand. Pan/zoom become pure GPU transforms on a
Container — no CPU work per frame. Overlay is a second sprite in the
same container so it tracks pan/zoom for free.

Key points:
- app.ticker.stop() + manual app.renderer.render() on rAF: idle frames
  cost 0, active frames cost one GPU draw.
- mainTexture.source.update() called only when imageDataDirty (same
  gating as before). Writes stay cheap.
- resolution: Math.min(devicePixelRatio, 2). iPhone 3x → 2x = 44%
  fewer fragments; pixel art gains nothing from 3x.
- Public API preserved: applyUpdates, undo/redo, setOverlay, panBy,
  gotoPoint, getCommittedColor, getPendingPixels, commitPending,
  refetchCanvas, cancelStrokeIfAny all unchanged.
- Overlay sprite rebuilt on setOverlay() with destroy({texture,
  textureSource}) to avoid GPU leaks.

Bundle: +200KB main, +~100KB pixi chunks, ~70KB gz total. Tradeoff
accepted for mobile perf parity with wplace.
2026-04-18 17:51:53 +07:00
tiennm99 088ed291a8 perf(canvas): rAF-coalesced render, dirty imageData, viewport-bounded zoom/pan
Mobile lag root cause: every pan/zoom tick ran putImageData(imageData, 0, 0),
a ~67MB copy into the offscreen. On a phone, that dominates the frame budget.

- CanvasRenderer: split render() into a deferred scheduler + doRender().
  requestAnimationFrame coalesces redundant calls within one frame. An
  imageDataDirty flag gates putImageData — only re-upload when pixels
  actually changed (setPixelRgba / loadCanvas). Pan+zoom renders are now
  pure compositor work.
- Zoom clamp: minZoom = min(viewportW/CW, viewportH/CH). Computed in
  App.svelte from a resize-tracked viewport, passed to CanvasRenderer
  (wheel + pinch use it) and CanvasControls (zoom-out button disables at
  bound). Replaces the hardcoded 0.25 that let phones see empty void.
- Pan clamp: clampPan() runs before every paint. When canvas >= viewport,
  canvas always covers the screen. When canvas < viewport (never happens
  at minZoom, but safe), canvas stays inside.
2026-04-18 17:10:56 +07:00
tiennm99 6bce69adc8 feat(ui): eyedropper, shortcut help overlay, WS indicator, first-visit hint
Ship four UX gaps identified from wplace comparison + direct review.

- Eyedropper: new 'eyedrop' mode button in DrawToolbar, sampled on
  mouseUp-without-drag. Alt+click samples in any mode (Photoshop
  convention). 'I' key toggles the mode; after sampling, mode snaps
  back to 'paint'.
- HelpOverlay.svelte: modal listing all keybindings. Triggered by '?'
  / '/' keys, a '?' button in CanvasControls, or Esc to close.
- Connection indicator: WS readyState reflected as a colored dot in
  CanvasControls (green=open, amber pulse=connecting/reconnecting,
  red=closed). No more silent disconnects.
- First-visit hint: one-line banner explaining paint→submit flow and
  core shortcuts. localStorage-gated (rplace:hint:v1), auto-dismisses
  on first successful commit or explicit X.
2026-04-18 15:31:36 +07:00
tiennm99 6b790f3892 chore: prune dead code, unused exports, and orphan scripts
- scripts/: remove upload-colors.js (broken — imported removed constants)
  and image-to-colors.js (orphan — flow moved fully client-side to
  ImageImporter). Drop sharp from devDependencies.
- constants.js: drop unused BITS_PER_PIXEL export.
- dither-kernels.js: make KERNEL_* consts internal; only the
  ERROR_DIFFUSION_KERNELS map is consumed externally.
- image-pipeline.js: drop unused pipeline.source() accessor.
- image-to-palette.js: drop `options.dither` legacy boolean shim
  (no external caller uses it) and its test.
- canvas-storage.js, App.svelte: drop comments that restated names.
2026-04-18 15:01:49 +07:00
tiennm99 8c6b9aa191 perf(importer): offload pipeline to worker, stage cache, quick-preview tier
Replaces the synchronous reactive pipeline with a debounced worker RPC.
Slider drags and file loads no longer block the main thread.

- image-pipeline.js: pure staged pipeline (transform → resize →
  color-correction → quantize). Each upstream stage keeps one cached
  slot keyed on the inputs it depends on, so a single-slider change
  reruns only the downstream stages.
- image-pipeline-worker.js: hosts the pipeline inside a module Worker.
  Accepts set-source (transferable) and run(params). Returns indices
  + preview RGBA as transferable ArrayBuffers.
- image-pipeline-client.js: main-thread RPC with request-id tracking
  and stale-response dropping.
- ImageImporter.svelte: two-tier scheduler — throttled quick request
  (<=384px) for the preview panel while inputs churn, debounced full
  request (180ms) for overlay/buildPixels/opaque-count on settle.
  Source buffer cloned and transferred to the worker on load/resume.

Main bundle shrinks ~4KB (pipeline code now in its own worker chunk).
2026-04-18 14:51:48 +07:00
tiennm99 d6503676c6 perf(importer): O(1) palette lookup via 5-bit LUT, auto-fit on load
Resolves browser freeze when importing large images.

- image-to-palette.js: add lazy 32^3 Uint8Array LUT (32 KB) for
  nearest-color lookup. Replaces 256-iteration linear scan per pixel
  in quantizeNearest, error-diffusion, and ordered-dither paths.
- ImageImporter.svelte: cap initial resizeW/resizeH to canvas bounds
  so a full-res photo does not run the pipeline at source size before
  the user can touch Fit.
2026-04-18 14:30:47 +07:00
tiennm99 cfbac2a586 feat(canvas): 4096^2 canvas, 256-color palette (u8 byte-aligned), custom picker (#5)
Canvas:
- CANVAS_W/H = 4096, total 16,777,216 pixels
- BITS_PER_PIXEL = 8 (byte-aligned) — raw Redis bytes are palette indices
- Canvas-decoder becomes an identity wrap/copy
- Storage BITFIELD uses u8; offset = y*W + x
- Redis key versioned to rplace:canvas:v2 so old 32-color/2048^2 data is
  orphaned (operators can DEL the old key to reclaim memory)

Palette:
- 256 entries, generated deterministically:
  - 0..15  = 16-step grayscale ramp (pure black -> pure white)
  - 16..255 = 240 HSL wheel (4 lightness rings x 60 hues @ 82% saturation)
- nearestPaletteIndex(r,g,b) helper for custom-color snapping

UI:
- ColorPicker: 16-swatch favorites strip (grays + 8 accents) + current-color
  swatch + expand toggle for the full 16x16 grid + "Custom..." button that
  opens the native <input type="color"> and snaps to nearest palette entry
- Default selected color bumped to index 0 (black)

Tests + docs:
- canvas-decoder tests rewritten for identity u8 decode
- canvas-storage tests updated for u8 offsets
- image-to-palette tests anchored to PALETTE_BLACK=0 / PALETTE_WHITE=15 and
  COLORS_RGBA[i] probes (no more hardcoded old 32-color indices)
- integration test uses u8 BITFIELD and canvas-aware bounds
- README, system-architecture, deployment-guide updated (storage math,
  migration note for orphaned old key)
2026-04-18 13:47:01 +07:00
tiennm99 0bf51a410e feat(ui): pick-position, cooldown, goto, shortcuts, progressive skip, resume (#4)
* feat(ui): pick-position, cooldown badge, goto, shortcuts, progressive skip, resume

- Pick-position mode: "Pick on canvas" replaces the broken "Use cursor"
  button. Next left-click (no drag) sets origin; Esc or second press cancels.
- Visible cooldown on Submit: disables button + shows "Wait Xs" countdown
  after a successful place or 429 response.
- Goto-coordinates: "x,y" input in CanvasControls; Enter centers the
  viewport on that canvas point.
- Keyboard shortcuts: Q/E zoom, WASD pan (40px), Esc cancels pick or stroke.
  Guarded against firing in focused inputs.
- Progressive skip-already-matching: uploader re-filters remaining queue
  every 8 batches via an optional shouldSkip predicate.
- Auto-save + resume: new image-job-storage module (localStorage + PNG
  dataURL); uploader emits onCheckpoint after each batch; importer shows
  a Resume/Discard banner when an unfinished job is found on mount.

* docs(reports): wplace-autobot logic + wplace.live design research
2026-04-18 12:48:16 +07:00
tiennm99 f59e55a852 refactor(rate-limit): 1 req/sec cooldown, batch up to 2048 (#3)
* refactor(rate-limit): switch to 1 req/sec cooldown, batch size up to 2048

Replace per-pixel credit/token-bucket model with a simple per-user cooldown
(SET NX EX 1). Batch size is now independent of the rate limit and capped
at MAX_BATCH_SIZE = 2048.

- rate-limiter: SET NX EX replaces Lua credit script
- worker: response shape { ok: true } (no credits field)
- client: drop credit state/timer/UserInfo; uploader paces by cooldown
- tests: mock checkRateLimit; integration test exercises SET NX EX
- docs: README, system-architecture, code-standards, deployment-guide

* chore(plans): remove implemented plan directories

rplace-implementation (base build), review-fixes, and
image-importer-enhancements are all shipped. Keep plans/reports/ as
historical code-review and research references.
2026-04-18 10:19:00 +07:00
tiennm99 2d61225ee4 fix(ui): resolve overlapping panels and buttons
- lift DrawToolbar above ColorPicker (bottom 80 → 188)
- move Import button below CanvasControls (top 12 → 68)
- hide Import button while panel open; panel has its own close
2026-04-17 13:14:07 +07:00
tiennm99 d924a66c50 feat(importer): phase 6 — skip-white + paint-transparent toggles
Two quick quality-of-life options for logo and background-heavy imports:

- Skip-white: near-white pixels (r,g,b >= threshold, default 230) are
  marked transparent in the output so they don't consume credits. Useful
  when the source has a white background you don't want to paint.
- Paint-transparent: fully-transparent source pixels are treated as opaque
  white before quantization. Useful if the canvas background is white and
  you want empty regions of a logo to blend in.

Both toggles compose with every existing dither method; paint-transparent
runs first, so combining the two leaves originally-transparent pixels as
skip (since they're synthesized white, then the skip-white pass drops
them).

UI adds the two checkboxes and a threshold slider shown only when
skip-white is on. CLI gains --skip-white, --white-threshold,
--paint-transparent. 4 new unit tests pin the behavior including the
paint+skip interaction.
2026-04-17 11:58:55 +07:00
tiennm99 6c35771445 feat(importer): phase 5 — color correction sliders
Brightness, contrast, saturation, and gamma adjustments before palette
quantization. Pure-function applyColorCorrection in src/lib keeps the CLI
and browser importer in sync. Inserted post-resize so slider moves stay
responsive on large sources; precision loss is negligible through a
32-color palette.

UI is a collapsible "Color correction" section with four sliders, live
values, a "Reset" button, and an "on" badge when any value differs from
default. CLI gains --brightness, --contrast, --saturation, --gamma
(all -100..+100 except gamma which is 0.1..3.0).

9 unit tests pin down identity, saturate-to-0-or-255 clamping, greyscale
at saturation=-100, gamma midtone direction, and alpha preservation.
2026-04-17 11:56:35 +07:00
tiennm99 4a5b6ef903 feat(importer): phase 4 — more dithering algorithms
Replace the single Floyd-Steinberg toggle with a method dropdown offering
none, floyd, atkinson, jarvis, burkes, sierra, sierra-lite (error diffusion)
and bayer-2 / bayer-4 / bayer-8 (ordered). Kernels live in
src/lib/dither-kernels.js as plain data; rgbaToPalette dispatches via a
shared runErrorDiffusion runner and a runOrderedDither for Bayer.

The legacy `dither: true` option keeps working (maps to `method: 'floyd'`),
so existing CLI invocations and tests stay green. CLI gains
`--dither-method <name>` which takes precedence over `--dither`.

13 unit tests cover exact-palette hits, transparent pixels, all-method
smoke, kernel weight sums (Atkinson=0.75, others=1.0), and verify Bayer
actually scatters on mid-grey where plain nearest would produce one color.
2026-04-17 11:53:52 +07:00
tiennm99 fc0121fd48 feat(importer): phase 3 — flip / rotate transforms
Pure-function transformRgba in src/lib/image-transform.js supports flip H,
flip V, and rotation in 90° steps (fixed application order: rotate → flipH
→ flipV). Wired into the importer pipeline before resize with buttons for
each op; ±90° rotations auto-swap the resize dims so output aspect tracks
the rotation. CLI gains --flip-h, --flip-v, --rotate flags using the same
shared module.

8 new unit tests cover identity, each flip, 90/180/270 rotation, the
flipH+flipV ≡ 180° identity, and invalid rotation rejection.
2026-04-17 11:49:54 +07:00
tiennm99 75c1023554 feat(importer): phase 2 — overlay preview on main canvas
CanvasRenderer.setOverlay draws the palette-converted import preview on top
of committed + pending layers at (x, y) with a configurable alpha. Image
importer gains an "Overlay" toggle and opacity slider; the overlay
reactively follows resize/dither/origin changes and clears on panel close,
toggle-off, or image swap. Lets the user align the upload before spending
credits.
2026-04-17 11:45:16 +07:00
tiennm99 f94f1dc5f5 feat(importer): phase 1 — resize controls with multiple methods
Add W/H inputs, aspect-ratio lock, resampling method dropdown (nearest /
bilinear / box), "Fit to canvas" (respects current origin) and "1:1" reset
buttons to the import panel. Pipeline now runs resize before palette
quantization so dither/skip-matching operate on the final dimensions.

Shared src/lib/image-resize.js is reused by scripts/image-to-colors.js,
which gains --width, --height, --method flags with aspect-preserve when
only one dimension is provided.
2026-04-17 11:43:11 +07:00
tiennm99 9254fec8c9 docs(plans): add image importer enhancements plan
Six phases spanning resize, on-canvas overlay preview, transforms, more
dithering algorithms, color correction, and skip-white/paint-transparent
toggles. Based on WPlace-AutoBOT's image-processor.js feature set, scoped
to what fits our 32-color canvas.
2026-04-17 11:43:04 +07:00
tiennm99 4a149c38eb feat(client): add image importer panel with dither toggle
New floating "Import Image" panel decodes PNG/JPG/WebP in the browser, shows
a palette-matched preview, and uploads pixels at a user-chosen origin with
pause/resume/cancel and live progress. Options:

- Skip-matching: compare against CanvasRenderer.getCommittedColor so pixels
  already correct don't waste credits.
- Dither (Floyd-Steinberg): toggle between nearest-color and error-diffusion
  quantization; reactively re-quantizes the cached source on change.

Uploader (src/lib/image-uploader.js) tracks credits locally with regen and
backs off on 429 responses so it can run alongside manual drawing.
2026-04-17 11:25:16 +07:00
tiennm99 1565776f02 feat(scripts): add image-to-colors + upload-colors CLI scripts
Convert PNG/JPG/WebP to rplace palette JSON (nearest-color or Floyd-Steinberg
dither) and upload at a target (x, y) with client-side credit throttling and
429 backoff. Shared palette conversion lives in src/lib/image-to-palette.js so
the browser importer can reuse it.
2026-04-17 11:25:06 +07:00
tiennm99 a0c32b18ee fix: serve /api/canvas raw, drop manual gzip + add error envelope
Manual CompressionStream + Content-Encoding: gzip caused the browser
to receive un-decoded gzipped bytes through the wrangler-dev + vite
proxy path, surfacing as "Canvas buffer truncated: got 3598 bytes"
in the new decoder bounds check.

Cloudflare's edge auto-compresses compressible content already, so the
manual gzip layer was redundant in prod and broken in dev.

Also wraps getFullCanvas in try/catch and returns a JSON error envelope
on failure instead of letting Hono's default handler return a 500 with
text body the client decoder would mis-parse.
2026-04-17 10:31:05 +07:00
tiennm99 ad8d2a6f71 docs: add ultrareview reports and fix-sweep plan
Three review reports (backend, frontend, security/scale) and a
follow-up re-review after pull. Plan documents which findings were
addressed in this sweep and which were intentionally deferred.
2026-04-17 10:14:45 +07:00
tiennm99 a823f8527d fix: address ultrareview findings across backend and frontend
Backend:
- rate-limiter: retryAfter now in seconds; ms-precision lu preserves
  fractional regen residue across calls (C1, C2)
- redis-client: throw on Upstash 200-with-error envelope; redisRaw
  returns body.result (NH1)
- constants: MAX_BATCH_SIZE = MAX_CREDITS = 256 (was 512 vs 256)
- worker: content-length cap, gzip + s-maxage=10 on /api/canvas,
  broadcast via executionCtx.waitUntil with r.ok check (NC2, H4, H5)
- canvas-storage: warn on truncated Upstash read instead of silent
  zero-pad (NH2)
- get-user-id: SHA-256 (16 hex chars) replaces 32-bit string hash;
  missing cf-connecting-ip routes to anon:dev with warn (H1, H2);
  function is now async
- canvas-room: log unclean WS closes and errors; defensive close on
  unexpected client message (NH4, N5)

Frontend:
- pixel buffer capped at MAX_BATCH_SIZE with toast (NC2)
- Submit error UX: toast for 429/413/400/5xx/network; honor
  retryAfter (NC1)
- committedColors allocated upfront so WS updates during initial
  fetch no longer null-deref (NC3)
- handleWheel always renders even when zoom is clamped (C1, C2)
- canvas-decoder throws on truncated input instead of reading past
  end with || 0 (C3)
- WS reconnect refetches canvas to recover missed pixels (C4)
- pixel-buffer Map cache for O(1) getColorAt/pixelCount (NH1)
- cancel in-progress stroke on mode switch (NH3)
- canvas load error overlay with Retry button (NH5)
- DPR-aware canvas sizing (H1)
- onMount cleanup is now sync (no leaked resize listener) (H2)

Tests:
- update for async getUserId, decoder bounds check, redis error
  format; add Upstash error-envelope coverage
2026-04-17 10:14:45 +07:00