Skip to main content

CHANGELOG.md

# Changelog

All notable changes to **Etcher** are documented here. The format
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
this project adheres to [Semantic Versioning](https://semver.org/).

## [0.5.3] — 2026-05-28

### Fixed

- **Strip mode silently dropped shapes on pages appended after mount.**
  `_onResize` (the universal re-sync path wired to window `resize`,
  `orientationchange`, and the strip's `image-loaded` event) refreshed
  layout for existing overlays but bailed for pages added to the
  container after the initial `_buildStripOverlays` pass. Multi-
  chapter infinite-scroll readers that fetch the next chapter's
  `<img>`s on demand were the canonical break: drawing tools fell
  through (no overlay to receive the gesture) and `addShape` /
  `addShapes` for the appended pages rejected with
  `[Etcher] addShape: strip mode requires a valid image_idx. …`.
  `_onResize` now builds overlays for newly-discovered pages.
  Extracted a single `_buildStripOverlay(page)` helper shared by
  mount-time iteration and the post-mount resync.

### Added

- **`layer.refreshPages()`** — public strip-mode method to force the
  same re-sync the window-`resize` / `image-loaded` listeners use.
  Useful for consumers that hydrate the next chapter's annotations
  immediately after appending its `<img>`s and want overlays in place
  before the first `addShape` call, rather than leaning on the
  synthetic-resize side-channel. No-op on canvas hosts.

## [0.5.2] — 2026-05-28

### Fixed

- **Shape hover/click inside daisyUI `.modal-open` host.** When Etcher
  was rendered inside a `<div class="modal modal-open">`, the doc-level
  `isInputOwner` gate matched the surrounding modal and short-circuited
  hover, click, double-click, and outside-click for every shape kind
  that relies on the global doc-fallback (rectangle, circle, polygon,
  freehand, callout, line shaft). `text` and `dimension` were unaffected
  because their per-element listeners bypass the gate. The check now
  ignores input-owner ancestors that **contain** the layer's own overlay
  — only ancestors layered *over* the Etcher canvas still suppress
  interaction, restoring the original intent.

## [0.5.1] — 2026-05-25

### Added

- **`layer.shapeAt(pt)`** — public hit-test API. Returns the top-
  most shape descriptor (`{uuid, kind, geometry, image_idx?,
  image_id?, style, metadata}`) under `pt`, or `null`. Strip
  handles take `pt = {imageIdx, x, y}` in source-pixel space;
  canvas handles take `pt = {x, y}` in canvas-pixel space.
  Wraps the existing internal `_shapeAt` so consumers wiring
  custom tap-zone navigation (left-third = previous page,
  right-third = next page, sidebars, mini-maps) can defer to
  Etcher's per-kind hit-test instead of re-implementing one per
  shape kind (rectangle / circle / polygon / freehand / …).
  Pairs with `fresco 0.6.3`'s suppress-tap fix — most consumers
  don't need this if they rely on `data-fresco-suppress-tap`,
  but custom tap handlers that bypass Fresco's tap-bus do.

### Compatibility

- Pure additive — no existing API changed.

## [0.5.0] — 2026-05-24

Deep-linking release. Collapses the ~60 lines of consumer JS each
"find this annotation in context" flow used to need (poll for the
shape, scrape DOM data-attrs for the host image, translate coords,
call the right handle method, optionally flash the shape) down to
a single `layer.revealShape(uuid, { pulse: true })` Promise.

### Added

- **`layer.getShape(uuid)` / `layer.getShapes()` carry the host-
  image identifier.** The returned descriptor now includes
  `image_idx` (strip mode) or `image_id` (canvas multi-image)
  when applicable — either field is present iff that handle mode
  is in use; both omitted for single-image canvas. Consumers
  routing UI to a shape no longer need to scrape
  `data-image-idx` / `data-image-id` off the SVG. Fully additive
  to the existing `{ uuid, kind, geometry, style, metadata }`
  shape.
- **`layer.revealShape(uuid, opts)` is now Promise-returning** and
  polls for late-mounted shapes (chapters that hydrate on scroll,
  async annotation backfills) for up to `opts.timeout` ms
  (default `10000`). Resolves with
  `{ uuid, image_idx?, image_id?, scrollTop?, cameraBounds? }`
  as soon as the underlying scroll / `fitBounds` call has been
  issued; rejects with `{ reason }` on timeout or handle failure.
- **`align` option** on `revealShape` for strip mode:
  `"center" | "top" | "bottom"` (default `"center"`). Lets
  callers pin the shape to a specific edge of the viewport — e.g.
  align to top when a fixed bottom comment-modal would otherwise
  occlude the shape.
- **`pulse` option** on `revealShape`: a brief halo flash
  (`.etcher-shape--pulse` keyframe animation, 1.5s default,
  configurable via `pulseDuration`). Helps users spot the just-
  navigated-to shape against a busy page.
- **`etcher:shape-revealed` DOM event** fires on the layer host
  with the same payload as the resolved Promise. LiveView hooks /
  consumer event-bus listeners can react to reveals without
  owning the Promise — useful when the deep-link handler lives
  somewhere other than where `revealShape` was called.

### Docs

- New **Coordinate spaces** section in `Etcher.Layer` moduledoc
  explaining the strip-mode source-pixel vs canvas-mode canvas-
  pixel distinction. Consumers persisting shape positions outside
  of Etcher (mini-maps, server-side analytics, deep-link routes)
  need to know which space their geometry is in. The "Programmatic
  API" section now shows the descriptor shape with `image_idx` /
  `image_id` and the `revealShape` Promise + pulse pattern.

### Compatibility

- `layer.revealShape` returning a Promise instead of a boolean is
  the one consumer-visible change. Callers that did
  `if (handle.revealShape(uuid)) { ... }` will hit the truthy-
  Promise gotcha — a Promise is always truthy. In practice the
  only caller in the wild is consumer deep-link code, and the
  Promise return is the actual asked-for change. Migrate to
  `revealShape(uuid).then(...)` / `await revealShape(uuid)`.
- Fully back-compat for `getShape` / `getShapes` — added fields
  only.

## [0.4.12] — 2026-05-24

### Changed

- **`mix.exs` `:fresco` dep constraint relaxed** to
  `"~> 0.5.9 or ~> 0.6.0"` so Etcher resolves against both the
  current `fresco` 0.5.x and the strip-extracted `fresco` 0.6.0.
  No code changes — Etcher's strip-renderer detects handles at
  runtime via `"scrollTo" in handle` and works identically
  whether the strip handle was registered by `fresco <= 0.5.9` or
  by the new `fresco_strip` package.

### Note

Strip mode is moving to the standalone
[`fresco_strip`](https://hex.pm/packages/fresco_strip) package
in `fresco 0.6.0`. If you use `<Fresco.scroll_strip>` (now
`<FrescoStrip.viewer>`), add `{:fresco_strip, "~> 0.1.0"}` to
your deps alongside `{:fresco, "~> 0.6.0"}`. Both packages
contribute handles to the same `window.Fresco.viewerRegistry`,
so Etcher finds them uniformly.

## [0.4.11] — 2026-05-24

Polishes touch-and-tooltip UX on `<Fresco.scroll_strip>` hosts +
plumbs into Fresco 0.5.9's tap-suppression hooks so consumer tap-
zone navigation no longer races etcher's shape interactions.
Backwards-compatible — every fix is either purely additive (new
API surface) or scoped to the broken state.

### Requires Fresco ~> 0.5.9

The `data-fresco-suppress-tap` shape-attribute and
`handle.suppressNextTap(...)` call paths only engage when paired
with Fresco 0.5.9+. The etcher.js side guards the latter with
`typeof handle.suppressNextTap === "function"` so older Fresco
silently degrades (no crash, but the iOS tap-race fixes don't
activate). The dep constraint in `mix.exs` is bumped to make
the requirement explicit.

### Fixed

- **Strip-mode tooltip lands at the right position when the
  container is scrolled.** `_showTooltipFor` now adds the
  container's `scrollLeft` / `scrollTop` when computing the
  tooltip's `style.top` / `style.left`. The tooltip is
  `position: absolute` inside the relatively-positioned scroll
  container, so its coordinates are interpreted in CONTENT space
  — without the scroll offset, every tooltip past the first
  viewport-worth of content landed `scrollTop` px above the
  visible area. Canvas-mode containers don't scroll (they pan
  via CSS transform) so `scrollTop` stays 0 and the addition
  is a no-op there — no per-mode branching.
- **Tooltip hover-bridge race on first show.** After
  `_showTooltipFor`, a 250 ms grace window suppresses the next
  `_scheduleHideTooltip` call. Without it, iOS Safari's
  synthesized mousemove (which lands on the just-shown tooltip,
  not the originating shape) fires `_setHoveredShape(null)`  `_scheduleHideTooltip` before the tooltip's own `mouseenter`
  cancellation runs, so the tooltip flashed visible then hid in
  the same frame.
- **Touch-drag of vertex / midpoint handles + edit-mode shape
  body** no longer fights the strip container's native scroll
  on mobile. New CSS rule applies `touch-action: none` to
  `.etcher-handle`, `.etcher-handle-midpoint`, and
  `.etcher-shape.is-editing` / `.is-moving` — defers iOS's
  scroll-vs-app classification long enough for `setPointerCapture`
  to claim the gesture. Scoped to interactive states so static
  shapes don't block native scroll past them.

### Added

- **`handle.tooltip()`** on the public layer API. Returns the
  currently-shown tooltip's `{shape, pinned}` or `null`. The
  `shape` field is the same `{uuid, kind, geometry, style?,
  metadata?}` descriptor `getShape` returns. Lets consumers
  driving custom chrome react to "user opened the tooltip on
  shape X" without scraping the DOM. The raw `tooltipEl` is
  intentionally not exposed.
- **`handle.repositionTooltip()`** on the public layer API. Re-
  anchors the currently-shown tooltip to its shape. No-op when
  no tooltip is up. Useful after a consumer-driven layout
  change (toggling a side panel, adjusting strip padding) has
  drifted the tooltip from its anchor.
- **`data-fresco-suppress-tap`** on every `.etcher-shape`
  element (set by both `_finalizeShape` and `_renderAnnotation`).
  Fresco 0.5.9+ probes for this attribute under the tap point
  via `document.elementsFromPoint` and skips the `tap` emit, so
  tapping an existing annotation pins the tooltip without
  bubbling to consumer-side tap-zone navigation. Older Fresco
  versions ignore the attribute entirely (no behavior change).
- **`handle.suppressNextTap(250)` call after every shape-commit**
  (inside `_finalizeShape`). Closes the synthesized-tap-after-
  drag race that would fire a consumer's tap-zone navigation
  immediately after the user finished drawing a shape. Guards
  on `typeof handle.suppressNextTap === "function"` so older
  Fresco versions silently no-op.

## [0.4.10] — 2026-05-23

### Added

- **`handle.addShape(payload)` / `handle.addShapes(payloads)`** on
  the public layer API. Splices one or more shapes into a
  live-mounted layer without remounting — preserves the active
  tool, color selection, multi-selection, undo stack, and any
  pinned tooltip. Useful for multi-chapter strip readers that
  fetch the next chapter's annotations on scroll, or canvas
  hosts that grow with new images at runtime.

  The payload mirrors the persisted-annotation shape used by
  `etcher:annotations-changed`:

      {
        uuid?:      "01HXY...",          // optional; generated if omitted
        kind:       "rectangle",
        geometry:   { ... },
        image_idx?: 17,                  // strip mode (REQUIRED)
        image_id?:  "page-3",            // canvas multi-image (auto-resolved
                                          //  from centroid when omitted)
        style?:     %{ color: "..." },
        metadata?:  %{ ... }
      }

  `addShape` returns the shape's uuid (or `null` on validation
  failure — strip mode requires a valid `image_idx`).
  `addShapes` returns an array of uuids in input order with any
  rejected payloads filtered out.

  Multiple sibling `addShape` / `addShapes` calls scheduled in
  the same microtask batch into one
  `etcher:annotations-changed` emit, so the consumer's
  server-sync handler doesn't see a flurry of full-array
  replays.

## [0.4.9] — 2026-05-21

Lets consumers hide Etcher's built-in chrome and wire their own UI
to the same actions. Pure-additive — defaults preserve every
existing consumer's behavior. Pairs naturally with Fresco 0.5.7's
matching `:nav_buttons` empty-list semantics on the viewer side,
though no Fresco upgrade is required.

### Added

- **`:nav_buttons` attr** on `<Etcher.layer>`. Atom list controlling
  which buttons get appended to Fresco's nav column:
  `[:pencil, :visibility]`.

    - `nil` (default) — both enabled.
    - `[]` — both hidden. Consumers wire their own UI to
      `handle.toggleMode()` / `handle.toggleVisible()` (or
      `setMode(true|false)` / `setVisible(true|false)`).
    - A subset list — only those buttons render.

  Mirrors as `data-nav-buttons` on the layer host using the
  `"none"` sentinel for the empty case, matching Fresco's
  convention.
- **`:toolbar` attr** on `<Etcher.layer>` (boolean, default `true`).
  `false` skips building the bottom toolbar entirely. Annotation
  mode still works programmatically — consumers wire their own
  toolbar UI to `handle.selectTool(...)` / `handle.selectColor(...)` /
  `handle.undo()` / `handle.redo()` / `handle.setMode(false)`.

### Programmatic equivalents for every built-in button

The `window.Etcher.layerFor(id)` handle already exposes the
matching primitives — this release just clarifies the mapping so
consumers hiding a built-in button know exactly which method to
call from their replacement UI:

| Built-in button         | Programmatic equivalent                       |
|-------------------------|-----------------------------------------------|
| Pencil (annotation on)  | `handle.setMode(true)` / `toggleMode()`       |
| Visibility (eye)        | `handle.setVisible(true)` / `toggleVisible()` |
| Toolbar — cursor        | `handle.exitDrawing()` / `selectTool(null)`   |
| Toolbar — rectangle…    | `handle.selectTool("rectangle")` (etc.)       |
| Toolbar — undo / redo   | `handle.undo()` / `handle.redo()`             |
| Toolbar — color swatch  | `handle.setColor("#hex")`                     |
| Toolbar — close (×)     | `handle.setMode(false)`                       |

All seven were already on the API surface; this entry just gives
consumers building their own chrome a single place to find the
mapping.

## [0.4.8] — 2026-05-21

### Fixed

- **Pre-0.4.7 canvas shapes now get their `image_id` backfilled on
  hydration**, closing the gap where annotations persisted before
  0.4.7 still ghost-rendered into adjacent viewport bands on
  multi-image `<Fresco.canvas>` hosts. Older payloads had no
  `image_id` field, so 0.4.7's `_applyImageVisibility` filter
  couldn't tell which page they belonged to and left them
  unconditionally visible.

  `_renderAnnotation` now runs the same centroid hit-test that
  draw-time uses (`_resolveCanvasImageId`) for any canvas-mode
  annotation that hydrates without an `image_id`. The full
  hydration pass batches its work and `_renderInitial` emits one
  `etcher:annotations-changed` after the loop — so the consumer
  persists the new ids once, not per shape, and the next mount
  reads them straight from `ann.image_id` without re-running the
  lookup. Single-image canvases (where `getImages().length < 2`)
  and shapes that already carry an `image_id` skip the branch.

  Shapes that sit in empty canvas margin (not over any image)
  stay untagged — matching draw-time behavior. A freeform note
  between two pages isn't owned by any page, so it stays visible
  regardless of which image is hidden.

## [0.4.7] — 2026-05-21

### Requires Fresco ~> 0.5.5

The visibility-mirroring fix below relies on Fresco 0.5.5's new
`image-visibility-change` event + `getHiddenImageIds()` snapshot.
Older Fresco gracefully degrades (the wiring no-ops; shapes still
leak into hidden-image bands as before).

### Fixed

- **Shapes on a hidden image no longer leak into adjacent viewport
  bands on multi-image `<Fresco.canvas>` hosts.** Paged readers,
  spreads, and lookbooks lay every page out side-by-side on one
  canvas and call `handle.setImageVisible(id, false)` to hide the
  non-active pages. Before this release, the page imgs were
  hidden but their Etcher shapes stayed visible — ghost-rendering
  inside the surrounding canvas-space the host exposes via
  page-padding bands.

  Etcher now subscribes to Fresco's `image-visibility-change` and
  toggles `display: none` on shapes whose `image_id` is in the
  hidden set (plus their title satellites). Hidden shapes that
  were being edited drop out of edit mode; hidden shapes with a
  pinned tooltip unpin — both states would otherwise float with
  no visible anchor.

### Added

- **`image_id` field on canvas-mode annotations.** When a shape is
  finalized on a multi-image canvas (`handle.getImages().length > 1`),
  its centroid is hit-tested against every image rect and the
  matching id is recorded on `shape.image_id` + emitted in
  `etcher:annotations-changed`. Shapes that land in empty canvas
  space (between images, in an unallocated region) get no
  `image_id` and behave like always — visible regardless of any
  image's visibility. Single-image canvas consumers are
  unaffected: the `length > 1` gate skips the lookup entirely, so
  no `image_id` is emitted.

  Hydrated annotations with `image_id` round-trip cleanly through
  the LiveView — Etcher reads it from `ann.image_id` in
  `_renderAnnotation` and tags the new DOM element with
  `data-image-id` for consumer CSS hooks.

## [0.4.6] — 2026-05-21

### Fixed

- **Touch taps after a finger-move no longer re-grab the previously-
  moved shape.** `_docPointerDown` now hit-tests fresh on every
  `pointertype === "touch"` event instead of trusting
  `_hoveredShape`. iOS synthesizes a `mousemove` at the
  `touchend` point after every gesture, which fires
  `_docMouseMove` and leaves the hover cache pinned to the last-
  touched shape — making the *next* tap re-select that shape
  regardless of where the finger actually landed. Mouse / pen
  events still consult the cache (it's accurate there) and fall
  back to a fresh hit-test only when it's empty.

## [0.4.5] — 2026-05-21

### Fixed

- **Cursor-mode finger-drag on a shape in `<Fresco.scroll_strip>`
  now moves the shape instead of scrolling the page.** Strip
  `_wireStripPointerInput` adds a `touchstart` listener that
  hit-tests the touch point and calls `preventDefault()` only when
  the finger lands on an existing shape — defers iOS's
  scroll-vs-app classification long enough for `pointerdown` +
  `setPointerCapture` to claim the gesture for the move handler.
  Every other tap (empty area, between pages, etc.) still scrolls,
  so the reader can navigate the chapter without exiting
  annotation mode.

  Sibling to 0.4.4's `touch-action: none` fix, but scoped to
  cursor mode where blanket-disabling `touch-action` would break
  reader navigation. `{ passive: false }` on the listener so
  `preventDefault` actually overrides the scroll classification.

## [0.4.4] — 2026-05-21

### Fixed

- **Finger-drawing on iOS Safari in `<Fresco.scroll_strip>` mode no
  longer commits a single oversized shape spanning finger-down to
  finger-up.** The strip container now picks up
  `touch-action: none` while annotation mode is on AND a drawing
  tool is active, so iOS hands every `touchmove` to the app
  instead of classifying the gesture as scroll at `touchstart`  too early for `pointerdown`'s `preventDefault` to override.
  Cursor mode (no drawing tool) keeps `touch-action: auto` so the
  reader can still scroll the chapter to reach existing shapes.

  The `.etcher-strip-drawing` class was already toggled on the
  strip container in `_selectTool` for the crosshair cursor and a
  consumer-CSS hook; this release adds the matching
  `touch-action: none` rule to Etcher's injected stylesheet.

## [0.4.3] — 2026-05-20

**Mobile-friendly toolbar + custom color picker + multi-select +
polygon vertex deletion.** A round of UX features that turn the
annotation surface from "works on desktop" into "comfortable on a
phone with a real workflow." Backwards-compatible: no API breakage,
no consumer changes required.

### Added

- **Progressive-overflow toolbar.** A `ResizeObserver` on the viewer
  container drives a layout pass that walks tools and swatches in
  lockstep and collapses the rightmost non-active items into one of
  two new `[⋯]` overflow popups (`tools`, `colors`) as the
  container narrows. Tools and swatches shrink in alternation so
  the row stays visually balanced; once both groups are exhausted,
  `undo` / `redo` collapse together as the final step (still
  reachable from the bottom of the tools popup under a hairline).
  The active tool / swatch is pinned and never collapses.
- **Custom color picker.** The colors popup now hosts a 132 px hue
  ring + 120 px lightness slider + preview chip. Press + drag on
  either canvas commits live via `_selectColor` (in-flight drafts
  and edits update under the finger); the final color pushes to
  recents on `pointerup`. Saturation is fixed at 100 %.
- **Recent custom colors.** Up to 5 picks are persisted to
  `localStorage` under `etcher.recentColors` (MRU, dedup +
  move-to-front on re-pick). The toolbar's inline swatch row now
  reflects this list — new users see the static preset palette,
  and once they pick anything the row transitions to their actual
  usage. Presets backfill any unused slots so the toolbar is never
  empty.
- **Canvas-frequent bootstrap.** When `_recentColors` is empty but
  the canvas already has annotations (a hydrated `.fresco` file, a
  manga chapter with persisted comments), the inline toolbar
  derives from the top 5 most-used colors on existing shapes —
  inferred from `style.color` frequencies. Once the user picks any
  color, recents takes over and the inferred palette stops
  contributing.
- **Shift-click multi-select.** In annotation cursor mode,
  `Shift+click` toggles a shape in/out of `selectedShapes`. The
  group can be dragged together (image-px delta applied uniformly,
  title boxes translate too) or deleted with a single
  `Backspace`/`Delete` under one `bulk_delete` undo entry + one
  `etcher:annotations-changed` emit. Selection clears on empty-
  canvas click, drawing-tool select, or annotation-mode exit.
- **Polygon vertex deletion.** While a polygon is in edit mode,
  clicking a vertex (no drag) highlights it red; `Backspace`/
  `Delete` splices the selected vertices out of `geometry.points`
  and re-renders. `Shift+click` extends the vertex selection.
  Falls through to whole-shape delete if the removal would leave
  fewer than 3 vertices.
- **`Etcher.registerInputOwnerSelector(selector)`** on the global
  `window.Etcher`. Append a CSS selector to the input-owner escape
  list for non-conventional overlays that don't match the built-in
  modal / dialog / tooltip / handle defaults. Idempotent.

### Changed

- **Default active color** now snaps to whatever lands as the
  leftmost toolbar swatch on first paint, not the legacy preset
  blue. Once the user picks anything, `_pushRecentColor`'s
  move-to-front keeps `activeColor` in agreement with the leftmost
  slot — so "what's highlighted" always matches "what will draw."
- **`etcher:annotations-changed` payload** carries a `fresco_id`
  key alongside `annotations` so a LiveView hosting multiple
  `<Etcher.layer>` instances can pattern-match the source.
- **Doc-level hit-test handlers** (`_docPointerDown`,
  `_docDblClick`, `_outsideClickHandler`,
  `_titleOutsideClickHandler`, `_tooltipOutsideClick`,
  `_docMouseMove`) route through a shared `isInputOwner(target)`
  helper instead of inline selector lists. Adding `.etcher-popup`
  to the input-owner set means the new picker popups don't tear
  down their own state.

## [0.4.2] — 2026-05-20

### Fixed

- **Clicks inside consumer-owned modals no longer fall through to
  shapes behind them.** Etcher's doc-level hit-test handlers
  (`_docPointerDown`, `_docDblClick`, `_outsideClickHandler`,
  `_titleOutsideClickHandler`, `_tooltipOutsideClick`, and the
  hover-tracker `_docMouseMove`) now skip events whose target is
  inside a standard modal / dialog. Three selectors are recognized
  out of the box:

  - `dialog[open]` — native HTML5 `<dialog>` shown via `.showModal()`
    or `.show()`
  - `.modal-open` — daisyUI / Bootstrap convention
  - `[role='dialog']` — ARIA-compliant custom modals

  Previously, tapping a button inside a modal that sat over the
  viewer would shadow the button's own handler and pin / move /
  select the shape underneath instead. Consumers shipping a comment
  composer, settings sheet, share dialog, or confirmation prompt
  layered over a Fresco viewer can now drop their per-modal
  `pointerdown` / `stopPropagation` shims.

### Added

- **`Etcher.registerInputOwnerSelector(selector)`** on the global
  `window.Etcher`. Append a CSS selector to the input-owner escape
  list for non-conventional overlays that don't match the three
  defaults. Idempotent — re-registering the same selector is a
  no-op. Affects every doc-level Etcher handler immediately on the
  next event.

  ```js
  window.Etcher.registerInputOwnerSelector(".my-custom-overlay");
  ```

## [0.4.1] — 2026-05-20

Closes strip-mode parity gaps flagged by the consumer reader on top of
0.4.0. Most issues collapsed to two missing wires; a couple needed
small fresco-side help (`getImages()` now reports horizontal layout +
prefers live natural dims — see Fresco 0.5.4).

### Requires Fresco ~> 0.5.4

The overlay-positioning fix below relies on Fresco 0.5.4's enriched
`getImages()` (added `left` / `width`, switched `naturalWidth` /
`naturalHeight` to prefer loaded bitmap dims over consumer-passed
`sources` hints). Older Fresco gracefully falls back to the 0.4.0
behavior (overlay pinned to container width).

### Fixed

- **Shape hover + tap now work on strip.** `_initStripRenderer` was
  missing the `_wireGlobalShapeListeners()` call canvas mode has. The
  doc-level listeners already understand strip's `{imageIdx, x, y}`
  coords (since `_shapeAt` filters by `pt.imageIdx`), so wiring them
  in unlocks: tooltip on hover, `.is-hovered` styling, `_onShapeTap`
  in browse mode (pin tooltip → fires `etcher:tooltip-pin`), and
  `_enterEditMode` on shape click in annotation cursor mode (no
  drawing tool active). No consumer-side hover/tap workarounds
  needed.
- **Toolbar stays in view while scrolling.** Strip-mode toolbar gets
  a `data-strip` attribute and a `position: fixed` CSS rule so it
  anchors to the viewport instead of the scroll container (which IS
  the scrolling element in strip mode and was carrying the toolbar
  off-screen).
- **Overlays size to each image, not to the container.**
  `_buildStripOverlays` and `_onResize` now read `left` / `width`
  from `getImages()` per image, so consumer-side horizontal padding
  or centered narrow pages render correctly. Previously every
  overlay was hardcoded to `left: 0; width: 100%`, stretching shapes
  to fill the container width.
- **viewBox refreshes on resize / image-load.** `_onResize` (which
  also fires on Fresco's `image-loaded` event) now refreshes each
  overlay's `viewBox` from the current `naturalWidth` /
  `naturalHeight`. Combined with Fresco 0.5.4 preferring loaded
  bitmap dims, consumers who patch `sources[i]` after the bitmap
  arrives no longer end up with stale-ratio viewBoxes that distort
  geometry.
- **No more momentary stretch on layout mismatches.** The overlay
  SVG's `preserveAspectRatio` is now the default (`xMidYMid meet`),
  which letterboxes if there's a brief mismatch between the
  element's box and the viewBox (during load / aspect-ratio
  correction / padding changes). Previously `"none"` would stretch
  shapes during those windows as a user-visible flash of distorted
  geometry. Trade-off: shapes don't perfectly fill the element
  during the mismatch — but a momentary letterbox is strictly
  better UX than a momentary stretch.

### Added

- **`_applyStripOverlayLayout(svg, page)`** internal helper: shared
  by mount + resize paths so viewBox / position / size always
  refresh together. Universal re-sync entrypoint is still
  `window.dispatchEvent(new Event("resize"))` — same hook the
  browser uses on its own. Consumers who mutate `<img>` layout via
  CSS (toggling a padding slider, swapping an aspect-ratio class)
  should dispatch a resize event to nudge etcher to re-query.

## [0.4.0] — 2026-05-20

**`<Fresco.scroll_strip>` support.** Etcher now renders on strip-format
viewers (vertical-scroll manga/manhwa, long-form web comics) with the
same UX surface canvas mode has — pencil button, toolbar, hover
tooltips, undo/redo, hydration from `extensions.etcher`. Pure-additive
on the consumer side: the existing `<Etcher.layer>` mounts unchanged
and dispatches strip vs canvas internally based on the Fresco handle
shape.

### Requires Fresco ~> 0.5.3

Strip mode relies on `handle.getExtension("etcher")` and
`handle.getImages()` (both added in Fresco 0.5.3). Mixing Etcher 0.4
with an older Fresco prints a console warning and skips hydration but
otherwise no-ops cleanly.

### Added

- **Strip renderer.** `<Etcher.layer>` inspects the Fresco handle at
  mount and picks between two renderers: the existing canvas renderer
  (one SVG overlay spanning the whole canvas) and a new strip renderer
  (one SVG sibling per image, sized to each image's `offsetTop` /
  `offsetHeight`, with `viewBox` set to natural pixel dimensions so
  geometry stored in image-px renders 1:1 without any per-frame coord
  math). Native browser scroll moves overlays with their images for
  free.
- **Per-image annotations.** Strip shapes carry an `image_idx` field
  (the page they live on), pushed in the `etcher:annotations-changed`
  payload and round-tripped through `extensions.etcher`. Canvas-mode
  payloads are unchanged — `image_idx` is strip-only.
- **`handle.revealShape(uuid, opts)` on the layer API.** Scroll a
  strip to center a shape's bbox in the viewport (or call
  `handle.fitBounds` on the shape's bbox in canvas mode). `opts`:
  `{behavior: "smooth" | "instant", padding: <natural-px>}`. Returns
  `true` if the shape was found and a reveal action was issued.
  Useful for "click a comment thread → jump to the page it's on"
  flows.
- **Touch-native tap-to-select.** `_docPointerDown` now hit-tests
  directly on pointerdown when no shape is currently hovered.
  Previously, devices without hover (mobile Safari / Chrome on
  Android) never populated `_hoveredShape`, so finger-tapping a shape
  never pinned its tooltip — fixed for canvas and strip alike.
- **Per-page click + drag locking.** When the user starts drawing on
  image #3, a `pointermove` that wanders into image #4 is clamped to
  image #3's screen rect — the resulting shape stays anchored to the
  page it was started on. Polygon and callout multi-click flows lock
  the same way: clicks outside the starting page are ignored.
- **Strip-mode `crosshair` cursor** on the scroll container while a
  drawing tool is active, plus an `etcher-strip-drawing` class hook
  for consumer CSS that wants to restyle native scrollbars or hide
  page chrome while drawing.

### Changed

- **`_init` dispatch.** The mount-time init split into
  `_initCanvasRenderer(handle)` and `_initStripRenderer(handle)`.
  Detection: `"scrollTo" in handle && typeof handle.scrollTo === "function"`
  → strip; `typeof handle.getCanvasSize === "function"` → canvas;
  anything else logs a warning and bails. Consumers driving the layer
  via `window.Etcher.layerFor(...)` see the same `api` either way.
- **`_shapeAt(pt)`** filters hit-tests by `pt.imageIdx` in strip mode.
  Without the filter, a shape on image 2 with bbox
  `{x: 100, y: 200, w: 50, h: 50}` would falsely match a click at
  the same image-px coordinates on image 5. Canvas mode is
  unaffected.
- **`_finalizeShape` / `_renderAnnotation`** stamp `data-image-idx`
  on the shape's `<g>` / `<rect>` / `<polygon>` element in strip mode
  for DOM-level debugging + consumer CSS hooks.
- **`mix.exs`** dep pinned to `{:fresco, "~> 0.5.3"}` (was `~> 0.5`).

### Why now

The consumer reader was migrating their long-form manhwa chapters
from `<Fresco.canvas>` (paged, one image at a time) to
`<Fresco.scroll_strip>` (vertical scroll, all pages stitched) but
couldn't bring Etcher with them — strip's handle missed the surface
canvas had, and Etcher's geometry model assumed a single canvas-pixel
coord space. Fresco 0.5.3 closed the handle-side gap; this release
closes the Etcher-side gap.

## [0.3.0] — 2026-05-19

**Major rewrite.** Etcher now plugs into `<Fresco.canvas>`'s
`extensions.etcher` blob instead of a separate Ecto table. Annotations
live in the `.fresco` file alongside the image layout, so a single
`Fresco.Canvas.write!/2` saves the entire scene — no more scattered
DB rows.

### Requires Fresco ~> 0.5

Etcher 0.2 was OpenSeadragon-coupled (through Fresco 0.3.x). Fresco 0.5
dropped OSD entirely; Etcher's coord transforms (`pointFromPixel`,
`pixelFromPoint`, `world.getItemAt`) port to Fresco 0.5's stable
`handle.screenToImage` / `handle.imageToScreen` / `handle.getCanvasSize`.
The "tile-source axis shift" and "modal-traversal drift" problems that
motivated Etcher's custom OSD-viewport math are gone in Fresco 0.5, so
the bridge math is now four lines instead of a forty-line workaround
with footnotes.

### Removed

- **`Etcher.Annotation` Ecto schema.** Annotations are plain maps inside
  `extensions.etcher`, not DB rows.
- **`Etcher.Storage` behaviour** + `Etcher.Storage.Default` adapter. No
  adapter pattern — one storage path: `Fresco.Canvas.put_extension/3`
  and `Fresco.Canvas.write!/2`.
- **`mix etcher.gen.migration`** task + the `etcher_annotations` table.
- **`:target_type`, `:target_uuid`, `:initial_annotations` attrs** on
  `<Etcher.layer>`. The canvas IS the target; hydration comes from
  `handle.getExtension("etcher")` at mount.
- **`Etcher.create_annotation` / `list_annotations_for` /
  `update_annotation` / `delete_annotation`** defdelegates on the
  `Etcher` module.
- **`etcher:created` / `etcher:updated` / `etcher:deleted` /
  `etcher:selected`** events and the matching `etcher:annotation-saved`
  / `:annotation-removed` / `:annotation-added` /
  `:annotation-updated` / `:exit-drawing` push-events. Replaced by a
  single bulk `etcher:annotations-changed` event.
- **tmp_id ⇄ real-uuid round-trip.** UUIDv7 is generated client-side
  via `crypto.getRandomValues` at draw time; the server never assigns
  ids. The `_pendingTitle` / `_discardOnSave` / `syncLiveUuid`
  deferred-action plumbing all goes away.
- **`OpenSeadragon.Point` references** and the `handle.on("fast-pan")`
  listener. Fresco 0.5's CSS-transform engine doesn't need either.

### Added

- **Hydration from `handle.getExtension("etcher")`** on mount. Initial
  annotations come from the canvas's `extensions` map — the consumer
  loads a `.fresco` file via `Fresco.Canvas.read!/1` and stashes it in
  assigns; Etcher reads it through Fresco's handle.
- **Single bulk event** `etcher:annotations-changed`, payload
  `%{"annotations" => [%{uuid, kind, geometry, style, metadata}, …]}`.
  Consumer's LiveView pipes through `Fresco.Canvas.put_extension(canvas,
  "etcher", %{"version" => "1", "annotations" => annotations})`.
- **`etcher:shape-drawn` event**, payload `%{"uuid", "kind"}`. Fires
  once per `_finalizeShape` call — i.e. on actual user-draw intent.
  Distinct from `annotations-changed` (which fires on every mutation
  including undo/redo of deletes, drags, color picks). Use this when
  a consumer wants to open a composer / inspector keyed on "the user
  just drew a new shape" without false positives.
- **`patchShape(uuid, {metadata, style})` API** on the layer handle.
  Merges the supplied fields into the in-memory shape and re-renders
  so DOM that derives from metadata (dimension labels, callout text,
  title siblings) reflects the patch. Designed for consumers hosting
  the canvas with `phx-update="ignore"``handle.getExtension("etcher")`
  freezes at mount, so a full layer remount used to be the only way
  to push server-side state updates.
- **`deleteShape(uuid)` API** on the layer handle. Removes the shape
  from local state + DOM, pushes the deletion onto Etcher's undo
  stack (Cmd+Z restores), and fires `annotations-changed` so the
  consumer's persistence layer catches up automatically.
- **Line annotation tool** — eighth drawing kind. Two-endpoint stroke,
  no arrows, no inline label. Geometry (`{a: [x,y], b: [x,y]}`) and
  edit-handle mechanics shared with `dimension`. Title rides the
  standard sibling-above-shape path (the same movable label group
  rectangle / circle / polygon use).
- **Direct shape drag in annotation cursor mode** — pointerdown on
  any shape's body now immediately starts the move gesture. Stationary
  clicks still select via the no-drag fallback. Doc-level pointer
  routing was extended so shapes with `.etcher-shape { pointer-events:
  none }` wrappers (rectangle, circle, polygon, line, dimension,
  freehand) participate; callout and text were already covered via
  their inner `pointer-events: all` rects.
- **Select-on-grab** — shape enters edit mode the moment the user
  starts a move gesture, not on release. Handles appear immediately
  so drag feels like "select and move" instead of "move then select."
- **Backspace / Delete keyboard shortcut** removes the
  currently-selected shape. Routes through the same `_deleteShape`
  path as the eraser tool, so undo + sync behavior is identical.

### Changed

- **Callout commit flow.** Second-click no longer auto-opens Etcher's
  inline text editor and no longer seeds `metadata.title` with an
  `"Add a title…"` placeholder. Consumers wiring their own composer
  (taking the title via a UI field + creating a linked comment in
  one flow) now get a clean draft to work with — the composer is the
  single edit surface for the title. Re-editing the title later via
  double-click is unchanged.
- **Tooltip placement** flips below the shape when sitting above
  would clip the container's top edge, and clamps horizontally so
  the tooltip stays inside the container near the left/right edges.
  Previously a shape near the top of the viewport had its tooltip
  rendered partially off-screen above the container.

### Unchanged (drawing UX)

The eight drawing tools (rectangle, circle, polygon, freehand,
callout, text, dimension, line) plus the eraser keep their existing
draw + edit mechanics. Hit-testing, undo/redo (⌘Z / ⌘⇧Z / Ctrl+Y),
inline text editor for text shapes, color swatches, tooltips, the
bottom toolbar, the pencil + visibility nav buttons — all preserved.
The new drag-without-tap + select-on-grab + keyboard-delete layers
above these without changing the per-shape draw paths. The ~5000
lines of shape drawing code are substantially unchanged; only the
~200-line Fresco bridge was rewritten.

### Migration from 0.2.x

Consumers on Etcher 0.2 with persisted annotations in
`etcher_annotations` need to migrate. Export the rows you care about:

```sql
SELECT uuid, kind, geometry, style, metadata
FROM etcher_annotations
WHERE target_type = ? AND target_uuid = ?
ORDER BY position;
```

Marshal them into the new `extensions.etcher.annotations` array shape
and stash into the canvas struct:

```elixir
canvas =
  Fresco.Canvas.new(width: 4000, height: 3000)
  |> Fresco.Canvas.add_image(%{src: image_url, x: 0, y: 0, width: 4000})
  |> Fresco.Canvas.put_extension("etcher", %{
    "version" => "1",
    "annotations" => exported_rows
  })

Fresco.Canvas.write!("/path/to/scene.fresco", canvas)
```

Drop the `etcher_annotations` table once migrated. `<Etcher.layer>`
loses its `:target_type` / `:target_uuid` / `:initial_annotations`
attrs in the template — pass just `fresco_id="..."` and optionally
`tools={...}`. The handle_event clauses for `etcher:created`,
`etcher:updated`, `etcher:deleted` collapse into a single
`etcher:annotations-changed` clause.

`<Fresco.viewer>` users need to switch to `<Fresco.canvas>` (use a
canvas with a single image for the same effect) — Etcher 0.3 only
attaches to canvases.

## [0.2.8] — 2026-05-17

Coordinate with Fresco's new CSS-transform pan fast path so
annotations stay anchored to the canvas during the pan window. No
behavior change for consumers on Fresco `< 0.3.0` or for viewers
not opted into `:pan_optimized` — the new subscription is inert
when the event never fires.

### Added

- Subscribe to Fresco's `fast-pan` event (introduced in fresco
  `0.3.0` for the `:pan_optimized` viewer mode). When Fresco emits
  `fast-pan {phase, x, y}` during a fast-path pan, the EtcherLayer
  hook applies the same `translate3d(x, y, 0)` CSS transform to
  its SVG overlay wrapper so annotations, tooltips, and
  foreignObject editors glide in lockstep with the canvas. CSS
  transform propagates to descendants automatically; hit-testing
  follows the visual transform so clicks during fast-pan still
  register on the correct annotation. On `phase: "end"`, the
  transform is cleared — Fresco has restored OSD's drawer and the
  next `animation` tick re-renders the overlay from the committed
  viewport.

### Notes

- Backwards compatible. Older Fresco (`< 0.3.0`) never emits the
  `fast-pan` event, so the new subscription is dead code with no
  overhead. Etcher 0.2.8 works identically against any Fresco
  version it was previously compatible with.
- The subscription is added to the existing `_unsubViewport`
  array, so `destroyed()` cleans it up like the other viewport
  bridges.

## [0.2.7] — 2026-05-15

Documentation + comment cleanup release. No runtime behavior changes
— every existing call site behaves exactly as in 0.2.6. The goal is
to make Etcher visibly **decoupled from any specific consumer** so
that a third-party Phoenix dev reading the source or docs sees a
clean, drop-in library rather than an obvious satellite.

### Changed

- `Etcher.Storage` moduledoc: replaced the "PhoenixKit, for example"
  paragraph with a generic "consumer that pairs every annotation
  with a comment thread" example. The behaviour itself is unchanged
  — only the explanatory prose.
- `lib/etcher.ex` moduledoc: corrected the install snippet
  (`{:fresco, "~> 0.1"}, {:etcher, "~> 0.2"}` — the old version pins
  pointed at non-existent fresco 0.2 / outdated etcher 0.1) and
  expanded the shape list to include `callout, text, dimension`
  rather than only the original four.
- `Etcher.Layer` moduledoc: tools example and "Tools" section now
  include `:eraser`, matching the actual default. Added one line
  explaining how to opt out.
- `priv/static/etcher.js`: four inline comments that named PhoenixKit
  / PhoenixKitComments now describe the generic contract instead
  (any element with `data-annotation-uuid` for cross-component
  highlight; "consumer's annotation-creation UI" / "host apps" for
  extension-point comments). The contracts themselves were already
  generic — only the prose changed.
- `README.md`: corrected the Installation version pins, expanded the
  bottom-toolbar ASCII diagram to show all eight buttons, replaced
  "the four drawing tools" with "seven drawing tools (rectangle,
  circle, polygon, freehand, callout, text, dimension) plus an
  eraser," documented the `EtcherLayer` hook name for explicit
  hook-map wiring, and rewrote the Out-of-scope section to drop a
  stale "v0.1 is draw-and-commit" claim (editing has been supported
  for several releases) and the "four built-ins" reference.
- `CHANGELOG.md`: reworded the 0.2.6 entry to drop the two PhoenixKit
  name-drops; the same fix applies to any consumer that opens its
  own composer popup on `etcher:created`.

## [0.2.6] — 2026-05-15

Single fix to the dimension-creation flow so consumer apps that open
their own composer popup on `etcher:created` can attach a comment to
a freshly-drawn dimension without fighting an auto-opened inline
editor.

### Fixed

- **Dimension creation no longer auto-opens the inline label editor.**
  0.2.5 fired `_startTextEdit` in the `_finalizeShape` afterCreate
  for dimensions (mirroring the callout flow), which stacked a
  foreignObject input over the label position. Consumers that pop a
  separate composer popup on `etcher:created` (for setting an
  annotation title + comment in one flow) lost the composer behind
  the inline editor — users would dismiss the composer they hadn't
  noticed and lose the comment, sometimes the whole shape (such
  composers typically treat cancel as "discard the annotation").
  Dimensions now spawn empty; the consumer's UI sets the label via
  whatever path it normally uses for non-text shapes (typically the
  `etcher:created` → composer-popup → `etcher:updated` chain). Re-
  editing the label after creation still works via double-click on
  the dimension — that path was wired in 0.2.5 and is unchanged.

## [0.2.5] — 2026-05-15

New annotation kind — `dimension` — for measurement-style labeling.
A horizontal-or-angled shaft with V-arrows on both ends and a
black, slidable label. Arrow color follows the active swatch; label
stays black with a white halo so it's legible on any color.

### Added

- **Dimension tool** (`kind: "dimension"`). Two endpoints
  (`geometry: {a: [x,y], b: [x,y]}`); label text + position along
  the shaft live in `metadata.title` and `metadata.title_offset`
  (0–1, default 0.5 = midpoint). Drawn either by click-drag (commit
  on release) or by two-click rubberband (first click locks endpoint
  A, the line follows the cursor, second click commits endpoint B).
  After commit, drops straight into inline-edit mode for the label.
- **Slidable label** — in cursor mode, click and drag the label to
  slide it along the shaft. Persists as `metadata.title_offset`.
- Endpoint corner handles + body-drag translate, double-click to
  re-edit the label, eraser supports the new kind.
- `Etcher.Annotation`'s `@kinds` widened to include `"callout"`,
  `"text"`, and `"dimension"` (the schema docs were also out of date
  for callout/text — fixed in passing).

### Changed

- The bundled `Etcher.Annotation` schema now accepts the same kinds
  the JS toolbar exposes. Consumers using the bundled storage need
  no migration if their CHECK constraint was already widened for
  callout/text — add `'dimension'` to the same allow-list.
- Inline text editor input pinned to black so the typed text stays
  readable on the white-ish input background regardless of the
  shape's stroke color (light pastels were nearly invisible).

## [0.2.4] — 2026-05-15

Cross-browser fixes + a callout stability sweep — mostly fallout from
the 0.2.3 title fix not being symmetric across the rendering paths.

### Fixed

- **Title / callout / text-shape labels render at the same vertical
  position in Firefox as in Safari.** The text elements were created
  with `dominant-baseline: hanging`, which Safari and Firefox
  interpret differently for Latin text — Safari renders glyphs ABOVE
  the hanging baseline while Firefox renders them BELOW per spec.
  Combined with the wrap helper's `dy="1em"` on the first tspan,
  callout text rendered correctly in Safari but floated below the
  rect in Firefox. All three render paths (`_renderTitleSibling`,
  the `case "text"` branch, and the callout branch in `_renderShape`)
  now override `dominant-baseline` to `alphabetic` on the text
  element each render. Both browsers honor alphabetic identically:
  the alphabetic baseline lands at `text.y + 1em`, putting the text
  cleanly inside the rect with the underline / leader attaching at
  the rect's bottom edge.
- **Callouts no longer grow exponentially when text overflows.** The
  width-fit font cap that 0.2.3 added to `_renderTitleSibling` was
  missing from the callout render path, so a long callout label
  triggered the same multi-line wrap → grow → wider font → more wraps
  feedback loop the title fix originally addressed. Same cap applied
  to callouts.
- **Click on a title or callout/text-shape handle no longer triggers
  a phantom resize.** `_startTitleHandleDrag` and `_startHandleDrag`
  unconditionally fired `etcher:updated` and snapped geometry to the
  rendered (shrunk) box on `pointerup`, even when the user just
  clicked without dragging. Both now use a 3-px screen-space dead
  zone (matching the body-drag and title-drag handlers) so a bare
  click is a no-op.
- **Callout corner drags no longer shrink the box on every
  interaction.** Drag math used `_renderedBox` (shrink-fit visual)
  as the start reference, so each drag computed a new geometry of
  `(visual + delta)` instead of `(geometry + delta)`. With shrink-fit
  on, that baked the shrink offset back into storage every drag —
  the callout visibly shrunk a bit each time, then converged. Drag
  math now uses pointer DELTA (`pt - startPt`) against the full
  `geometry.text_box`, so dragging a handle by Δpx grows or shrinks
  geometry by exactly Δpx; the visual continues to shrink-fit
  independently. Anchor drag (idx 0) still uses absolute pt — no
  visual/storage offset there.
- The `_startHandleDrag` `onUp` snap-to-`_renderedBox` is skipped for
  callouts (delta math already keeps geometry consistent with the
  visible drag). Text shapes still snap on release — same 0.2.x
  behavior, no regression.

## [0.2.3] — 2026-05-14

Single bug fix — stops the runaway growth of shape titles on drag /
click. No API change, no behavior change for code that doesn't hit
the bug.

### Fixed

- **Shape title text no longer balloons on every interaction.** When
  a title's content overflowed the default box width at the
  height-derived font-size, `_fillTextWithWrappedTspans` wrapped it
  onto multiple lines. `actualH = measured.height + pad·2` then
  exceeded the input `th`, that taller height got persisted back
  into `metadata.title_box` on release, the next render derived an
  even larger font, more lines wrapped, and the title grew
  exponentially per interaction (`title_box.h` going
  22 → 54 → 273 → … in three drags). `_renderTitleSibling` now
  caps the font-size so the title fits the box width on a single
  line (floor of 10 px), bounding `actualH` to one line of text.
  The shrink-to-text rendering + handle-drag commit are unchanged;
  with the cap in place the system has a fixed point instead of a
  feedback loop.

## [0.2.2] — 2026-05-14

Follow-up patch to 0.2.1: restore body-grab on the editing shape,
keep the tooltip from blocking the satellite title label, and make
edit-mode survive a click on a sibling shape now that shapes are
`pointer-events: none`.

### Fixed

- **Body-grab restored on the editing shape.** With 0.2.1 flipping
  every shape to `pointer-events: none`, the click-drag-the-body-
  to-move-the-shape gesture stopped firing because the shape's
  own pointerdown listener no longer saw any events. The
  currently-edit-mode shape now re-enables `pointer-events:
  visiblePainted` via a `.etcher-shape.is-editing` rule — only
  THAT shape catches its own pointerdown; the rest of the shapes
  stay invisible to events so pan/zoom still passes through them.
- **Tooltip no longer covers the title satellite.** When the cursor
  was over a shape's movable title label, the hover tooltip
  rendered above the parent shape — directly on top of the title
  the user was trying to grab. The doc-level hover hit-test now
  detects when the cursor is inside a title's bbox and suppresses
  the tooltip for that hit; hover styling on the parent shape
  stays applied so it's still clear which annotation is targeted.
- **Edit-mode and tooltip-pin survive a click on a sibling shape.**
  Both outside-click handlers (the edit-mode tear-down and the
  tooltip-pin tear-down) used `e.target.closest(".etcher-shape")`
  to detect "is this click on a shape?" — but since 0.2.1 shapes
  are `pointer-events: none`, the click's DOM target is OSD's
  canvas, not the shape. The handlers now fall back to an
  image-px hit-test via `_shapeAt(pt)` so clicking a different
  shape switches edit mode or the pin instead of tearing down to
  empty.

### Internal

- `_setHoveredShape/2` (was /1) gains an `onTitle` flag.
- New helper `_pointOnTitleOf/2` for the title-bbox hit-test.

## [0.2.1] — 2026-05-14

Patch release: pan / zoom now work over shapes.

### Fixed

- Scroll-wheel zoom and click-drag pan on the underlying viewer
  stopped working whenever the cursor was over an annotation
  (rectangle, circle, polygon, freehand, callout, text). Root
  cause: shapes had `pointer-events: visiblePainted` so they
  caught wheel + pointerdown before OSD's MouseTracker on the
  canvas sibling could see them — pointer events bubble UP the
  DOM, not sideways. Shapes are now `pointer-events: none`, and
  hover + click are re-detected at the document level via
  image-px hit-testing (reuses the eraser's per-kind point-in-
  shape check). Hover styling, tooltips, click-to-pin, click-to-
  edit, and dblclick-inline-edit all continue to work; pan and
  zoom now pass through every annotation cleanly.

### Internal

- Renamed `_eraserHit/2` to a shared `_shapeContainsPoint/2`
  helper. The eraser keeps a thin alias for readability at its
  call sites.
- New helpers: `_shapeAt/1` (topmost-shape lookup), `_onShapeTap/1`
  (shared tap-handling entry), `_wireGlobalShapeListeners/0` +
  `_unwireGlobalShapeListeners/0`, `_setHoveredShape/1`.
- Tap-vs-drag disambiguation with a 5px dead-zone keeps a quick
  click-without-drag firing the shape's selection / pin / edit
  flow, while any drag-with-movement passes through to OSD's
  pan unchanged.

## [0.2.0] — 2026-05-14

A backwards-compatible second release: two new shape kinds, an eraser
tool, undo/redo with full history, satellite titles, edge-resize
grabbers, polygon midpoint insertion, a visibility toggle, and a
complete programmatic API so consumers can drive the layer without
rendering its built-in toolbar.

### Added

- **Callout tool** (`kind: "callout"`) — blueprint-style leader-line
  annotation: an anchor dot pointing at the image, a thin line to a
  resizable text bbox (with a horizontal underline spanning the bbox
  bottom). Text inside scales to fit the bbox.
- **Text tool** (`kind: "text"`) — freestanding text label drawn as
  a click-drag bbox. Inline editor (`<foreignObject>` + `<input>`)
  opens on commit and on double-click for re-edit. Font scales with
  bbox height; bbox shrink-wraps to the text on release.
- **Eraser tool** — press-and-drag wipes shapes by sweep. Each shape
  the cursor crosses dims (`.is-erasing`) for preview; release
  flushes them all as a single compound delete. Idle hover (eraser
  selected, no button held) previews the single shape under the
  cursor.
- **Optional title field per annotation** — every kind can carry a
  short label (`title varchar(200)`). On rect/circle/polygon/freehand
  the title renders as a movable, resizable satellite group with a
  dashed leader line back to the parent's nearest perimeter point
  (leader auto-hides when the title is inside the parent). On
  callouts the title is the in-bbox content. Drag to move (persisted
  as `metadata.title_box`), 4 corner handles to resize, double-click
  to inline-edit.
- **Edge-midpoint resize grabbers on rectangles** — small rounded
  rect handles on each side; drag a side to slide one edge while
  the opposite edge stays anchored. Distinct visual + `ns-resize` /
  `ew-resize` cursors so they don't conflate with polygon midpoints.
- **Polygon midpoint vertex insertion** — every polygon edge now
  carries a "ghost" midpoint handle that lights up when the cursor
  is near. Grab it to insert a new vertex at that midpoint and place
  it via a vertex-style drag.
- **Undo / Redo** — toolbar buttons + ⌘Z / ⌘⇧Z / Ctrl+Y keyboard
  shortcuts. 50-op session history covers geometry, style, metadata,
  title text, and deletes (including bulk-delete from the eraser).
  Delete recreation honors a new `restore: true` flag so the consumer
  can suppress its create-time UI (e.g. comment composer) on undo.
- **Visibility toggle** — eye / eye-slash button above the pencil in
  Fresco's nav column. Hides/shows the entire SVG overlay with one
  click.
- **Color picker** — bottom-toolbar swatches; persisted as
  `style.color` on each annotation; vertex + title handles inherit
  the shape's color via `currentColor` (no more always-orange dots).
  Override the palette via `window.Etcher.colorSwatches`; initial
  color via `window.Etcher.defaultColor`.
- **Tooltip slot extension API**`window.Etcher.tooltipSlots = {
  header, body, footer }` lets consumers replace the tooltip content
  per-slot while keeping the wrapper (trash button, pin/unpin, hover
  bridge) under Etcher's control. Default slots read generic
  `metadata.{title,body,subtitle}` keys.
- **Complete programmatic control surface** on
  `window.Etcher.layerFor(frescoId)` so every built-in button is
  callable from outside. Methods grouped by mode, visibility, tool,
  color, history, and shape selection/edit. Consumers can render
  their own toolbar and drive the layer headlessly.
- **CustomEvents** for state changes:
  `etcher:mode-changed`, `etcher:tool-changed`, `etcher:color-changed`,
  `etcher:visibility-changed`, `etcher:history-changed`,
  `etcher:tooltip-show / -hide / -pin / -unpin`.
- **Restored comment threads on undo-of-delete** — the etcher:created
  payload for a restore carries `restore_from_uuid` so consumers can
  re-link soft-deleted child rows (e.g. comments) to the new uuid the
  server assigns to the recreated annotation.
- **`appendNavButton` mutable handle (Fresco 0.1.2+)** — Etcher's nav
  buttons can now update their icon / title in place (used by the
  visibility toggle to flip eye ↔ eye-slash).

### Changed

- Default `:tools` list on `Etcher.Layer.layer/1` is now
  `[:rectangle, :circle, :polygon, :freehand, :callout, :text,
  :eraser]` (all seven). Pass an explicit list to subset.
- Text + title + callout bboxes shrink-wrap to the rendered text on
  every render; the stored geometry is rewritten to the shrunk
  dimensions on release so storage always matches what's visible.
- Vertex handles now inherit the shape's color (`currentColor`)
  instead of hard-coded orange. CSS hover / drag fills use
  `fill-opacity` so they tint correctly with whichever color the
  shape carries.
- Single-shape deletes now flow through the same compound
  `bulk_delete` undo op the eraser uses, so the tooltip trash button
  also gets redo support.

### Fixed

- Tooltip stops hijacking hover state on a different shape while
  another shape's tooltip is pinned.
- Pinned shape keeps its `.is-selected` outline when the cursor
  leaves it (was getting stuck visually deselected).
- Tooltip `.is-hovered` no longer sticks after the cursor leaves a
  pinned shape.

## [0.1.0] — 2026-05-06

Initial release.

### Added

- `Etcher.Layer` Phoenix LiveView function component — attaches an
  annotation overlay to a named Fresco viewer and adds a pencil button
  to its nav column.
- `Etcher.Storage` behaviour — pluggable storage adapter contract with
  four callbacks (`create/1`, `list_for/2`, `update/2`, `delete/1`).
- `Etcher.Storage.Default` — bundled implementation backed by the
  `etcher_annotations` table. Reads the consumer's Repo from
  `config :etcher, repo: …`.
- `Etcher.Annotation` Ecto schema for the bundled table (UUIDv7 primary
  key, `target_type` / `target_uuid`, four geometry kinds: rectangle,
  circle, polygon, freehand).
- `mix etcher.gen.migration` — generates the `etcher_annotations` table
  migration into the consumer's `priv/repo/migrations/`.
- JS engine at `priv/static/etcher.js` — registers the `EtcherLayer`
  LiveView hook, draws shapes as SVG overlays anchored to image
  coordinates, emits `etcher:created` / `:updated` / `:deleted` /
  `:selected` events.
- Bottom drawing toolbar with rectangle / circle / polygon / freehand
  tools; pencil-button toggle integrated with Fresco's nav column via
  `handle.appendNavButton/3` (Fresco 0.2+).

[0.2.2]: https://github.com/alexdont/etcher/releases/tag/v0.2.2
[0.2.1]: https://github.com/alexdont/etcher/releases/tag/v0.2.1
[0.2.0]: https://github.com/alexdont/etcher/releases/tag/v0.2.0
[0.1.0]: https://github.com/alexdont/etcher/releases/tag/v0.1.0