{"_id":"@aitofy/bugdeck","_rev":"2-7ac2cd8cceaa45ef4154fccb7a762b81","name":"@aitofy/bugdeck","dist-tags":{"latest":"0.2.0"},"versions":{"0.1.0":{"name":"@aitofy/bugdeck","version":"0.1.0","keywords":["marker-io-alternative","bugherd-alternative","userback-alternative","usersnap-alternative","jam-dev-alternative","bug-report-widget","bug-reporting","visual-feedback","screenshot","annotation","self-hosted","react","plane","plane-so"],"author":{"name":"aitofy"},"license":"MIT","_id":"@aitofy/bugdeck@0.1.0","maintainers":[{"name":"masterpk","email":"huanthuyon671@gmail.com"}],"homepage":"https://github.com/aitofy-dev/bugdeck/tree/main/packages/widget#readme","bugs":{"url":"https://github.com/aitofy-dev/bugdeck/issues"},"dist":{"shasum":"c4dbef2339f15d52e29d3695dfd0a345f58997f9","tarball":"https://registry.npmjs.org/@aitofy/bugdeck/-/bugdeck-0.1.0.tgz","fileCount":79,"integrity":"sha512-OZKhLkCbwb+LYOM4pm4mL7vvRe3YJA8KXRrJr1Bd60FD2ZH17FPwvNM9pkCK9/CzYx1X5ez16GXRvIRhfEAeOQ==","signatures":[{"sig":"MEUCIQC+WilZhFp7/XbQL/44H5PLPVS50n42D7jhxxn03JHazgIgbaCupc2IgOLCK+j1VX62rj/y0LaA6HtH0SZzA3lL8kA=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":231540},"main":"./dist/index.js","type":"module","_from":"file:aitofy-bugdeck-0.1.0.tgz","types":"./dist/index.d.ts","engines":{"node":">=20"},"exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js"}},"scripts":{"test":"tsx --test $(find src -name '*.test.ts')","build":"tsc -p tsconfig.build.json","typecheck":"tsc --noEmit","e2e:picker":"node e2e/picker-highlight.mjs"},"_npmUser":{"name":"masterpk","email":"huanthuyon671@gmail.com"},"_resolved":"/private/var/folders/kn/9vdzgqzj7f96007cb3tyr2j40000gn/T/05462d5f091bd394294ec2bb88aeb234/aitofy-bugdeck-0.1.0.tgz","_integrity":"sha512-OZKhLkCbwb+LYOM4pm4mL7vvRe3YJA8KXRrJr1Bd60FD2ZH17FPwvNM9pkCK9/CzYx1X5ez16GXRvIRhfEAeOQ==","repository":{"url":"git+https://github.com/aitofy-dev/bugdeck.git","type":"git","directory":"packages/widget"},"_npmVersion":"10.9.0","description":"Open-source Marker.io alternative: a React bug-report widget with screenshot, annotation and element picker.","directories":{},"sideEffects":false,"_nodeVersion":"22.11.0","dependencies":{"html-to-image":"^1.11.13","@aitofy/bugdeck-core":"0.1.0"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"tsx":"^4.19.2","react":"^19.0.0","react-dom":"^19.0.0","typescript":"^5.7.2","@types/node":"^22.10.2","@types/react":"^19.0.6","@types/react-dom":"^19.0.3"},"peerDependencies":{"react":">=18","react-dom":">=18"},"_npmOperationalInternal":{"tmp":"tmp/bugdeck_0.1.0_1789161429871_0.8331932094991126","host":"s3://npm-registry-packages-npm-production"}},"0.2.0":{"_id":"@aitofy/bugdeck@0.2.0","bugs":{"url":"https://github.com/aitofy-dev/bugdeck/issues"},"dist":{"shasum":"69f7651e753cb3468c811dd6f58908d17b467679","tarball":"https://registry.npmjs.org/@aitofy/bugdeck/-/bugdeck-0.2.0.tgz","fileCount":80,"integrity":"sha512-vsyrjtketKEo0iO1U+0vWM/EgpPO4jD1G+w6acTns+SylMbBFEO00Sa2jWMrwJ3ADzq+DHWtNJ6+jyjpp11iSw==","signatures":[{"sig":"MEUCIQCbLfytGC13zUUmmdAY3S71/FKgY1PQQfuViNINCIKYEwIgfRl8vCCvQcSAgQqo7WwoixFVQm14TPoA32jELXVZ6ww=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"},{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCIQD8oWrJgELiCuLN38ORkwh7z8OGw6jJwEzAtek1iNGb1QIgHff7+kNExpeTinb8MpoBk4GGgk7/KzfyY9HmCH0VSVU="}],"unpackedSize":233371},"main":"./dist/index.js","name":"@aitofy/bugdeck","type":"module","_from":"file:aitofy-bugdeck-0.2.0.tgz","types":"./dist/index.d.ts","author":{"name":"aitofy"},"engines":{"node":">=20"},"exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js"}},"license":"MIT","scripts":{"test":"tsx --test $(find src -name '*.test.ts')","build":"tsc -p tsconfig.build.json","typecheck":"tsc --noEmit","e2e:picker":"node e2e/picker-highlight.mjs"},"version":"0.2.0","_npmUser":{"name":"masterpk","email":"huanthuyon671@gmail.com"},"homepage":"https://github.com/aitofy-dev/bugdeck/tree/main/packages/widget#readme","keywords":["marker-io-alternative","bugherd-alternative","userback-alternative","usersnap-alternative","jam-dev-alternative","bug-report-widget","bug-reporting","visual-feedback","screenshot","annotation","self-hosted","react","plane","plane-so"],"_resolved":"/private/var/folders/kn/9vdzgqzj7f96007cb3tyr2j40000gn/T/6921c683ec5101960e6a4ddf743f1481/aitofy-bugdeck-0.2.0.tgz","_integrity":"sha512-vsyrjtketKEo0iO1U+0vWM/EgpPO4jD1G+w6acTns+SylMbBFEO00Sa2jWMrwJ3ADzq+DHWtNJ6+jyjpp11iSw==","repository":{"url":"git+https://github.com/aitofy-dev/bugdeck.git","type":"git","directory":"packages/widget"},"_npmVersion":"10.9.0","description":"Open-source Marker.io alternative: a React bug-report widget with screenshot, annotation and element picker.","directories":{},"maintainers":[{"name":"masterpk","email":"huanthuyon671@gmail.com"}],"sideEffects":false,"_nodeVersion":"22.11.0","dependencies":{"html-to-image":"^1.11.13","@aitofy/bugdeck-core":"0.2.0"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"tsx":"^4.19.2","react":"^19.0.0","react-dom":"^19.0.0","typescript":"^5.7.2","@types/node":"^22.10.2","@types/react":"^19.0.6","@types/react-dom":"^19.0.3"},"peerDependencies":{"react":">=18","react-dom":">=18"},"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/bugdeck_0.2.0_1789163994545_0.3483688007303505"}}},"time":{"created":"2026-09-11T21:17:09.662Z","modified":"2026-09-11T21:59:54.800Z","0.1.0":"2026-09-11T21:17:10.047Z","0.2.0":"2026-09-11T21:59:54.627Z"},"bugs":{"url":"https://github.com/aitofy-dev/bugdeck/issues"},"author":{"name":"aitofy"},"license":"MIT","homepage":"https://github.com/aitofy-dev/bugdeck/tree/main/packages/widget#readme","keywords":["marker-io-alternative","bugherd-alternative","userback-alternative","usersnap-alternative","jam-dev-alternative","bug-report-widget","bug-reporting","visual-feedback","screenshot","annotation","self-hosted","react","plane","plane-so"],"repository":{"url":"git+https://github.com/aitofy-dev/bugdeck.git","type":"git","directory":"packages/widget"},"description":"Open-source Marker.io alternative: a React bug-report widget with screenshot, annotation and element picker.","maintainers":[{"name":"masterpk","email":"huanthuyon671@gmail.com"}],"readme":"# bugdeck\n\nOpen-source [Marker.io](https://marker.io) / [BugHerd](https://bugherd.com) alternative — a floating \"Report a bug\" button for React apps. The user screenshots the page (or points at one element), draws on the shot, writes the report with the pictures inline, and presses Send. It lands on your own server, which files it into Plane, GitHub or Linear.\n\n**Zero UI dependencies**: one `<style>` tag, injected once, every rule scoped to the widget's own root so nothing leaks either way. Colours are CSS variables, so `theme` and `accent` are the whole design system. The single runtime dependency is `html-to-image`, imported lazily so it never enters the host app's initial bundle.\n\n```\nuser clicks 🐞 → modal (block editor) → POST {apiBase}/reports (multipart)\n                                            └→ @aitofy/bugdeck-server → tracker issue\n```\n\nAI agents: `llms.txt` ships next to this file in the tarball, and\n[`llms-full.txt`](https://raw.githubusercontent.com/aitofy-dev/bugdeck/main/llms-full.txt) is the\nwhole project — widget, server, core — in one document.\n\n## Install\n\n```sh\npnpm add @aitofy/bugdeck        # react and react-dom >= 18 are peers\n```\n\n## Use\n\n```tsx\nimport { FeedbackWidget, reportApiError } from '@aitofy/bugdeck';\n\n<FeedbackWidget\n  apiBase=\"/api\"                                        // required\n  fetchAuthHeaders={async () => ({ Authorization: t })} // awaited per send — tokens stay fresh\n  buildCommit={__COMMIT__}                              // the most useful context field there is\n  myReportsHref=\"/reports\"\n/>;\n\n// In the app's HTTP interceptor, so the last API failure rides along:\napiClient.interceptors.response.use(undefined, (err) => {\n  reportApiError(err);\n  throw err;\n});\n```\n\n### Props\n\n| Prop | Type | Default | What it does |\n|---|---|---|---|\n| `apiBase` | `string` | — | Base of the bugdeck server, e.g. `/api` or `http://localhost:3131`. Required. |\n| `fetchAuthHeaders` | `() => Record<string,string> \\| Promise<…>` | — | Awaited on every send, so a short-lived token is never stale. |\n| `buildCommit` | `string` | — | Commit the running bundle was built from. Attached to the report context. |\n| `myReportsHref` | `string` | `'/reports'` | Where the \"My reports\" link on the confirmation points. |\n| `captureTarget` | `() => HTMLElement \\| null` | `document.body` | What gets photographed. |\n| `theme` | `'light' \\| 'dark' \\| 'auto'` | `'auto'` | `auto` follows the reader's `prefers-color-scheme`. |\n| `accent` | `string` | `#2563eb` | Any CSS colour. Becomes `--bd-accent` for this widget only. |\n| `position` | `'bottom-right' \\| 'bottom-left' \\| 'top-right' \\| 'top-left'` | `'bottom-right'` | Which corner the launcher sits in. |\n| `offset` | `number` | `20` | Launcher distance from both edges of that corner, in px. |\n| `zIndex` | `number` | `2147483000` | Stacking for every layer the widget draws. |\n| `label` | `string` | `strings.launcherLabel` | Launcher label. Prefer `strings` when translating everything. |\n| `strings` | `Partial<WidgetStrings>` | English | Every user-visible word, including one line per server error code. |\n| `toPng` | `ToPng` | lazy `html-to-image` | Test seam. |\n\n`FeedbackEditor` — the composing half with no opinion about where the report goes — takes the same `strings`, `captureTarget`, `toPng` and `zIndex`, plus `initialText` / `initialImages` / `onSubmit`. A report page uses it for \"Edit\" and \"Comment\", so there is exactly one editor to fix.\n\n### Styling\n\nTen CSS variables, declared on the widget root and nothing else:\n\n`--bd-bg` · `--bd-fg` · `--bd-muted` · `--bd-border` · `--bd-accent` · `--bd-accent-fg` · `--bd-danger` · `--bd-radius` · `--bd-shadow` · `--bd-font`\n\nLight values are declared unconditionally, dark ones under `prefers-color-scheme` and under `[data-bd-theme=\"dark\"]`, so `theme=\"dark\"` works on a host that is otherwise light. Inline style is left for the values that are genuinely dynamic: coordinates, sizes, `zIndex`, the accent override. Animations are dropped under `prefers-reduced-motion`.\n\nOverride a token from the host app if a prop is not enough — the widget sets no `!important` except on the element picker's layer, which has to survive hostile host CSS:\n\n```css\n[data-bugdeck-widget] { --bd-radius: 4px; --bd-font: \"Inter\", sans-serif; }\n```\n\n### Translating\n\nThe server answers `{ \"error\": \"RATE_LIMITED\" }` and never a sentence, so every word lives in the widget and one object translates all of it:\n\n```tsx\n<FeedbackWidget\n  apiBase=\"/api\"\n  strings={{\n    launcherLabel: 'Signaler un bug',\n    send: 'Envoyer',\n    errors: { RATE_LIMITED: 'Trop de rapports cette heure-ci.' },\n  }}\n/>\n```\n\nAnything you leave out falls back to English; `errors` merges one level deeper, so a single code can be overridden on its own. `defaultStrings` is exported if you want to diff against it.\n\n## What the user can do\n\n| Task | How |\n|---|---|\n| Capture the page | **Capture screen** — the modal unmounts and a paint is awaited before the shot; the widget's own subtree is filtered out as a second net |\n| Capture one element | **Pick element** — a devtools-style overlay: hover highlights, click captures exactly that box, Esc cancels without losing the text already typed |\n| Add images | Drag-and-drop · paste Ctrl/Cmd+V · **Upload image**. Every route (capture included) **adds a block**, never replaces one. Cap: `FEEDBACK_MAX_ASSETS` (10), 10 MB each |\n| Draw on an image | **Draw** on a thumbnail → canvas editor: Pen / Box / Arrow / **Crop**, red or yellow, Undo, Save. Exports a full-resolution PNG; touch works |\n| Crop | In the draw editor: **Crop** → drag a selection (outside dims) → **Apply crop**. The picture becomes exactly that region at full resolution (cut from the source image, not from the scaled canvas), and the marks move with it. **Undo reverses a crop too** |\n| Interleave text and images | Block editor (`text \\| image`): an image is inserted at the caret, and the order survives all the way to the tracker |\n| Come back later | Drafts autosave **per page** (localStorage). Closing the modal, F5 and closing the tab all keep them; reopening the same page restores text and images with a one-line notice. The launcher shows a draft marker |\n| Leave the dialog | **Hide** (✕ · Esc · backdrop · the Hide button) puts it away and KEEPS everything · **Discard** throws it away, asking once inline when there is anything to lose |\n\n**One draft per PAGE** (`draftSlug` = `location.pathname`, query and hash dropped). A different bug on a different screen is a **different report**, not an appendix to the last one — a single global draft would merge two unrelated bugs into one issue that gets read to the end of the first paragraph and closed. Drafts live 7 days and are swept when the widget opens.\n\n## Wire format\n\n`POST {apiBase}/reports`, multipart. Field names come from `@aitofy/bugdeck-core/contract` — the\nbrowser-safe entry point, so bundling the widget never reaches `sharp` — and the widget and the\nroute cannot drift:\n\n| Field | Contents |\n|---|---|\n| `description` | Text — every text block joined by a blank line. A server that only reads this field still gets the whole report |\n| `context` | JSON: `url`, `viewport{width,height,dpr?}`, `userAgent`, `buildCommit?`, `lastApiError?{status,path,message,at?}` |\n| `blocks` | JSON, ordered; new images referenced by their index among the uploaded files. Additive — a server that ignores it loses only the interleaving |\n| `images` | A REPEATED `images` key (not `images[]`), ≤ `FEEDBACK_MAX_ASSETS` files, ≤ 10 MB each, png/jpeg/webp |\n\nEditing (`PATCH /reports/:id`) and commenting (`POST /reports/:id/comment`) use the **same three fields**. One difference: an image the report ALREADY has travels as `{kind:'image', assetId}` inside `blocks` instead of being uploaded again — stable ids are how a tracker adapter recognises its own attachments instead of adding a second copy of every screenshot. `imageIndex` therefore counts only the files actually in the body.\n\nStored images are served from `GET {apiBase}/assets/:id`, to the report's owner only.\n\n**Editing vs commenting are different things:**\n\n| | Edit the body | Comment |\n|---|---|---|\n| Route | `PATCH /reports/:id` | `POST /reports/:id/comment` |\n| When | ONLY while `pending` | **Every state**, including `done` |\n| Why | Nobody has read it yet, so a real edit is honest. From `doing` on, someone has — rewriting under them is how two people debug different bugs | The most valuable sentence a user writes is *\"it is still broken\"*, and it always arrives after the report is closed |\n\nSuccess is `201 { id, code? }` (`code` may arrive later: the tracker bridge runs async). Failures are `{ error: ErrorCode }` — the widget throws a `FeedbackSubmitError` carrying the code and `submitErrorMessage(err, strings)` turns it into a sentence. The server re-encodes every image (magic bytes, no SVG or GIF, capped at 5000×5000); client validation only buys a faster refusal, **the rules live on the server**.\n\n## Architecture\n\n```\nFeedbackWidget.tsx   launcher + filing a NEW report + the \"Sent\" screen\nLauncher.tsx         the floating pill: icon, label, draft dot, corner + offset\nFeedbackEditor.tsx   the shared composer (blocks + images + capture + picker + drafts).\n                     A report page reuses this exact file for Edit and Comment\nFeedbackModal.tsx    the composing dialog (the insertion point is the last focused text block)\nModalShell.tsx       scrim, header, body, footer — and every a11y rule: focus trap,\n                     aria-modal, scroll lock, Esc\nFeedbackSent.tsx     the confirmation, with the tracker reference\nEditorToolbar.tsx    Capture screen · Pick element · Upload, one segmented group\nBlockList.tsx        the document: auto-growing paragraphs, images as a contact sheet\nAnnotateToolbar.tsx  the bar over the canvas, and the keys that drive it (P R A C)\nicons.tsx            eleven outlines as path data. No icon dependency\ntheme.ts             theme, accent, launcher corner — the whole look, as three props\nstyles/              the stylesheet, by area: tokens, chrome, editor, overlays; `sheet.ts`\n                     injects it once, keyed by id\npicker-styles.ts     the picker's geometry, inline and `!important` (see Gotchas)\nimage-intake.ts      how an image gets in: dropped, dragged over, pasted\nuse-focus-trap.ts    Tab stays in the dialog; the page behind it stops scrolling\nuse-editor-draft.ts  restore on open, autosave while typing, save again on unload\nuse-screen-capture.ts the shot, and the phase that unmounts the modal before it\nuse-annotate-picture.ts loading the image being drawn on (StrictMode-safe)\nstrings.ts           every user-visible word, English defaults, `{name}` placeholders\nstrings-context.tsx  the merged dictionary, handed down without prop drilling\nexit-intent.ts       Hide (keep) vs Discard (drop) — ✕/Esc/backdrop mean Hide. Pure rule\ndraft-store.ts       per-page drafts: slug, defensive parse, budget, 7-day TTL\nAnnotateOverlay.tsx  canvas editor  ─┐\nElementPicker.tsx    element picker (portalled to body — see Gotchas) ─┤ thin UI over:\nannotate.ts          stroke→canvas, pointer→image coordinates, crop (rect + stroke shift).\n                     Pure, tested without a DOM\nblocks.ts            the block document model and the payload it serialises to\ncapture.ts           html-to-image wrapper: imagePlaceholder, 15s timeout, widget filter\nimages.ts            size/type/count validation against the contract's limits\nsubmit.ts            multipart body, endpoints, `FeedbackSubmitError`\ncontext.ts           url / viewport + pixel ratio / UA / commit / last API failure\napi-error.ts         `reportApiError()` — module-level store for an HTTP interceptor\ndescribe-error.ts    Event/Error → a readable line (html-to-image rejects raw Events)\n```\n\nEvery node the widget owns carries `WIDGET_ROOT_ATTR` (`data-bugdeck-widget`): the capture filter and the element picker both use it to avoid seeing themselves.\n\n## Gotchas\n\nHard-won. Each one cost a production bug; please do not undo them.\n\n- **A cross-origin image without CORS headers used to kill the whole capture**, with the message `undefined`. `html-to-image` fetches every image to inline it; on failure its catch sets `dataURL = options.imagePlaceholder || ''`, assigns that `''` to the cloned `<img>.src`, and the browser resolves `''` against the DOCUMENT URL — loading the page's own HTML as an image, firing `error`, and rejecting with a raw DOM `Event` that has no `.message`. Its module-level cache then remembers the failure for the life of the tab. The fix is four things: `CAPTURE_IMAGE_PLACEHOLDER` (a real 1×1 PNG, so a broken image becomes a grey box), no `cacheBust`, a 15 s `withTimeout`, and `describeError`.\n- **`cacheBust` is off deliberately.** It appends a timestamp to every image URL and defeats the cache the page already filled: 122 ms vs 15 ms for the same shot in Chrome 148, and it buys nothing now that a failed fetch degrades to a placeholder.\n- **`html-to-image` has no timeout and cannot be cancelled.** One image whose server never answers leaves the promise pending forever and the widget stuck on \"Capturing…\". 15 s, then give up; the losing promise is swallowed so a shot we abandoned cannot surface as an unhandled rejection in the host app.\n- **A transparent PNG makes Undo look dead** — redrawing over the old frame leaves the undone stroke showing through. `clearRect` before every repaint.\n- **The element picker must not have a pointer-catching overlay.** `elementFromPoint` would return the overlay itself. The design instead is: the widget's UI is `pointer-events:none`, `pointermove`/`click` are listened for on `document` in the capture phase (with `preventDefault`, so a link cannot navigate), and the widget's own chrome is skipped via `firstForeignElement`.\n- **Tokens are declared on `[data-bd-theme]`, never on the widget root.** Every widget root would re-declare the LIGHT palette on itself, and the annotate overlay is a nested root — it would have flipped back to light inside a dark dialog. Only the tops of each tree (launcher, dialog, picker layer) carry the attribute; everything below inherits.\n- **The highlight MUST declare its own z-index and MUST live on `document.body`.** An earlier version drew a `position:fixed` div with no z-index, rendered in place inside the host's tree. On a bare demo page it looked perfect; in a real app (fixed sidebar at `z-10`, sticky header) it was **painted over** — still present, rect correct to the pixel, completely invisible. The banner declared `zIndex+1` and did show, producing exactly the reported symptom: \"I click Pick element, I see the banner, nothing highlights.\" Measured with Playwright: hovering a content card gave `rgb(37,99,235)` on all four edges; hovering a sidebar item gave the sidebar's own `rgb(24,20,17)` on all four. The picker now draws into a **layer portalled to `document.body`** whose style is written with `!important` (`pickerLayerDecls` + `applyImportant`), so the host cannot change its position, stacking or visibility — and `position:fixed` cannot be broken by an ancestor with `transform`/`filter`/`contain`. That layer carries `WIDGET_ROOT_ATTR`; remove the attribute and the widget photographs and picks itself.\n- **The ring is `box-shadow: inset`, never a `border` on an inflated rect.** A 2 px border drawn OUTSIDE the rect leaves the viewport as soon as the hovered element reaches a screen edge — and a full-width panel reaches three at once, so the \"highlight\" vanishes while its rect is still correct.\n- **A DOM test for the picker is a blind test.** Both earlier fixes passed, because they asked \"is there a div, is its rect right\" and the broken build answered yes to both. The only check that separates \"drawn\" from \"drawn under the sidebar\" is **reading pixels out of a screenshot**: `e2e/picker-highlight.mjs`.\n- **Capture behaviour, if you change it:** opening the modal does NOT capture anything (nobody wants to be photographed by surprise), and a second capture ADDS an image rather than replacing the first.\n- **Drafts must be localStorage, not IndexedDB.** IndexedDB is the better store for blobs, right up until the instant the tab is closing — which is exactly when the draft is worth the most, and `beforeunload` cannot await anything. Two consequences: an image's data URL is **cached when the image is added** (a `FileReader` will not finish during unload), and there is a **budget** (~2 MB) — over it, IMAGES are shed newest-first, **text is never shed**, and the number of images lost is said out loud.\n- **Undo is a stack of STATES, not a stack of strokes.** Since Crop exists, one step back can mean \"drop a stroke\" or \"restore the picture before the crop\" — different kinds of thing. The stack holds whole `{picture, strokes}` pairs, which is what makes one Undo button honest about both. Strokes that fall outside the crop are **not deleted**; they take negative coordinates and draw nowhere, because deleting them would make Undo a liar.\n- **Crop cuts from the IMAGE, never from the displayed canvas.** The canvas is shown fit-contain; measuring the selection on screen and cutting there gives the wrong box at the wrong resolution. Every crop coordinate lives in image space (`cropRect`, `shiftStrokes` in `annotate.ts`, both tested).\n- **`URL.revokeObjectURL` in a cleanup + StrictMode = a phantom error.** The effect runs, tears down, runs again; the teardown revokes the first URL while its image is still loading, that image fires `error`, and the editor showed \"could not open this image\" over a picture that had loaded perfectly from the second URL. A `cancelled` flag is required.\n- **Drawing on an image DROPS its `assetId`** (when editing an existing report). Those are new bytes; keeping the id tells the server \"same image as before\" and the drawing never leaves the browser.\n- **Hide has to FLUSH the draft, not trust the autosave.** The 600 ms debounce is cleared when the editor unmounts, so everything typed in the last 600 ms — which, for a one-sentence report, is everything — went away with the dialog that promised to keep it. `Hide` writes the snapshot synchronously before closing, the same way `beforeunload` does.\n- **\"Cancel\" that KEEPS the draft is one word saying two things.** Once drafts autosaved, the old Cancel only closed the modal: whoever wanted to keep their text was afraid to press it, and whoever wanted it gone pressed it and met the draft again later. It is now two buttons with one rule in `exit-intent.ts` (pure, tested): **✕ · Esc · backdrop click all mean Hide** when there is a draft — the most reflexive gesture must be the one that loses nothing. With no draft (editing an existing report, commenting) the Hide button is not shown at all: promising to keep something nothing is keeping is a lie. The confirmation is **inline in the dialog**, never `window.confirm` (some mobile browsers suppress it silently, so the draft would vanish unasked), and `clearDraft` runs **before** closing — close first and the pending autosave writes the discarded draft back.\n\n## Screens\n\n`docs/screens/` — light and dark, straight out of the demo app: `launcher-*.png`, `modal-*.png`, `annotate-*.png`.\n\n## Test and build\n\n```sh\npnpm --filter @aitofy/bugdeck build       # tsc → dist/\npnpm --filter @aitofy/bugdeck typecheck\npnpm --filter @aitofy/bugdeck test        # node:test via tsx — no DOM needed\n```\n\nThe pure halves (annotate, blocks, images, submit, context, drafts, exit-intent) are tested without a browser.\n\n### The pixel test\n\n`e2e/picker-highlight.mjs` is the check for \"Pick element\". It hovers five points spread across the viewport and **reads the four edge pixels of the highlight out of a screenshot**. How it locates the highlight deliberately does NOT depend on how the highlight is drawn — it only asks \"which piece of widget chrome has the same box as the hovered element\" — so changing the implementation cannot make it pass falsely.\n\nIt needs a page with a **fixed sidebar and a sticky header**; that is what `examples/vite-react` provides, and it is the whole reason the bug survived two fixes against a bare demo page.\n\n```sh\npnpm --filter @aitofy/bugdeck build\npnpm --filter @aitofy/example-vite-react dev   # serves http://localhost:5173\npnpm --filter @aitofy/bugdeck e2e:picker\n```\n\nPlaywright is not a dependency of this repo. The script imports `playwright` from wherever it is installed; point `PLAYWRIGHT_MODULE` at a copy if that fails.\n\n| Env | Default | |\n|---|---|---|\n| `WIDGET_DEMO_URL` | `http://localhost:5173` | the page to test against |\n| `PLAYWRIGHT_MODULE` | bare `import('playwright')` | path to an installed Playwright |\n| `HEADED` | — | `1` to watch it happen |\n\nThe remaining DOM work (capturing, drawing) is verified in a real Chromium while developing — unit tests are blind to it.\n\n## Notes\n\n- Inline images on Plane use `<image-component>`, the Plane editor's internal format rather than a public API. Images are always attachments as well, so a format change costs the layout, never the pictures.\n- Embedding outside React needs a `<script>` build (bundled React, or a Preact port) — not done yet.\n\n## License\n\nMIT\n","readmeFilename":"README.md"}