pgenのcomposerズームを7回失敗してまるごと削除した後、リサーチと過去コミット考古学から再設計した話
概要
pgen(Takazudo個人OSSのパターン生成 + composer)のズーム・パン機能を、7回の実装失敗を経て一度まるごと削除した。+317 / −4717 linesのnet deletionで、fit-on-mount onlyの状態まで巻き戻した。
そこから「8回目の攻めをする前にリサーチと過去コミット考古学をやろう」と腰を据えて、camera modelベースの再設計プランを立てるところまでが今回の話。Claude Codeとの会話をそのまま残す。
リサーチを先にやったら、2026-04-18の自分が既に正解アーキテクチャを出荷済みだった、という気づきが一番重かった。途中で「もっとうまくやれるはず」とtransform: scaleに舵を切った判断が迷路の入り口で、そこから7回迷走した。今回は「正解を持ってたのに捨てた」ことを認めた上で、追加の洞察を入れて再スタートする。おまけに、今回決めた4つのルールをproject-scope skillに畳んで、9回目の同じ失敗を自動でブロックするところまでをプランに含めた。
背景
- pgenのcomposerには、背景パターンの上にtext/image/componentレイヤを重ねて配置するフルスクリーンエディタがある。
- ズーム・パン機能は何度も作り直しているが、最新の試み(PR #805)で完全に詰まり、ブラーとペンツールのドリフトが復活した。
- 8回目の攻めを始める前に、issue #806 → #807 → PR #812 でcomposerのviewport zoomをまるごとripout。fit-on-mount onlyに戻した。
- この会話はripoutが終わった直後、「じゃあ次どうするか」の入り口から始まる。
会話
me:
looks the issue #806 is done correctly. removed zoom perfectly. that’s all?
Claude Code:
Good question. Let me do the repository archaeology before answering.
Looking at the git history and PR #812 more carefully, the ripout was not just a removal. Three things landed:
- Regression fix for #806. The pan tool’s auto-scroll behavior had broken as a side effect of the last zoom attempt. Ripping out the zoom/pan world-model took that regression out too.
- ~4400 line cleanup. The
+317 / −4717net deletion stripped a lot of dead code: the viewport transform layer, Fit button state machine, pan/zoom gesture handlers, and theCanvasSpace/WorldSpaceconversion helpers that only existed to serve the wrapper-scale approach. - Clean slate for attempt #8. With fit-on-mount only, the composer is now in a state where zoom can be re-introduced without unwinding the failed world-model first.
So “removed zoom perfectly” is accurate, but it’s also the first step. The composer is now fit-on-mount only. If you want zoom + pan back, it has to be rebuilt from scratch.
me:
ok. I want zoom + pan tool again. but I think the reason we go failing because we used transform related things. I’m not really sure why, but we tried 7 times I think. Let’s research how other projects are handling this kind of zoom/pan on canvas + doing crisp rendering. and let’s plan well. this time I want to really plan before writing code.
Claude Code:
Agreed. Let me run two parallel research passes and one archaeology pass before I draft anything.
/codex-research— how tldraw / Excalidraw / Fabric.js / Konva / Miro / Figma do canvas zoom + crisp rendering. Primary sources only./gcoc-research— what exactly causes blur when you use CSStransform: scale()on a canvas? Compositor / rasterization / subpixel AA level.- git archaeology on our own history from #678 → #805 — when did the approach drift, and why.
I’ll come back with all three.
(research runs in parallel — logs saved to $HOME/cclogs/pgen/20260422_044223-codex-research-zoom-pan-architecture.md and $HOME/cclogs/pgen/20260422_044223-gcoc-research-css-transform-blur.md)
Claude Code:
Results.
Codex (other projects). Every serious canvas editor does the same thing:
- Keep a camera object (
{ x, y, zoom }) in state. - Store all layer data in world coordinates — never in screen coordinates.
- At render time, resize the canvas backing store to
cssSize × devicePixelRatio × zoom, thenctx.setTransform(zoom * dpr, 0, 0, zoom * dpr, -camera.x, -camera.y)and re-rasterize everything. - Do NOT
transform: scale()a pre-rendered canvas. tldraw, Excalidraw, Konva, Fabric — every one of them explicitly warns against this. Figma does its own GPU path but the principle is the same: scale in the draw call, not in CSS.
Pan = updating camera.x / camera.y and re-rendering. Input (pointer events) is mapped from screen → world at the event boundary, once, via (screenX - rect.left) / zoom + camera.x.
Gcoc (why transform: scale() blurs). The short version:
- The browser rasterizes the canvas element at its CSS size into a compositor layer (a GPU texture).
transform: scale()on that layer asks the compositor to upscale the texture via bilinear sampling at paint time. There is no re-rasterization. That is the blur.- Making the canvas backing store larger does nothing, because what the compositor uploads to GPU is the CSS-sized rasterized layer, not the backing store.
- Chrome also downgrades subpixel antialiasing to grayscale AA on any layer with a non-integer transform. So text and thin lines go softer the moment you cross
scale(1.0).
Archaeology. I walked git log from #678 forward.
- #678 / PR #684 (2026-04-18). Already shipped the correct architecture. CSS size scales with zoom, backing store =
cssSize × zoom × dpr,ctx.setTransform(zoom × dpr). Crisp at every zoom level. - #756, #792. Small bugs — zoom center drifted, Fit button didn’t reset correctly. Fixable inside the #678 model.
- #798 / PR #805 (2026-04-20). Here’s the turn. Instead of fixing the center math, we rewrote to a “world-model with
transform: scaleon the wrapper”. That’s the move that brought the blur back. Pen tool drift followed, because pointer coords were now measured through a CSS transform. - #806 → #807 → #812 (2026-04-21). Ripout.
So we had the answer on April 18. We threw it away on April 20.
My draft proposal for attempt #8 is: go back to the #678 model (camera + resize + setTransform + re-rasterize), and this time do not touch transform: scale on anything in the canvas ancestor chain. Ever.
me:
hum… wait. you’re saying “re-render at zoom × dpr so it stays crisp”. but think about the Satori components, and the background pattern. if I zoom in 4x, we can’t really re-render the pattern at 4x — it would generate fake detail that isn’t in the truth. same for Satori: the text is laid out at the logical size, re-rendering it at 4x gives sharper glyph edges but the layout is a lie. I feel like the pattern and the component layers should just… be the truth, and blur when you zoom in. that’s honest. but! the pen tool — the helper guides, the anchor points, the selection outlines — those should be crisp. those are UI chrome, not content.
Claude Code:
Good. That’s a real distinction and I had it wrong. Let me re-design.
Two canvases, stacked:
- Art canvas. Backing store at world resolution only. This holds the background pattern, rendered Satori components, image layers — the truth. CSS size scales with zoom, so when you zoom in 4x, the browser upscales this canvas and it gets appropriately blurry. That blur is correct. It tells the user “you are zoomed in, this is the real pixel density of the content”.
- Overlay canvas. Backing store at
cssSize × zoom × dpr. This holds everything that is UI chrome: pen tool paths in progress, anchor handles, selection outlines, snap guides, rulers. Re-rasterized on every zoom change. Always crisp.
Both canvases share the same CSS size (they scale together with the zoom), stacked with position: absolute; inset: 0 inside a common scroll container. Pan = scrollLeft / scrollTop on the container. Zoom = update camera.zoom, resize the overlay’s backing store, re-render both.
Art canvas stays honest. Overlay stays crisp. No transform: scale anywhere.
me:
yes that’s what I mean. ok one more thing. last time the pen tool drifted — the click landed off from where I pointed. what was actually wrong there?
Claude Code:
Pointer events report coordinates in screen space. The canvas, visually, was scaled by a CSS transform on an ancestor. getBoundingClientRect() returns values that already include the ancestor’s transform, so (event.clientX - rect.left) “almost” gives you the right CSS-space coordinate — but:
- The rect is in fractional CSS pixels, and the event coords are rounded.
- Any scroll offset, any margin on ancestors, any nested transform adds another term.
- Small rounding errors compound by the zoom factor.
Over 7 attempts we kept patching the math to fix one case and breaking another. The real fix is to not have a CSS transform between the pointer and the canvas at all. Then (event.clientX - canvasRect.left) / zoom + scrollLeft/zoom is just “subtract the rect origin, divide by zoom, add the scroll offset”. One line. No rounding maze.
Canonical form for pointer-anchored zoom (zoom toward the cursor):
// On wheel / pinch event, with event.clientX/Y
const rect = container.getBoundingClientRect();
const cssX = event.clientX - rect.left;
const cssY = event.clientY - rect.top;
const worldX = (container.scrollLeft + cssX) / currentZoom;
const worldY = (container.scrollTop + cssY) / currentZoom;
const nextZoom = clamp(currentZoom * zoomDelta, minZoom, maxZoom);
// Resize canvases, re-render, THEN set scroll
const newScrollLeft = worldX * nextZoom - cssX;
const newScrollTop = worldY * nextZoom - cssY;
container.scrollLeft = newScrollLeft;
container.scrollTop = newScrollTop;
This is the same formula tldraw and Excalidraw use. scrollLeft / scrollTop stay out of React state — they’re DOM properties read/written directly. Only zoom is in React state, and only because we need a re-render to resize the overlay backing store.
me:
ok. also last time we had a Fit button and it misbehaved. is there anything special about Fit?
Claude Code:
Fit is just “compute a zoom that makes the content bounding box fit inside the viewport, and center the scroll on the content”. No special case, no separate code path. If zoomAroundPointer() is the only place that changes zoom + scroll, Fit reduces to:
function fit() {
const z =
Math.min(viewport.width / content.width, viewport.height / content.height) *
0.95; // small margin
zoomAroundPointer({
nextZoom: z,
anchorCssX: viewport.width / 2,
anchorCssY: viewport.height / 2,
worldX: content.cx,
worldY: content.cy,
});
}
The reason the old Fit misbehaved is that it had its own math. Any time a zoom operation has its own math, it drifts.
me:
good. ok one more. we failed 7 times. what stops us from failing an 8th?
Claude Code:
Four rules. First three are merge-blockers, fourth is a design invariant.
Rule 1 — transform: scale is banned.
No transform: scale(...) on any ancestor of either canvas. Add a b4push grep check: if the scan finds a transform:\s*scale (or scale( in inline style) in the composer render tree, fail the push. Zero exceptions. The April 20 drift happened because nothing was watching for this.
Rule 2 — real-Chrome verification is the merge gate. Playwright’s green check lied to us 7 times. It rendered the DOM but did not catch the blur, because the blur lives in the compositor layer and Playwright’s default pixel compare has enough tolerance to miss it. The merge gate is: open real Chrome in incognito at the preview URL, zoom to 2x / 4x, verify no blur on the overlay canvas, verify pen tool lands under the cursor. Takes two minutes. Playwright keeps running, but it does not gate merge.
Rule 3 — POC spike before production code.
Build the two-canvas-stack + zoomAroundPointer() loop as a throwaway route (/pgen/zoom-poc or similar) first. Prove crisp + no drift at 0.1x / 1x / 4x. Then port into the composer. Do not refactor the composer and discover the math is wrong.
Rule 4 — zoomAroundPointer() is the only zoom function.
One function. Wheel, pinch, Fit, zoom buttons, keyboard shortcut — all of them compute (nextZoom, anchorCssX, anchorCssY, worldX, worldY) and call it. If zoom math appears anywhere else, that’s the regression.
me:
ok go /big-plan then /zpaper-articlify —conversation
me: (interrupting the big-plan kickoff)
maybe we need to make skill to note for the dev info /l-dev-notes-zoom-tool for later info for new tool adding. we’ll fail then if we don’t know these facts. add this to the plan
me:
as project scope skill /skill-creator
Claude Code:
Good call. Codifying the architectural decisions — Rule 1 (no transform: scale on canvas ancestors), the two-canvas stack, the world-coord invariant, the pointer-anchored zoom formula — as a project-scope skill via /skill-creator is a much more durable guard-rail than a buried doc page. A doc page gets opened when someone remembers it exists. A skill gets surfaced by the harness automatically, any time a future session touches canvas-drawing work — not just zoom v2, but any new tool that draws into the composer.
Adding it to the big-plan as its own sub-issue, placed early and parallel to the POC spike so it’s available as reference throughout the rest of the implementation subs. The skill description should trigger on anything like “adding a tool to the composer canvas”, “drawing on the composer”, “zoom behavior”, so attempt #9 (whoever / whenever that is) doesn’t have to re-derive why transform: scale is banned.
余談
ripoutのPRをmergeしたあと、「なんで7回も失敗したんだ」と素直に不思議だったので、今回の会話はリサーチと考古学を先にやってから再設計する、という順番にした。
結果として、4月18日の自分が既に正解を出荷済みだった、というのが一番の収穫。追加で得たのは、ズームは”truth surface”なんだからフェイクのディテールを生やすな、という洞察。アートレイヤは再レンダせずブラーさせて正直に、ペンツールのUIだけcrispに、という2-canvas stackの設計はこの会話で初めて出てきた。
8回目の攻めに向けてのsafeguardsは記事の最後に書いた4つのルール。Rule 2(Playwrightの緑を信用しない)が一番効くような気がする。7回嘘をつかれたので。
もう一つ、big-planをキックする直前に入れたのが「この4ルールをproject-scope skillにする」という決定。docページに書いて終わりにすると、次にcomposerに描画系ツールを足す人(未来の自分を含む)がそのdocを開くかは運でしかない。Claude Codeのskillなら、関連しそうなタスクに入った時点でharnessが自動でsuggestしてくれる。zoom v2だけじゃなくて、この先composerに新しい描画ツールを足す全てのセッションで、Rule 1〜4が勝手に表に出てくるようにする、というのが9回目の同じ失敗を防ぐ本当の答えなのかなと。docではなく、skillで守る。
:::note 注記
この記事の英語対話部分は、Claude Codeとの実際の会話を--conversationモードで保存したもの。翻訳せず、英語のまま残している。
:::