diff --git a/AGENTS.md b/AGENTS.md index 83465c7..a10a1a3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Role -This repo is a focused browser extension for collecting structured feedback from web pages, PDFs, and browser-visible surfaces. Treat it as a practical workflow tool and a portfolio anchor, not a throwaway extension. +This repo is a focused browser extension for collecting structured Element feedback from webpages. New Region/PDF capture is out of scope; keep previously saved Region/PDF records readable and exportable. Treat it as a practical workflow tool and a portfolio anchor, not a throwaway extension. ## Public Direction @@ -22,7 +22,7 @@ This project should support Monroe's public story as a software engineer who bui ## Implementation Notes - Manifest V3 extension. -- Core files are `manifest.json`, `popup.*`, `content.js`, `capture.*`, `background.js`, `shared.js`, and `styles.css`. +- Core files are `manifest.json`, `popup.*`, `content.js`, `collector.js`, `element.*`, `background.js`, `shared.js`, and `styles.css`. - Keep permissions minimal and user-triggered. - Preserve local-history compatibility when changing saved item shapes. @@ -46,5 +46,5 @@ See `docs/agents/domain.md`. ## Verification - Run `npm test` and `npm run check` before claiming behavior is ready. -- For UI behavior changes, manually load the unpacked extension in Chromium/Edge and test both Element and Region modes. +- For UI behavior changes, manually load the unpacked extension in Chromium/Edge and test Element picking, private note entry, Save & pick next, History editing, and selected exports. Verify legacy Region/PDF History remains readable; do not restore Region capture to satisfy old checklists. - Run `git diff --check` before committing. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c75134..bbe6b93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 1.8.0 Element-focused candidate (unreleased) + +- Remove new Region/PDF capture and its screenshot/editor code. Preserve old records and exports. +- Replace the mode selector with one compact Pick an element action. +- Use a smaller private note editor with optional acceptance checks. +- Add Save & pick next and Cmd/Ctrl+Enter shortcuts. +- Open History in a private on-page panel, with an extension-menu fallback on restricted pages. +- Edit saved requests and acceptance checks while preserving captured context. +- Keep selected export previews, private History access, and draft-preserving save failures. + +The earlier hardening entry below describes the broader intermediate candidate; its Region creation UI is superseded. + + ## 1.8.0 hardening candidate (unreleased) - Keep Element and ordinary-page Region capture in private overlays; use a capture window for protected browser surfaces. diff --git a/CONTEXT.md b/CONTEXT.md index 3d0f8fc..ed6aac1 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1,13 +1,25 @@ # Dev Feedback Capture Context -## Glossary - -- **Page Capture.** Feedback captured from a browser-visible web page or PDF. -- **Element Capture.** A Page Capture focused on one identifiable page element and its surrounding evidence. -- **Region Capture.** A Page Capture focused on a selected visible region, annotations, and supporting context. -- **Capture Record.** One saved, portable feedback item with its request, evidence, and source context. -- **History.** The user-facing collection of saved Capture Records. -- **Feature Label.** A human-readable identity for a selected interface element, derived from explicit accessibility names, roles, and allowlisted Host App feature attributes. -- **Agent Handoff.** An explicit local transfer of Capture Records to a coding agent for implementation and separate verification. -- **Host App.** An Electron application whose developer explicitly installs and activates the Electron Inspector. -- **Electron Inspector.** The developer-only adapter that captures Host App interface feedback as Capture Records. It does not load or simulate the browser extension. +Feedback about identifiable webpage elements, collected for implementation and explicit verification. + +## Language + +**Element Capture**: Feedback about one selected webpage element, with its identity and observed page context. + +**Change Request**: The user's description of what should change about an element. + +**Acceptance Check**: A user-written condition for deciding whether a requested change is complete. + +**Capture Record**: One saved feedback item with its change request, acceptance checks, and source context. + +**History**: The user-facing collection of saved Capture Records. + +**Legacy Region Capture**: A saved screenshot-based record from an earlier version, including any annotations and source context. + +**Agent Handoff**: An explicit transfer of selected Capture Records to a coding agent for implementation and separate verification. + +**Feature Label**: A human-readable identity for an interface element, derived from accessibility names, roles, or declared application attributes. + +**Host App**: An Electron application whose developer explicitly installs and activates the Electron Inspector. + +**Electron Inspector**: The developer-only adapter that captures Host App element feedback as Capture Records. diff --git a/QUICKSTART.md b/QUICKSTART.md index cc425c8..e312eca 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -1,80 +1,14 @@ -# Quick Start Guide +# Quick start -Get Dev Feedback Capture running in a few minutes. +1. Install Dev Feedback Capture from the Chrome Web Store, or load the unpacked extension folder in Chrome/Edge Developer Mode. +2. Open a webpage, click the extension icon, and choose **Pick an element**. +3. Click the element and write what should change. Acceptance checks are optional. +4. Choose **Save note** or **Save & pick next**. +5. Open **History & export**. Edit a note if needed, then select the records you want to share. +6. Choose an export and review its notes, captured fields, and any legacy images before confirming. -This guide describes the unreleased 1.8.0 candidate: Element, Region/PDF, History, and one explicit Agent Handoff. Store and GitHub release notes in this repository preserve earlier submission evidence. +Cmd/Ctrl+Enter saves a note; adding Shift picks the next element. While picking, Tab to the target and press Alt+Enter. Escape stops picking or offers to discard the draft. -## 1. Install the extension +**Copy AI Prompt** gives you text to paste into a coding agent. **Send to Codex** downloads JSON for the separately configured local MCP companion. See `docs/mcp-local-agent.md` for inbox and project setup. -Preferred: install the public release from the [Chrome Web Store](https://chromewebstore.google.com/detail/dev-feedback-capture/hhdmfaaplpiokafjieefpgoppckijafc). - -For source or fallback installation: - -1. Download the latest `dev-feedback-capture-v.zip` asset from GitHub Releases. -2. Unzip it. -3. Open `chrome://extensions/` or `edge://extensions/`. -4. Enable `Developer mode`. -5. Click `Load unpacked`. -6. Select the unzipped extension folder. - -Optional for local PDFs: - -7. Open the extension details page. -8. Enable `Allow access to file URLs`. - -## 2. Capture an element - -1. Open any `http`, `https`, or `file` page you want to inspect. -2. Open the extension popup. -3. Leave the mode on `Element`. -4. Click `Start Element Mode` or press `Ctrl+Shift+F` (`Command+Shift+F` on macOS). -5. Click a page element, add your note, and save it. -6. Save or cancel in the private overlay. Your source tab stays in place; use History to review saved captures. - -## 3. Compile an annotated region spec - -1. Open the target page or PDF in the browser. -2. Open the extension popup and switch to `Region`. -3. Click `Capture Region`. -4. Use `Crop` in the overlay to define the evidence area. Protected browser surfaces use a separate capture window. -5. Add arrows, rectangles, ellipses, numbered pins, text, or blur/redact marks. Use Undo and Redo as needed. -6. Describe the requested change and optionally add one acceptance check per line. -7. Save the visual change spec. - -## 4. Export saved feedback - -Open the extension popup and select `Open History & Export`. This extension-owned page works for captures from normal pages, PDFs, and other surfaces where the in-page panel is unavailable. Select the captures to share, choose an export, and review the preview before confirming. Filters clear selection and hidden captures stay out of exports. From History, you can: - -- Download one `AI Bundle` ZIP with `prompt.md`, structured feedback and page context, before/annotated PNGs, and `report.html` -- Choose `Send to Codex` to place the selected handoff payload in the configured local Downloads inbox for MCP import -- Download a self-contained `HTML Report` with embedded region images -- Copy `Markdown` for issue trackers or docs -- Copy `AI Prompt` for ready-to-paste implementation instructions based on saved text and source context - -AI Prompt is text-only. Use AI Bundle when the implementation handoff needs its numbered evidence images. - -## 5. Give a local agent project-scoped feedback - -1. In History, choose `Send to Codex` to review and download the selected captures. -2. Configure the MCP companion with the absolute target project path and the browser Downloads folder. -3. Ask the agent to call `dev_feedback_import_latest`. If the handoff contains multiple site/file groups, provide the exact `storageKey` shown by the first rejected import. -4. The agent can call `dev_feedback_list`, `dev_feedback_get`, and `dev_feedback_build_brief`, implement changes with its normal project tools, then record progress with `dev_feedback_status_update`. - -Codex setup is one command per target project: - -```sh -codex mcp add dev-feedback -- node /absolute/path/to/webDevFeedbackExt/mcp/cli.mjs \ - --project /absolute/path/to/project \ - --inbox /absolute/path/to/Downloads -``` - -The extension places the file in the inbox; users do not need to move it manually. Implementation and verification remain separate steps. - -Setup and security boundaries are in `docs/mcp-local-agent.md`. - -## Need Help? - -- Full docs: see `README.md` -- PDF capture issues on local files: check `Allow access to file URLs` -- Element capture unavailable: use `Region` mode on non-injectable browser surfaces -- Region capture saves viewport-only crops in v1, not full-page screenshots +New Region/PDF capture is no longer offered. Previously saved records remain available in History. Capture requires an accessible webpage; browser-internal pages and PDF viewers are unsupported. diff --git a/README.md b/README.md index a2ac790..f4d3a3c 100644 --- a/README.md +++ b/README.md @@ -1,208 +1,77 @@ # Dev Feedback Capture -Turn browser-visible feedback into a local, buildable handoff. Dev Feedback Capture focuses on four connected surfaces: Element capture, Region/PDF capture, History, and one explicit Agent Handoff. +Pick a webpage element, describe the change, and give another developer enough context to act on it. -> This branch documents the unreleased 1.8.0 candidate. Store and GitHub downloads may contain earlier versions; source changes are not publication evidence. +The 1.8.0 candidate focuses on Element capture, local History, and explicit selected exports. Published Store versions may differ until this candidate completes review. -- `Element` capture records a selected DOM element with selectors, visible text, styles, and a requested change. -- `Region` capture records a visible page or PDF region with a crop, annotations, source context, and acceptance checks. -- `History` keeps saved Capture Records together on the device and provides review and export actions. -- `Send to Codex` is the named Agent Handoff: select captures and review the export through the browser, let the local MCP companion import the newest valid handoff from its configured Downloads inbox, and keep implementation and verification as separate agent steps. +## Capture feedback -Feedback stays local until you explicitly export it. There is no cloud sync, hosted AI connection, automatic browser control, or Electron injection in the browser extension. +1. Open the extension on a webpage and choose **Pick an element**. +2. Click the target. A compact private note editor opens beside the page. +3. Describe the requested change. Add optional acceptance checks and inspect the captured details. +4. Choose **Save note**, or **Save & pick next** to review several elements. +5. Open **History & export** in an on-page panel to edit notes and acceptance checks, select records, and review the export preview before sharing. -## Features +Picking also works with the extension shortcut: `Ctrl+Shift+F`, or `Command+Shift+F` on macOS. If the browser has not assigned it, set it in extension shortcut settings. While picking, focus a target with Tab and press Alt+Enter. Escape stops picking or offers to discard a draft. In the editor, Cmd/Ctrl+Enter saves; adding Shift starts the next pick. -- Element capture with selector, text, style, position, and note metadata -- Region capture for normal pages, hosted PDFs, and local PDFs when file access is enabled -- Crop, arrow, rectangle, ellipse, numbered pin, text, blur/redact, color, undo, and redo tools for Region captures -- DOM-linked vector annotations with selector fallbacks, roles, geometry, and parent-layout context when the source DOM is available -- Optional acceptance checks plus browser, viewport, scroll, zoom, DPR, and source metadata -- Works on arbitrary sites through explicit user-triggered activation -- Extension-owned History page that works even when the source page cannot accept injected UI -- One downloadable AI Bundle ZIP plus standalone JSON and self-contained HTML reports -- Project-scoped local MCP companion over stdio; no cloud or localhost service -- Copyable Markdown and implementation-prompt exports -- Minimal permissions and user-triggered activation +Each note keeps its selector, visible element text, selected styles, page context, and acceptance checks. Form input values and surrounding parent text are not collected directly. Captured text and your own notes can still contain private information; review them before sharing. -## Installation +## Install -### Chrome Web Store +Install the public version from the [Chrome Web Store](https://chromewebstore.google.com/detail/dev-feedback-capture/hhdmfaaplpiokafjieefpgoppckijafc). -Install the current public release from the [Chrome Web Store](https://chromewebstore.google.com/detail/dev-feedback-capture/hhdmfaaplpiokafjieefpgoppckijafc), then pin Dev Feedback Capture for quick access. +For a source build or [GitHub release ZIP](https://github.com/StoneHub/webDevFeedbackExt/releases): unzip the package, open `chrome://extensions/` or `edge://extensions/`, enable Developer Mode, and choose **Load unpacked**. Select the extension folder. No build or Node dependencies are required to load the browser extension. -### GitHub Release ZIP fallback +## Review and share -1. Download the latest `dev-feedback-capture-v.zip` asset from [GitHub Releases](https://github.com/StoneHub/webDevFeedbackExt/releases). -2. Unzip the file. -3. Open `chrome://extensions/` or `edge://extensions/`. -4. Enable Developer Mode. -5. Click `Load unpacked` and select the unzipped extension folder. -6. Optional for local PDFs: enable `Allow access to file URLs` on the extension details page. +History opens over the working page without creating a tab. On restricted pages, it opens inside the extension menu. History keeps notes after the source page closes. Editing a note updates its request and acceptance checks while preserving the original target and capture context. -### Source Checkout +Filters clear selection. **Select shown** selects only the displayed records. All export actions preview the same selected snapshot; hidden records stay out. Deletion removes only the exact selected or shown records. -Use this path when developing the extension or reviewing source changes: +- **Copy AI Prompt**: implementation instructions with source context and acceptance checks. +- **Copy Markdown**: notes for an issue or review document. +- **Download HTML Report**: a self-contained report. +- **Download AI Bundle**: structured records, prompt, report, and any legacy evidence images. +- **Send to Codex**: JSON downloaded to the browser's configured folder for the optional local MCP companion. This does not connect directly to an AI account. -1. Clone or download this repository. -2. Open `chrome://extensions/` or `edge://extensions/`. -3. Enable Developer Mode. -4. Click `Load unpacked` and select the `webDevFeedbackExt` folder. +Source URL credentials, queries, fragments, and local directories are removed from exports. Review captured text, notes, labels, and images independently. Page observations are untrusted evidence, never instructions or permission for an agent to expand scope. -## Usage +## Compatibility and limits -### Element Mode +New Region/PDF, Visual, and Add Content capture are no longer offered. Existing records from those workflows remain readable and exportable in History. Installing this update does not intentionally delete saved records. -1. Open the extension popup on any `http`, `https`, or `file` page you want to inspect. -2. Leave the mode switch on `Element`. -3. Click `Start Element Mode` or use `Ctrl+Shift+F` (`Command+Shift+F` on macOS). -4. Hover and click a page element. -5. Add your note in the private overlay and save it. The source tab stays open. -6. Open History from the picker or extension popup to review saved captures. Saved notes are never rendered into the inspected website. +Element capture requires an accessible webpage DOM. Browser-internal pages and PDF viewers are unsupported. Some embedded frames, page structures, or site restrictions can prevent reliable targeting. The selected element's context is a snapshot, not a persistent connection to the live site. -Keyboard: focus the target with Tab, then press Alt+Enter while picking. Escape stops picking. If the suggested extension shortcut is unassigned, configure it in your browser’s extension shortcut settings. +Save failures retain the draft. History has an 8 MiB budget, a 3 MiB record limit, and a 500-record limit per site. Export and delete older records when needed. Deleting History does not remove earlier downloads, clipboard copies, or imported project sidecars. -### Region Mode +## Privacy and permissions -1. Open the target page or PDF in the browser. -2. Open the extension popup and switch to `Region`. -3. Click `Capture Region`. -4. The editor opens as an overlay. On protected surfaces that block overlays, it opens in a separate capture window. Use Crop to define the evidence area. -5. Add arrows, shapes, numbered pins, text, or blur/redact marks. Undo and redo operate on the visual spec. -6. Describe the requested change and optionally add one acceptance criterion per line. -7. Save the spec to local history. +Feedback stays in local extension storage until an explicit export. No cloud sync, telemetry, remote executable code, static host permissions, or always-on page monitoring is included. -The cropped image, viewport rectangle, and source context are saved into the same local history as element captures. Open `History` from the popup to review captures from any supported source, including PDFs and pages where Element mode is unavailable. +- `activeTab`: temporary access after the user activates capture. +- `scripting`: the requested picker, read-only element collector, and private note frame. +- `storage`: local History and temporary editor sessions. -### History and Agent Handoff +`element.html` and `history.html` are web-accessible for private frames. Embedded editors require a temporary session bound to the source tab and editor document. The website does not receive your saved History or note fields. It can still interfere with the overlay's placement. See [SECURITY.md](SECURITY.md) for reporting and trust boundaries. -Open `History` from the popup to review captures from any supported source. Select the captures to share, then choose `Send to Codex`. Review the export preview and confirm. Every export uses that same selected snapshot. Changing the filter clears selection; hidden captures are excluded. Delete shown removes only the displayed captures in that group. When the browser download location matches the MCP companion's configured Downloads inbox, the companion discovers the newest valid handoff and imports it into the target project's ignored `.dev-feedback` sidecar without manual file movement. +## Local agent companion -The handoff contract is deliberately explicit: +The separate Node MCP companion imports the selected JSON handoff into a target project's ignored `.dev-feedback` folder. It can list and read records, expose legacy evidence resources, build an implementation brief, and record revision-checked progress. -1. The extension captures and saves a Capture Record. -2. The user selects and reviews captures, then sends that handoff to Downloads. -3. MCP imports the newest valid handoff and exposes its records, evidence, and implementation brief. -4. The coding agent implements the requested change with its normal project tools. -5. The agent records implementation and verification separately. +The companion does not control the browser, execute shell commands, or edit source. The connected agent uses its normal tools. Cloud-backed clients may transmit tool results under their provider's policies. Setup and inbox configuration are documented in [docs/mcp-local-agent.md](docs/mcp-local-agent.md). -No step gives the extension browser control, source-editing authority, or an automatic cloud bridge. +## Electron developer package -## Data Model +Electron developers can explicitly install `@flyingchangescode/dev-feedback-electron` in a development Host App. This is a separate package, not part of the Chrome Web Store extension. See [packages/electron-inspector/README.md](packages/electron-inspector/README.md). -Stored feedback items use a discriminated shape: +## Development and release -- `type: "element"` items include selector, element information, position, request text, and source context. -- `type: "region"` items include one evidence crop, vector annotations, DOM anchors when available, acceptance criteria, and page context. -- Older Capture Records remain loadable and are normalized without inventing missing evidence or mutations. Historical Visual and Add records remain compatible as records even though those creation surfaces are not part of the active product. +Run `npm ci`, `npm test`, `npm run check`, `npm run audit:dependencies`, `npm run package`, and `npm run verify:package`. The browser ZIP excludes tests, MCP code, and Node dependencies. -Annotated PNGs are rendered locally when the AI Bundle is built. Large image data stays in local extension storage until you export or clear History. +Before publishing, follow [docs/manual-release-checklist.md](docs/manual-release-checklist.md). Tagged GitHub releases are created as drafts; Store submission and Google approval are separate steps. -## Export Formats - -- `Send to Codex` writes the current History as a local handoff payload for MCP import. -- `Download AI Bundle` creates `prompt.md`, `feedback.json`, `page-context.json`, available evidence PNGs, and `report.html` in one local ZIP. - -- The `Send to Codex` JSON includes the full saved payload, including region image data URLs, for explicit local import. -- `Download HTML Report` creates a self-contained review with embedded region images. -- `Copy Markdown` creates a readable text review for issues or docs. -- `Copy AI Prompt` creates numbered implementation instructions from the saved requirements, anchors, and acceptance checks. Download the AI Bundle when images are needed. - -## Local MCP Agent Companion - -The Node MCP companion under `mcp/` imports an explicit History export from a configured local inbox into the target project's ignored `.dev-feedback` sidecar. It can list and get feedback, read evidence resources, create agent-authored feedback, build an implementation brief, and record revision-checked status. - -The companion does not read Chromium's internal storage, open a network port, control the browser, execute shell commands, or edit source code. The connected agent uses its normal browser and coding tools. Tool results and evidence are still delivered to that MCP client, so cloud-backed clients may transmit captured data under their provider policies. See [docs/mcp-local-agent.md](docs/mcp-local-agent.md) for setup and the trust boundary. - -## Electron Inspector developer package - -Electron apps cannot use Chrome's extension toolbar or attach this browser extension from Chrome. Developers can instead add the free, MIT-licensed `@flyingchangescode/dev-feedback-electron` development package to their app. - -The Host App integration is one guarded import in its main-process entry, before any window is created: - -```js -if (!app.isPackaged) await import('@flyingchangescode/dev-feedback-electron/register') -``` - -Press `Cmd/Ctrl+Shift+.` in any registered development window to start Element capture. The package owns its session preload, shortcut, package-owned overlay, local History under Electron `userData`, and `Copy History` action. It does not require React, replace the Host App preload, or expose Electron IPC to the Host App renderer. See [packages/electron-inspector/README.md](packages/electron-inspector/README.md) for installation and the trust contract. - -## Permissions - -The extension requests: - -- `storage` for local history -- `activeTab` for temporary, user-invoked access to the current tab -- `scripting` to collect the selected element and open the capture overlay only when requested - -Only the two capture editor HTML entry points are web-accessible so they can appear in extension-origin frames. The message broker restricts each editor to its own session and keeps global History access in the top-level History page. - -The extension does not use static host permissions, always-on content scripts, telemetry, or network sync. Region captures can include visible page content in screenshot data URLs; those crops stay in local extension storage until the user clears history or removes the extension. - -## PDF Notes - -- Hosted PDFs should work through Region mode because the capture flow is screenshot-based. -- Local `file://` PDFs may require enabling `Allow access to file URLs`. -- Region mode captures only the visible viewport in v1, not off-screen PDF pages. - -## Development - -### Project Files - -- `manifest.json`: Manifest V3 configuration -- `background.js`: runtime injection and Region-capture orchestration -- `content.js`: public picker and private editor frame host -- `collector.js`: read-only DOM snapshot collection -- `element.html` / `element.js`: private Element note editor -- `mcp/`: project-scoped stdio MCP companion and filesystem sidecar store -- `capture.html` / `capture.js`: screenshot region selection editor -- `popup.html` / `popup.js`: mode switch, current-tab actions, History entry point, and handoff action -- `history.html` / `history.js`: extension-owned history review and export controls -- `ai-bundle.js`: local, dependency-free AI Bundle assembly and ZIP creation -- `shared.js`: shared helpers, legacy normalization, and export formatting -- `styles.css`: injected in-page UI styles -- `docs/store-monetization-readiness.html`: local store identity, listing, privacy, and future paid-product decision artifact - -### Local Checks - -- `npm test` -- `npm run check` -- `npm run package` - -### Release Process - -1. Confirm `package.json` and `manifest.json` versions match. -2. Run `npm test`, `npm run check`, and `npm run package`. `npm test` covers both extension and MCP contracts. -3. Complete the package, listing, and manual unpacked-extension gates in `docs/manual-release-checklist.md`, then create and push the matching version tag when publishing a GitHub Release. -4. The release workflow builds `dist/dev-feedback-capture-v.zip` and attaches it to a draft GitHub Release. Publish the draft only after recording the runtime and artifact checks. - -See [CHANGELOG.md](CHANGELOG.md) for release notes. - -## Limitations - -- Element mode depends on DOM/script injection and is not intended for browser-internal surfaces. -- Historical Visual and Add Capture Records may still be read and normalized, but those creation surfaces are not active product workflows. -- Region mode stores one crop plus vector metadata in local storage; very large capture histories will still increase storage usage. -- Export previews remove URL credentials, query strings, fragments, and local file directories. Page content, images, and your notes can still contain private information; review them before sharing. -- Save failures retain the draft. History has an 8 MiB budget, a 3 MiB item limit, and a 500-capture limit per site; export and delete older captures when capacity is reached. -- A redacted Region discards all DOM annotation anchors and page titles, and reduces its source URL to the origin. User-written notes and labels remain. -- Blur/redact masks are applied to the saved crop before the transient viewport screenshot is discarded, so AI Bundle “before” evidence does not restore redacted pixels. -- DOM annotation anchors are best-effort and are unavailable for protected browser pages, PDFs without an accessible DOM, cross-origin frames, and pages that move after capture. -- Region mode captures the current viewport only, not full-page stitched screenshots. -- Cross-origin iframe DOM capture remains limited by browser security rules. -- Chrome's extension menu cannot attach this browser extension to an arbitrary already-running Electron app. Electron developers must explicitly install the separate Electron Inspector package in their Host App. - -## Roadmap - -- Add verification against saved acceptance criteria -- Add full-page or multi-step PDF region capture -- Add user-triggered import back into extension History -- Publish and dogfood the Element-only Electron Inspector across more development apps, then decide whether Region capture earns the additional screenshot permission and storage surface -- Evaluate a private cross-app History Hub only after app-local History and explicit clipboard export prove useful +Core files: `popup.*`, `content.js`, `collector.js`, `element.*`, `background.js`, `history.*`, `shared.js`, and `ai-bundle.js`. ## License -Dev Feedback Capture and its Electron package are free software under the [MIT License](LICENSE). - -## Support - -Dev Feedback Capture stays free, local-first, and available without an account. If it saves you time, you can [support its development](https://buy.stripe.com/eVqaEZghLdhKaka0NJawo01) with an optional one-time thank-you payment. Payment does not unlock features or services. +Free software under the [MIT License](LICENSE). diff --git a/background.js b/background.js index ee3f1e2..fa66a74 100644 --- a/background.js +++ b/background.js @@ -9,13 +9,14 @@ buildFeedbackId, canInjectIntoUrl, sanitizeFeedbackItems, - getEffectivePageUrl + detectSourceKind } = globalThis.DevFeedbackShared; const REGION_SESSION_MAX_AGE_MS = 30 * 60 * 1000; const mutationQueues = new Map(); const ELEMENT_SESSION_PREFIX = 'dev-feedback-element-session-'; - const SESSION_PREFIXES = [REGION_CAPTURE_SESSION_PREFIX, ELEMENT_SESSION_PREFIX]; + const HISTORY_SESSION_PREFIX = 'dev-feedback-history-session-'; + const SESSION_PREFIXES = [REGION_CAPTURE_SESSION_PREFIX, ELEMENT_SESSION_PREFIX, HISTORY_SESSION_PREFIX]; const MAX_HISTORY_BYTES = 8 * 1024 * 1024; const MAX_ITEM_BYTES = 3 * 1024 * 1024; const MAX_ITEMS_PER_SITE = 500; @@ -32,16 +33,15 @@ try { const url = new URL(sender.url); const page = url.pathname.slice(1); - if (sender.frameId && !['element.html', 'capture.html'].includes(page)) return ''; - return url.protocol === new URL(chrome.runtime.getURL('')).protocol && url.host === new URL(chrome.runtime.getURL('')).host && ['popup.html', 'history.html', 'capture.html', 'element.html'].includes(page) ? page : ''; + if (sender.frameId && !['element.html','history.html'].includes(page)) return ''; + return url.protocol === new URL(chrome.runtime.getURL('')).protocol && url.host === new URL(chrome.runtime.getURL('')).host && ['popup.html', 'history.html', 'element.html'].includes(page) ? page : ''; } catch { return ''; } } async function ownedSession(sender, page) { const id = new URL(sender.url).searchParams.get('session'); if (!id || !/^[a-zA-Z0-9-]{1,100}$/.test(id)) throw new Error('Invalid capture session.'); - const prefix = page === 'element.html' ? ELEMENT_SESSION_PREFIX : REGION_CAPTURE_SESSION_PREFIX; - const key = prefix + id; + const key = (page === 'history.html' ? HISTORY_SESSION_PREFIX : ELEMENT_SESSION_PREFIX) + id; const session = (await chrome.storage.session.get(key))[key]; if (!session || !Number.isFinite(Date.parse(session.createdAt)) || session.editorTabId !== sender.tab?.id || Date.now() - Date.parse(session.createdAt) > REGION_SESSION_MAX_AGE_MS) { throw new Error('This capture session expired or belongs to another editor.'); @@ -66,8 +66,14 @@ const contentSender = !page && sender.frameId === 0 && Number.isInteger(sender.tab?.id) && canInjectIntoUrl(sender.url) && sender.url === sender.tab.url; if (!page && !contentSender) throw new Error('Untrusted request sender.'); if (request.action === 'open-history' && (page || contentSender)) { - await chrome.tabs.create({ url: chrome.runtime.getURL('history.html') }); - return { ok:true }; + const tabId = contentSender || sender.frameId ? sender.tab?.id : request.tabId; + const tab = Number.isInteger(tabId) ? await chrome.tabs.get(tabId) : (await chrome.tabs.query({active:true,currentWindow:true}))[0]; + if (!tab?.id || !canInjectIntoUrl(tab.url) || detectSourceKind(tab.url) === 'pdf') return {ok:true,usePopup:true}; + await assertCaptureTab(tab); + const injected = await ensureContentScript(tab.id, tab.url); + if (!injected.ok) return {ok:true,usePopup:true}; + const session = {sessionId:buildFeedbackId(),tabId:tab.id,pageUrl:tab.url,createdAt:new Date().toISOString()}; + return openCaptureEditor(HISTORY_SESSION_PREFIX,session,'history.html'); } if (request.action === 'start-element-capture' && contentSender) return startElementCapture(sender, request.snapshot); if (request.action === 'ensure-content-script' && page === 'popup.html') { @@ -75,38 +81,48 @@ await assertCaptureTab(tab); return ensureContentScript(tab.id, tab.url); } - if (request.action === 'start-region-capture' && (page === 'popup.html' || contentSender)) { - const tab = await chrome.tabs.get(contentSender ? sender.tab.id : request.tab?.id); - return startRegionCapture(tab); - } if (page === 'history.html') { + if (sender.frameId) { + const {key,session} = await ownedSession(sender,page); + if (request.action === 'close-history') { + await chrome.storage.session.remove(key); + await chrome.tabs.sendMessage(session.tabId,{action:'close-capture-overlay',sessionId:session.sessionId},{frameId:0}).catch(()=>{}); + return {ok:true}; + } + } + + if (request.action === 'edit-feedback-note') { + if (typeof request.itemId !== 'string' || typeof request.note !== 'string' || !request.note.trim() || request.note.length > 2000) throw new Error('Write a note of up to 2000 characters.'); + return mutateFeedbackItems(request.storageKey, items => { + if (!items.some(item => item.id === request.itemId)) throw new Error('This note was deleted. Refresh History.'); + return items.map(item => item.id === request.itemId ? { + ...item, note:request.note.trim(), acceptance:request.acceptance, + changeRequest:{ ...item.changeRequest, summary:request.note.trim() } + } : item); + }); + } if (request.action === 'list-feedback-history') return listFeedbackHistory(); if (request.action === 'delete-feedback-items') { if (!Array.isArray(request.itemIds) || request.itemIds.length > MAX_ITEMS_PER_SITE || request.itemIds.some(id => typeof id !== 'string')) throw new Error('Invalid selection.'); return mutateFeedbackItems(request.storageKey, items => items.filter(item => !request.itemIds.includes(item.id))); } } - if (page === 'capture.html' || page === 'element.html') { + if (page === 'element.html') { const { key, session } = await ownedSession(sender, page); if (request.action === 'get-capture-session') return { ok:true, session }; - if (request.action === 'clear-region-session' || request.action === 'clear-capture-session') { + if (request.action === 'clear-capture-session') { await chrome.storage.session.remove(key); - if (session.embedded) await chrome.tabs.sendMessage(session.tabId, { action:'close-capture-overlay', sessionId:session.sessionId }, { frameId:0 }).catch(()=>{}); + if (session.embedded) await chrome.tabs.sendMessage(session.tabId, { action:'close-capture-overlay', sessionId:session.sessionId, pickNext:request.pickNext === true }, { frameId:0 }).catch(()=>{}); return { ok:true }; } - if (request.action === 'resolve-annotation-target' && page === 'capture.html') { - return resolveAnnotationTarget(session.tabId, request.point, { ...request.pageContext, url:session.pageUrl }); - } if (request.action === 'add-feedback-item') { if (!request.item || typeof request.item !== 'object') throw new Error('Missing capture.'); - const source = { ...request.item, id:session.sessionId, pageUrl:session.pageUrl, pageTitle:session.pageTitle }; - if (page === 'element.html') { - Object.assign(source, { type:'element', captureType:'element', selector:session.snapshot.selector, elementInfo:session.snapshot, position:session.snapshot.position, pageContext:session.pageContext }); - } else { - source.type = source.captureType = 'region'; - if (!source.screenshot?.dataUrl) throw new Error('Missing region evidence.'); - } - const item = globalThis.DevFeedbackShared.createCaptureRecord(source); + const item = globalThis.DevFeedbackShared.createElementRecord({ + id:session.sessionId, pageUrl:session.pageUrl, pageTitle:session.pageTitle, + selector:session.snapshot.selector, elementInfo:session.snapshot, + position:session.snapshot.position, pageContext:session.pageContext, + note:request.item.note, acceptance:request.item.acceptance, timestamp:new Date().toISOString() + }); await addFeedbackItem(globalThis.DevFeedbackShared.makeStorageKey(session.pageUrl), item); return { ok:true }; } @@ -146,29 +162,15 @@ async function openCaptureEditor(prefix, session, page) { const key = prefix + session.sessionId; - // Capture is presented over ordinary pages. Restricted browser surfaces use a popup window. - const injected = canInjectIntoUrl(session.rawTabUrl || session.pageUrl) - ? await ensureContentScript(session.tabId, session.rawTabUrl || session.pageUrl) : { ok:false }; - if (injected.ok) { - await chrome.storage.session.set({ [key]:{ ...session, editorTabId:session.tabId, embedded:true } }); - try { - const shown = await chrome.tabs.sendMessage(session.tabId, { action:'show-capture-overlay', sessionId:session.sessionId, page }, { frameId:0 }); - if (!shown?.ok) throw new Error(shown?.reason || 'Could not open the capture overlay.'); - return { ok:true, sessionId:session.sessionId }; - } catch (error) { - await chrome.storage.session.remove(key); - throw error; - } - } - const window = await chrome.windows.create({ url:'about:blank', type:'popup', width:1100, height:850, focused:true }); - const tab = window.tabs[0]; + const injected = await ensureContentScript(session.tabId, session.pageUrl); + if (!injected.ok) throw new Error(injected.reason); + await chrome.storage.session.set({ [key]:{ ...session, editorTabId:session.tabId, embedded:true } }); try { - await chrome.storage.session.set({ [key]:{ ...session, editorTabId:tab.id } }); - await chrome.tabs.update(tab.id, { url:chrome.runtime.getURL(`${page}?session=${encodeURIComponent(session.sessionId)}`) }); + const shown = await chrome.tabs.sendMessage(session.tabId, { action:'show-capture-overlay', sessionId:session.sessionId, page }, { frameId:0 }); + if (!shown?.ok) throw new Error(shown?.reason || 'Could not open the note editor.'); return { ok:true, sessionId:session.sessionId }; } catch (error) { - await chrome.storage.session.remove(key).catch(() => {}); - await chrome.tabs.remove(tab.id).catch(() => {}); + await chrome.storage.session.remove(key); throw error; } } @@ -208,11 +210,13 @@ }); async function ensureContentScript(tabId, rawUrl) { - if (!tabId || !canInjectIntoUrl(rawUrl)) { - return { ok: false, reason: 'This page does not support in-page element capture. Use Region mode instead.' }; + if (!tabId || (!canInjectIntoUrl(rawUrl) || detectSourceKind(rawUrl) === 'pdf')) { + return { ok: false, reason: 'Open a webpage to pick an element. PDF and browser-internal pages are not supported.' }; } try { + const result = await chrome.scripting.executeScript({target:{tabId},func:()=>document.contentType}); + if (result[0]?.result === 'application/pdf') return {ok:false,reason:'PDF capture is no longer offered. Open a webpage to pick an element.'}; await chrome.scripting.insertCSS({ target: { tabId }, files: ['styles.css'] @@ -234,67 +238,6 @@ } } - async function startRegionCapture(tab, viewportMetrics) { - if (!tab || !tab.id) { - return { ok: false, reason: 'No active tab is available for capture.' }; - } - - let storageKey = ''; - try { - await sweepExpiredRegionSessions(); - await assertCaptureTab(tab); - const state = await chrome.tabs.sendMessage(tab.id, { action:'get-state' }, { frameId:0 }).catch(()=>null); - if (state?.editorOpen) throw new Error('Save or cancel the open draft before starting another capture.'); - if (state?.feedbackMode) await chrome.tabs.sendMessage(tab.id, { action:'set-feedback-mode', enabled:false }, { frameId:0 }); - const zoom = await chrome.tabs.getZoom(tab.id); - const resolvedViewportMetrics = canInjectIntoUrl(tab.url || '') - ? await runCollector(tab.id, 'getViewportMetrics').catch(() => null) : null; - await assertCaptureTab(tab); - const screenshotDataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, { format:'png' }); - await assertCaptureTab(tab); - if (zoom !== await chrome.tabs.getZoom(tab.id)) throw new Error('Page zoom changed during capture. Try again.'); - if (resolvedViewportMetrics) { - const after = await runCollector(tab.id, 'getViewportMetrics'); - if (['width','height','scrollX','scrollY','devicePixelRatio'].some(key => after[key] !== resolvedViewportMetrics[key])) throw new Error('The page moved during capture. Try again.'); - } - const sessionId = buildFeedbackId(); - storageKey = `${REGION_CAPTURE_SESSION_PREFIX}${sessionId}`; - const pageUrl = getEffectivePageUrl(tab.url || ''); - const session = { - sessionId, - tabId: tab.id, - windowId: tab.windowId, - pageUrl, - rawTabUrl: tab.url || '', - pageTitle: tab.title || '', - viewportMetrics: sanitizeViewportMetrics(resolvedViewportMetrics || { width:tab.width, height:tab.height }), - screenshotDataUrl, - createdAt: new Date().toISOString() - }; - - session.viewportMetrics.zoom = zoom; - return await openCaptureEditor(REGION_CAPTURE_SESSION_PREFIX, session, 'capture.html'); - } catch (error) { - if (typeof storageKey === 'string') { - await chrome.storage.session.remove(storageKey).catch(() => {}); - } - return { ok: false, reason: error.message || 'Unable to capture the current tab.' }; - } - } - - async function resolveAnnotationTarget(tabId, point, pageContext) { - if (!tabId) { - return { ok: true, target: null, reason: 'The source tab is no longer available.' }; - } - - try { - const response = await runCollector(tabId, 'resolveDomTarget', [point, pageContext]); - return response?.ok ? response : { ok: true, target: null, reason: response?.reason || 'No DOM target found.' }; - } catch (error) { - return { ok: true, target: null, reason: 'DOM anchoring is unavailable for this page.' }; - } - } - async function clearRegionSessionsForEditorTab(tabId) { const sessions = await chrome.storage.session.get(null); const keys = Object.entries(sessions) @@ -367,8 +310,8 @@ const encodedBytes = new TextEncoder().encode(JSON.stringify(nextItems)).length; const largest = Math.max(0, ...nextItems.map(item => new TextEncoder().encode(JSON.stringify(item)).length)); const [used, previous] = await Promise.all([chrome.storage.local.getBytesInUse(null), chrome.storage.local.getBytesInUse(storageKey)]); - if (nextItems.length > currentItems.length && (largest > MAX_ITEM_BYTES || used - previous + encodedBytes + storageKey.length > MAX_HISTORY_BYTES)) { - throw new Error('History is nearly full or this capture is too large. Export and delete older items, or use a smaller crop. Your draft is still open.'); + if ((nextItems.length > currentItems.length || encodedBytes > new TextEncoder().encode(JSON.stringify(currentItems)).length) && (largest > MAX_ITEM_BYTES || used - previous + encodedBytes + storageKey.length > MAX_HISTORY_BYTES)) { + throw new Error('History is nearly full or this capture is too large. Export and delete older items, or shorten this note. Your draft is still open.'); } await chrome.storage.local.set({ [storageKey]: nextItems }); return { ok: true, items: nextItems }; @@ -414,18 +357,4 @@ return chrome.tabs.sendMessage(tabId, message); } - function sanitizeViewportMetrics(viewportMetrics) { - return { - width: Number.isFinite(viewportMetrics?.width) ? viewportMetrics.width : 0, - height: Number.isFinite(viewportMetrics?.height) ? viewportMetrics.height : 0, - scrollX: Number.isFinite(viewportMetrics?.scrollX) ? viewportMetrics.scrollX : 0, - scrollY: Number.isFinite(viewportMetrics?.scrollY) ? viewportMetrics.scrollY : 0, - devicePixelRatio: Number.isFinite(viewportMetrics?.devicePixelRatio) && viewportMetrics.devicePixelRatio > 0 - ? viewportMetrics.devicePixelRatio - : null, - zoom: Number.isFinite(viewportMetrics?.zoom) && viewportMetrics.zoom > 0 ? viewportMetrics.zoom : 1, - userAgent: typeof viewportMetrics?.userAgent === 'string' ? viewportMetrics.userAgent.slice(0, 500) : navigator.userAgent, - language: typeof viewportMetrics?.language === 'string' ? viewportMetrics.language.slice(0, 80) : navigator.language - }; - } })(); diff --git a/capture.html b/capture.html deleted file mode 100644 index 6d8a9ae..0000000 --- a/capture.html +++ /dev/null @@ -1,484 +0,0 @@ - - - - - - Visual Change Spec - - - -
-
-
-
- Captured viewport screenshot -
- -
-
-
- - -
- - - - - diff --git a/capture.js b/capture.js deleted file mode 100644 index 0e338d0..0000000 --- a/capture.js +++ /dev/null @@ -1,839 +0,0 @@ -/** - * Dev Feedback Capture - Visual Change Spec Editor - */ - -(function() { - 'use strict'; - - const { - CAPTURE_TYPE_REGION, - MAX_ACCEPTANCE_CRITERIA, - MAX_NOTE_LENGTH, - buildFeedbackId, - detectSourceKind, - makeStorageKey - } = globalThis.DevFeedbackShared; - - const SESSION_PREFIX = 'dev-feedback-region-session-'; - const SVG_NS = 'http://www.w3.org/2000/svg'; - const DRAW_TOOLS = new Set(['arrow', 'rectangle', 'ellipse', 'blur']); - - let session = null; - let selection = null; - let activeTool = 'crop'; - let annotations = []; - let undoStack = []; - let redoStack = []; - let gesture = null; - let saving = false; - const anchorPromises = new Map(); - - const imageWrap = document.getElementById('image-wrap'); - const screenshotImage = document.getElementById('screenshot-image'); - const selectionBox = document.getElementById('selection-box'); - const annotationLayer = document.getElementById('annotation-layer'); - const selectionSummary = document.getElementById('selection-summary'); - const sourceUrl = document.getElementById('source-url'); - const noteField = document.getElementById('note-field'); - const acceptanceField = document.getElementById('acceptance-field'); - const noteCounter = document.getElementById('note-counter'); - const acceptanceCounter = document.getElementById('acceptance-counter'); - const statusLine = document.getElementById('status-line'); - const saveButton = document.getElementById('save-btn'); - const undoButton = document.getElementById('undo-btn'); - const redoButton = document.getElementById('redo-btn'); - const colorField = document.getElementById('annotation-color'); - - saveButton.addEventListener('click', saveCapture); - document.getElementById('reset-btn').addEventListener('click', resetSpec); - document.getElementById('cancel-btn').addEventListener('click', cancelCapture); - undoButton.addEventListener('click', undo); - redoButton.addEventListener('click', redo); - document.querySelectorAll('[data-tool]').forEach((button) => { - button.addEventListener('click', () => setActiveTool(button.dataset.tool)); - }); - - imageWrap.addEventListener('pointerdown', startGesture); - noteField.addEventListener('input', updateCaptureState); - acceptanceField?.addEventListener('input', updateCaptureState); - window.addEventListener('keydown', handleKeydown); - window.addEventListener('pointermove', updateGesture); - window.addEventListener('pointerup', finishGesture); - window.addEventListener('pointercancel', cancelGesture); - window.addEventListener('resize', render); - - init().catch((error) => { - setStatus(error.message || 'Unable to load the capture session.', true); - }); - - async function init() { - const sessionId = new URLSearchParams(window.location.search).get('session'); - if (!sessionId) { - throw new Error('Missing region capture session id.'); - } - - const result = await chrome.runtime.sendMessage({ action:'get-capture-session' }); - session = result?.session; - if (!session || !session.screenshotDataUrl) { - throw new Error('The region capture session expired before it could be opened.'); - } - - sourceUrl.textContent = session.pageUrl || session.rawTabUrl || 'Unknown source'; - noteField.maxLength = MAX_NOTE_LENGTH; - updateCaptureState(); - - await new Promise((resolve, reject) => { - screenshotImage.onload = resolve; - screenshotImage.onerror = () => reject(new Error('Unable to load the captured screenshot.')); - screenshotImage.src = session.screenshotDataUrl; - }); - - const viewport = getViewportSize(); - annotationLayer.setAttribute('viewBox', `0 0 ${viewport.width} ${viewport.height}`); - render(); - } - - async function startGesture(event) { - if (event.button !== 0 || !session || gesture || saving) { - return; - } - - const point = getViewportPoint(event); - if (activeTool !== 'crop' && (!selection || !pointInsideRect(point, selection))) { - setStatus('Draw annotations inside the selected evidence crop.', true); - return; - } - imageWrap.setPointerCapture?.(event.pointerId); - - if (activeTool === 'pin') { - const pinBounds = insetRect(selection, 17); - if (pinBounds.width <= 0 || pinBounds.height <= 0) { - setStatus('The crop is too small for a numbered pin.', true); - return; - } - pushUndoState(); - addAnnotation({ - id: buildFeedbackId(), - type: 'pin', - point: clampPointToRect(point, pinBounds), - number: annotations.filter((annotation) => annotation.type === 'pin').length + 1, - color: getAnnotationColor(), - target: null - }); - redoStack = []; - render(); - updateCaptureState(); - event.preventDefault(); - return; - } - - if (activeTool === 'text') { - event.preventDefault(); - const text = await DevFeedbackDialog({message:'Annotation text (up to 280 characters)',input:true,confirmLabel:'Add label'}); - if (text?.trim()) { - const annotationText = text.trim().slice(0, 280); - const textPoint = fitTextPoint(point, annotationText, selection); - if (!textPoint) { - setStatus('The text annotation is wider than the selected crop.', true); - return; - } - pushUndoState(); - addAnnotation({ - id: buildFeedbackId(), - type: 'text', - point: textPoint, - text: annotationText, - color: getAnnotationColor(), - target: null - }); - redoStack = []; - render(); - updateCaptureState(); - } - event.preventDefault(); - return; - } - - const drawBounds = activeTool === 'crop' ? null : getToolBounds(activeTool, selection); - const gesturePoint = activeTool === 'crop' ? point : clampPointToRect(point, drawBounds); - gesture = { - tool: activeTool, - pointerId: event.pointerId, - start: gesturePoint, - current: gesturePoint, - before: captureEditorState() - }; - if (activeTool === 'crop') { - selection = { x: point.x, y: point.y, width: 0, height: 0 }; - } - render(); - event.preventDefault(); - } - - function updateGesture(event) { - if (!gesture || event.pointerId !== gesture.pointerId || saving) { - return; - } - - gesture.current = getViewportPoint(event); - if (gesture.tool === 'crop') { - selection = rectFromPoints(gesture.start, gesture.current); - } else if (selection) { - gesture.current = clampPointToRect(gesture.current, getToolBounds(gesture.tool, selection)); - } - render(); - updateCaptureState(); - } - - function finishGesture(event) { - if (!gesture || event.pointerId !== gesture.pointerId || saving) { - return; - } - - const completed = gesture; - gesture = null; - completed.current = completed.tool === 'crop' || !selection - ? getViewportPoint(event) - : clampPointToRect(getViewportPoint(event), getToolBounds(completed.tool, selection)); - if (completed.tool === 'crop') { - if (selection?.width >= 8 && selection?.height >= 8) { - pushUndoSnapshot(completed.before); - annotations = annotations.filter((annotation) => annotationInsideRect(annotation, selection)); - redoStack = []; - } else { - restoreEditorState(completed.before); - return; - } - } else if (DRAW_TOOLS.has(completed.tool)) { - const rect = rectFromPoints(completed.start, completed.current); - const isArrow = completed.tool === 'arrow'; - if ((isArrow && distance(completed.start, completed.current) >= 6) || (!isArrow && rect.width >= 6 && rect.height >= 6)) { - pushUndoSnapshot(completed.before); - redoStack = []; - addAnnotation(isArrow ? { - id: buildFeedbackId(), - type: 'arrow', - start: completed.start, - end: completed.current, - color: getAnnotationColor(), - target: null - } : { - id: buildFeedbackId(), - type: completed.tool, - rect, - color: getAnnotationColor(), - target: null - }); - } - } - render(); - updateCaptureState(); - } - - function render() { - renderSelection(); - renderAnnotations(); - document.querySelectorAll('[data-tool]').forEach((button) => { - const isActive = button.dataset.tool === activeTool; - button.classList.toggle('active', isActive); - button.setAttribute('aria-pressed', String(isActive)); - }); - undoButton.disabled = undoStack.length === 0; - redoButton.disabled = redoStack.length === 0; - } - - function renderSelection() { - if (!selection || selection.width < 2 || selection.height < 2) { - selectionBox.classList.remove('visible'); - selectionSummary.textContent = 'Use Crop to define the evidence area.'; - return; - } - - const viewport = getViewportSize(); - selectionBox.classList.add('visible'); - selectionBox.style.left = `${selection.x / viewport.width * 100}%`; - selectionBox.style.top = `${selection.y / viewport.height * 100}%`; - selectionBox.style.width = `${selection.width / viewport.width * 100}%`; - selectionBox.style.height = `${selection.height / viewport.height * 100}%`; - selectionSummary.textContent = `x ${Math.round(selection.x)}, y ${Math.round(selection.y)}, width ${Math.round(selection.width)}, height ${Math.round(selection.height)} · ${annotations.length} annotation${annotations.length === 1 ? '' : 's'}`; - } - - function renderAnnotations() { - annotationLayer.replaceChildren(); - const items = gesture && DRAW_TOOLS.has(gesture.tool) - ? annotations.concat(buildDraftAnnotation(gesture)) - : annotations; - items.filter(Boolean).forEach((annotation) => annotationLayer.appendChild(buildSvgAnnotation(annotation))); - } - - function buildDraftAnnotation(currentGesture) { - if (currentGesture.tool === 'arrow') { - return { type: 'arrow', start: currentGesture.start, end: currentGesture.current, color: getAnnotationColor() }; - } - return { - type: currentGesture.tool, - rect: rectFromPoints(currentGesture.start, currentGesture.current), - color: getAnnotationColor() - }; - } - - function buildSvgAnnotation(annotation) { - const group = document.createElementNS(SVG_NS, 'g'); - group.setAttribute('data-type', annotation.type); - const color = annotation.color || '#ff3b30'; - - if (annotation.type === 'arrow') { - const line = svgElement('line', { - x1: annotation.start.x, y1: annotation.start.y, - x2: annotation.end.x, y2: annotation.end.y, - stroke: color, 'stroke-width': 4, 'stroke-linecap': 'round' - }); - group.append(line, buildArrowHead(annotation.start, annotation.end, color)); - } else if (annotation.type === 'rectangle' || annotation.type === 'blur') { - group.appendChild(svgElement('rect', { - x: annotation.rect.x, y: annotation.rect.y, - width: annotation.rect.width, height: annotation.rect.height, - rx: 4, fill: annotation.type === 'blur' ? '#191919' : 'transparent', - stroke: color, 'stroke-width': 4, 'stroke-dasharray': annotation.type === 'blur' ? '8 5' : '' - })); - if (annotation.type === 'blur') { - const label = svgElement('text', { - x: annotation.rect.x + annotation.rect.width / 2, - y: annotation.rect.y + annotation.rect.height / 2, - fill: '#fff', 'text-anchor': 'middle', 'dominant-baseline': 'middle', 'font-size': 14, 'font-weight': 800 - }); - label.textContent = 'REDACT'; - group.appendChild(label); - } - } else if (annotation.type === 'ellipse') { - group.appendChild(svgElement('ellipse', { - cx: annotation.rect.x + annotation.rect.width / 2, - cy: annotation.rect.y + annotation.rect.height / 2, - rx: annotation.rect.width / 2, ry: annotation.rect.height / 2, - fill: 'transparent', stroke: color, 'stroke-width': 4 - })); - } else if (annotation.type === 'pin') { - group.appendChild(svgElement('circle', { cx: annotation.point.x, cy: annotation.point.y, r: 15, fill: color, stroke: '#fff', 'stroke-width': 2 })); - const label = svgElement('text', { - x: annotation.point.x, y: annotation.point.y + 1, - fill: '#fff', 'text-anchor': 'middle', 'dominant-baseline': 'middle', 'font-size': 15, 'font-weight': 800 - }); - label.textContent = String(annotation.number); - group.appendChild(label); - } else if (annotation.type === 'text') { - const label = svgElement('text', { - x: annotation.point.x, y: annotation.point.y, - fill: color, stroke: '#fff', 'stroke-width': 3, 'paint-order': 'stroke', 'font-size': 18, 'font-weight': 800 - }); - label.textContent = annotation.text; - group.appendChild(label); - } - return group; - } - - function buildArrowHead(start, end, color) { - const angle = Math.atan2(end.y - start.y, end.x - start.x); - const size = 14; - const points = [ - end, - { x: end.x - size * Math.cos(angle - Math.PI / 6), y: end.y - size * Math.sin(angle - Math.PI / 6) }, - { x: end.x - size * Math.cos(angle + Math.PI / 6), y: end.y - size * Math.sin(angle + Math.PI / 6) } - ]; - return svgElement('polygon', { points: points.map((point) => `${point.x},${point.y}`).join(' '), fill: color }); - } - - function svgElement(name, attributes) { - const element = document.createElementNS(SVG_NS, name); - Object.entries(attributes).forEach(([key, value]) => element.setAttribute(key, String(value))); - return element; - } - - function setActiveTool(tool) { - if (saving || !['crop', 'arrow', 'rectangle', 'ellipse', 'pin', 'text', 'blur'].includes(tool)) { - return; - } - cancelGesture(); - activeTool = tool; - setStatus(tool === 'crop' ? 'Drag to set the evidence crop.' : `Draw a ${tool} annotation on the screenshot.`); - render(); - } - - function resetSpec() { - if (saving || (!selection && !annotations.length)) { - return; - } - pushUndoState(); - selection = null; - annotations = []; - redoStack = []; - setStatus('Visual spec reset.'); - render(); - updateCaptureState(); - } - - function undo() { - if (saving) { - return; - } - const previous = undoStack.pop(); - if (!previous) { - return; - } - redoStack.push(captureEditorState()); - restoreEditorState(previous); - } - - function redo() { - if (saving) { - return; - } - const next = redoStack.pop(); - if (!next) { - return; - } - pushUndoSnapshot(captureEditorState()); - restoreEditorState(next); - } - - function pushUndoState() { - pushUndoSnapshot(captureEditorState()); - } - - function pushUndoSnapshot(state) { - undoStack.push(state); - if (undoStack.length > 50) { - undoStack.shift(); - } - } - - function captureEditorState() { - return JSON.parse(JSON.stringify({ selection, annotations })); - } - - function restoreEditorState(state) { - selection = state.selection; - annotations = state.annotations; - render(); - updateCaptureState(); - } - - async function saveCapture() { - if (saving) { - return; - } - const note = noteField.value.trim(); - if (!note) { - setStatus('Add a requested change before saving.', true); - noteField.focus(); - return; - } - if (!selection || selection.width < 8 || selection.height < 8) { - setStatus('Use Crop to draw a larger evidence area before saving.', true); - return; - } - - saving = true; - setEditorLocked(true); - setStatus('Resolving DOM anchors and saving visual spec...'); - const pageContext = buildPageContext(); - - try { - await Promise.all([...anchorPromises.values()]); - const resolvedAnnotations = annotations.map((annotation) => ({ ...annotation })); - annotations = resolvedAnnotations; - const beforeImage = cropSelectedRegion(); - const storageKey = makeStorageKey(session.pageUrl || session.rawTabUrl || ''); - const item = globalThis.DevFeedbackShared.createRegionRecord({ - id: session.sessionId, - pageUrl: session.pageUrl || session.rawTabUrl || '', - pageTitle: session.pageTitle || '', - viewportRect: roundRect(selection), - devicePixelRatio: getCaptureDevicePixelRatio(), - screenshot: { - mimeType: 'image/png', - dataUrl: beforeImage - }, - annotations: resolvedAnnotations, - acceptance: getAcceptanceCriteria(), - pageContext, - tabContext: { - url: session.pageUrl || session.rawTabUrl || '', - title: session.pageTitle || '' - }, - sourceKind: detectSourceKind(session.pageUrl || session.rawTabUrl || ''), - note: note.slice(0, MAX_NOTE_LENGTH), - timestamp: new Date().toISOString() - }); - - const result = await chrome.runtime.sendMessage({ action: 'add-feedback-item', storageKey, item }); - if (!result?.ok) { - throw new Error(result?.reason || 'Unable to save visual change spec.'); - } - await chrome.runtime.sendMessage({ action: 'clear-region-session', sessionId: session.sessionId }).catch(() => {}); - setStatus('Saved to History.'); - if (!session.embedded) window.setTimeout(() => window.close(), 350); - } catch (error) { - saving = false; - setEditorLocked(false); - setStatus(error.message || 'Unable to save visual change spec.', true); - render(); - updateCaptureState(); - } - } - - function setEditorLocked(locked) { - document.querySelectorAll('button, input, textarea').forEach((control) => { - control.disabled = locked; - }); - imageWrap.style.pointerEvents = locked ? 'none' : ''; - imageWrap.setAttribute('aria-disabled', String(locked)); - } - - async function resolveAnnotationTarget(annotation, pageContext) { - const response = await chrome.runtime.sendMessage({ - action: 'resolve-annotation-target', - sessionId: session.sessionId, - point: getAnnotationTargetPoint(annotation), - pageContext - }); - return response?.target || null; - } - - function addAnnotation(annotation) { - annotations.push(annotation); - const pageContext = buildPageContext(); - const promise = (annotation.type === 'blur' ? Promise.resolve(null) : resolveAnnotationTarget(annotation, pageContext)) - .then((target) => { - applyResolvedTarget(annotation.id, target); - }) - .catch(() => {}) - .finally(() => anchorPromises.delete(annotation.id)); - anchorPromises.set(annotation.id, promise); - } - - function applyResolvedTarget(annotationId, target) { - const apply = (items) => { - const match = items?.find((candidate) => candidate.id === annotationId); - if (match) { - match.target = target; - } - }; - apply(annotations); - undoStack.forEach((state) => apply(state.annotations)); - redoStack.forEach((state) => apply(state.annotations)); - } - - function getAnnotationTargetPoint(annotation) { - if (annotation.type === 'arrow') { - return annotation.end; - } - if (annotation.point) { - return annotation.point; - } - return { - x: annotation.rect.x + annotation.rect.width / 2, - y: annotation.rect.y + annotation.rect.height / 2 - }; - } - - async function cancelCapture() { - if (saving) return; - if (hasUnsavedWork() && !await DevFeedbackDialog({message:'Discard this visual change spec?'})) { - return; - } - if (session?.sessionId) { - await chrome.runtime.sendMessage({ action: 'clear-region-session', sessionId: session.sessionId }).catch(() => {}); - } - if (!session?.embedded) window.close(); - } - - function handleKeydown(event) { - if (document.querySelector('dialog[open]')) return; - if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'z' && !isTextInput(event.target)) { - event.preventDefault(); - event.shiftKey ? redo() : undo(); - return; - } - if (event.key === 'Escape') { - if (gesture) { - cancelGesture(); - } else { - event.preventDefault(); - cancelCapture(); - } - return; - } - if (event.target !== imageWrap || activeTool !== 'crop') { - return; - } - - const viewport = getViewportSize(); - if ((event.key === 'Enter' || event.key === ' ') && !selection) { - event.preventDefault(); - pushUndoState(); - selection = { - x: Math.round(viewport.width * 0.25), - y: Math.round(viewport.height * 0.25), - width: Math.max(8, Math.round(viewport.width * 0.5)), - height: Math.max(8, Math.round(viewport.height * 0.5)) - }; - redoStack = []; - render(); - updateCaptureState(); - return; - } - - if (!selection || !['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) { - return; - } - event.preventDefault(); - pushUndoState(); - const delta = event.altKey ? 1 : 10; - const horizontal = event.key === 'ArrowLeft' ? -delta : event.key === 'ArrowRight' ? delta : 0; - const vertical = event.key === 'ArrowUp' ? -delta : event.key === 'ArrowDown' ? delta : 0; - if (event.shiftKey) { - selection.width = clamp(selection.width + horizontal, 8, viewport.width - selection.x); - selection.height = clamp(selection.height + vertical, 8, viewport.height - selection.y); - } else { - selection.x = clamp(selection.x + horizontal, 0, viewport.width - selection.width); - selection.y = clamp(selection.y + vertical, 0, viewport.height - selection.height); - } - redoStack = []; - render(); - } - - function updateCaptureState() { - const noteLength = noteField.value.length; - const criteria = getAcceptanceCriteria(); - noteCounter.textContent = `${noteLength} / ${MAX_NOTE_LENGTH}`; - if (acceptanceCounter) { - acceptanceCounter.textContent = `${criteria.length} / ${MAX_ACCEPTANCE_CRITERIA} criteria`; - } - saveButton.disabled = !(noteField.value.trim() && selection?.width >= 8 && selection?.height >= 8); - } - - function getAcceptanceCriteria() { - return String(acceptanceField?.value || '') - .split('\n') - .map((criterion) => criterion.trim().replace(/^[-*]\s*/, '')) - .filter(Boolean) - .slice(0, MAX_ACCEPTANCE_CRITERIA); - } - - function hasUnsavedWork() { - return Boolean(noteField.value.trim() || acceptanceField?.value.trim() || selection || annotations.length); - } - - function cropSelectedRegion() { - const naturalRect = buildNaturalRect(selection); - const canvas = document.createElement('canvas'); - canvas.width = naturalRect.width; - canvas.height = naturalRect.height; - const context = canvas.getContext('2d'); - context.drawImage( - screenshotImage, - naturalRect.x, naturalRect.y, naturalRect.width, naturalRect.height, - 0, 0, naturalRect.width, naturalRect.height - ); - const viewport = getViewportSize(); - const scaleX = screenshotImage.naturalWidth / viewport.width; - const scaleY = screenshotImage.naturalHeight / viewport.height; - annotations.filter((annotation) => annotation.type === 'blur').forEach((annotation) => { - redactRect(context, annotation.rect, naturalRect, scaleX, scaleY); - }); - return canvas.toDataURL('image/png'); - } - - function redactRect(context, rect, crop, scaleX, scaleY) { - const left = Math.max(crop.x, rect.x * scaleX); - const top = Math.max(crop.y, rect.y * scaleY); - const right = Math.min(crop.x + crop.width, (rect.x + rect.width) * scaleX); - const bottom = Math.min(crop.y + crop.height, (rect.y + rect.height) * scaleY); - const x = Math.floor(left - crop.x); - const y = Math.floor(top - crop.y); - const width = Math.ceil(right - crop.x) - x; - const height = Math.ceil(bottom - crop.y) - y; - if (width <= 0 || height <= 0) { - return; - } - context.save(); - context.fillStyle = '#191919'; - context.fillRect(x, y, width, height); - context.restore(); - } - - - function cancelGesture(event) { - if (!gesture || (event?.pointerId !== undefined && event.pointerId !== gesture.pointerId)) { - return; - } - const previous = gesture.before; - gesture = null; - restoreEditorState(previous); - } - - function buildPageContext() { - const metrics = session.viewportMetrics || {}; - return { - url: session.pageUrl || session.rawTabUrl || '', - title: session.pageTitle || '', - sourceKind: detectSourceKind(session.pageUrl || session.rawTabUrl || ''), - viewport: { - width: getViewportSize().width, - height: getViewportSize().height, - scrollX: metrics.scrollX || 0, - scrollY: metrics.scrollY || 0, - devicePixelRatio: getCaptureDevicePixelRatio(), - zoom: metrics.zoom || 1 - }, - browser: { - userAgent: metrics.userAgent || window.navigator.userAgent, - language: metrics.language || window.navigator.language - } - }; - } - - function getViewportPoint(event) { - const display = screenshotImage.getBoundingClientRect(); - const viewport = getViewportSize(); - return { - x: clamp((event.clientX - display.left) / Math.max(1, display.width) * viewport.width, 0, viewport.width), - y: clamp((event.clientY - display.top) / Math.max(1, display.height) * viewport.height, 0, viewport.height) - }; - } - - function getCaptureDevicePixelRatio() { - const reported = session?.viewportMetrics?.devicePixelRatio; - if (Number.isFinite(reported) && reported > 0) { - return reported; - } - const viewport = getViewportSize(); - const scaleX = screenshotImage.naturalWidth / Math.max(1, viewport.width); - const scaleY = screenshotImage.naturalHeight / Math.max(1, viewport.height); - const derived = (scaleX + scaleY) / 2; - return Number.isFinite(derived) && derived > 0 ? Number(derived.toFixed(3)) : 1; - } - - function pointInsideRect(point, rect) { - return point.x >= rect.x && point.x <= rect.x + rect.width - && point.y >= rect.y && point.y <= rect.y + rect.height; - } - - function clampPointToRect(point, rect) { - return { - x: clamp(point.x, rect.x, rect.x + rect.width), - y: clamp(point.y, rect.y, rect.y + rect.height) - }; - } - - function insetRect(rect, amount) { - const inset = Math.max(0, amount); - return { - x: rect.x + inset, - y: rect.y + inset, - width: Math.max(0, rect.width - inset * 2), - height: Math.max(0, rect.height - inset * 2) - }; - } - - function getToolBounds(tool, rect) { - return insetRect(rect, tool === 'arrow' ? 16 : 2); - } - - function measureTextAnnotation(text) { - const canvas = document.createElement('canvas'); - const context = canvas.getContext('2d'); - context.font = '800 18px system-ui'; - return context.measureText(text).width; - } - - function fitTextPoint(point, text, rect) { - const width = measureTextAnnotation(text); - if (width > rect.width - 8 || rect.height < 28) { - return null; - } - return { - x: clamp(point.x, rect.x + 4, rect.x + rect.width - width - 4), - y: clamp(point.y, rect.y + 22, rect.y + rect.height - 5) - }; - } - - function annotationInsideRect(annotation, rect) { - if (annotation.type === 'arrow') { - const arrowBounds = insetRect(rect, 16); - return pointInsideRect(annotation.start, arrowBounds) && pointInsideRect(annotation.end, arrowBounds); - } - if (annotation.type === 'pin') { - return pointInsideRect(annotation.point, insetRect(rect, 17)); - } - if (annotation.type === 'text') { - const width = measureTextAnnotation(annotation.text); - return annotation.point.x >= rect.x + 4 - && annotation.point.x + width <= rect.x + rect.width - 4 - && annotation.point.y >= rect.y + 22 - && annotation.point.y <= rect.y + rect.height - 5; - } - const shapeBounds = insetRect(rect, 2); - return annotation.rect - && pointInsideRect({ x: annotation.rect.x, y: annotation.rect.y }, shapeBounds) - && pointInsideRect({ x: annotation.rect.x + annotation.rect.width, y: annotation.rect.y + annotation.rect.height }, shapeBounds); - } - - function getViewportSize() { - const metrics = session?.viewportMetrics || {}; - return { - width: Number.isFinite(metrics.width) && metrics.width > 0 ? metrics.width : screenshotImage.naturalWidth || 1, - height: Number.isFinite(metrics.height) && metrics.height > 0 ? metrics.height : screenshotImage.naturalHeight || 1 - }; - } - - function buildNaturalRect(viewportRect) { - const viewport = getViewportSize(); - const scaleX = screenshotImage.naturalWidth / viewport.width; - const scaleY = screenshotImage.naturalHeight / viewport.height; - return { - x: Math.round(viewportRect.x * scaleX), - y: Math.round(viewportRect.y * scaleY), - width: Math.max(1, Math.round(viewportRect.width * scaleX)), - height: Math.max(1, Math.round(viewportRect.height * scaleY)) - }; - } - - function rectFromPoints(start, end) { - return { - x: Math.min(start.x, end.x), - y: Math.min(start.y, end.y), - width: Math.abs(end.x - start.x), - height: Math.abs(end.y - start.y) - }; - } - - function roundRect(rect) { - return { - x: Math.round(rect.x), y: Math.round(rect.y), - width: Math.round(rect.width), height: Math.round(rect.height) - }; - } - - function distance(start, end) { - return Math.hypot(end.x - start.x, end.y - start.y); - } - - function getAnnotationColor() { - return /^#[0-9a-f]{6}$/i.test(colorField?.value || '') ? colorField.value : '#ff3b30'; - } - - function isTextInput(element) { - return element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement; - } - - function clamp(value, min, max) { - return Math.min(Math.max(value, min), max); - } - - function setStatus(message, isError) { - statusLine.textContent = message; - statusLine.classList.toggle('error', Boolean(isError)); - statusLine.setAttribute('role', isError ? 'alert' : 'status'); - } -})(); diff --git a/content.js b/content.js index 30a0c40..71fefb3 100644 --- a/content.js +++ b/content.js @@ -70,17 +70,18 @@ if (sender.id !== chrome.runtime.id) return; if (request.action === 'show-capture-overlay') { if (editor) { sendResponse({ok:false,reason:'Save or cancel the open draft first.'}); return; } - if (!['element.html','capture.html'].includes(request.page) || !/^[a-zA-Z0-9-]{1,100}$/.test(request.sessionId)) return; + if (!['element.html','history.html'].includes(request.page) || !/^[a-zA-Z0-9-]{1,100}$/.test(request.sessionId)) return; setActive(false); editor?.remove(); previousFocus = document.activeElement; editorSession = request.sessionId; editor = document.createElement('iframe'); - editor.title = request.page === 'element.html' ? 'Capture element feedback' : 'Capture region feedback'; + editor.allow = 'clipboard-write'; + editor.title = request.page === 'history.html' ? 'Feedback History' : 'Write element feedback'; editor.src = chrome.runtime.getURL(request.page + '?session=' + encodeURIComponent(editorSession)); - editor.style.cssText = request.page === 'element.html' - ? 'display:block;width:min(460px,calc(100vw - 24px));height:min(720px,calc(100vh - 24px));border:2px solid #4f46e5;border-radius:12px;background:white;box-shadow:0 8px 40px #0005;' - : 'display:block;width:calc(100vw - 24px);height:calc(100vh - 24px);border:2px solid #4f46e5;border-radius:12px;background:white;'; + editor.style.cssText = request.page === 'history.html' + ? 'display:block;width:min(440px,calc(100vw - 24px));height:calc(100vh - 24px);border:1px solid #a5a0dd;border-radius:14px;background:white;box-shadow:0 8px 40px #0003;' + : 'display:block;width:min(380px,calc(100vw - 24px));height:min(510px,calc(100vh - 24px));border:1px solid #a5a0dd;border-radius:14px;background:white;box-shadow:0 8px 40px #0003;'; shadow.querySelector('section').hidden = true; shadow.appendChild(editor); host.hidden = false; @@ -91,13 +92,10 @@ shadow.querySelector('section').hidden = false; host.hidden = true; if (previousFocus?.isConnected) previousFocus.focus({preventScroll:true}); + if (request.pickNext) { setActive(true); status.textContent = 'Saved. Pick the next element, or press Esc to stop.'; } } else if (request.action === 'toggle-feedback-mode') setActive(!active); else if (request.action === 'set-feedback-mode') setActive(request.enabled); - else if (request.action === 'start-region-capture') { - setActive(false); - chrome.runtime.sendMessage({ action:'start-region-capture' }).then(sendResponse); - return true; - } else if (request.action !== 'get-state') return; + else if (request.action !== 'get-state') return; sendResponse({ ok:true, editorOpen:Boolean(editor), feedbackMode:active, interactionMode:active ? 'element' : 'off' }); }); })(); diff --git a/docs/hardening-validation.md b/docs/hardening-validation.md index 18d15ba..4e722b1 100644 --- a/docs/hardening-validation.md +++ b/docs/hardening-validation.md @@ -1,4 +1,6 @@ -# Hardening candidate validation +# Historical broad candidate validation + +The Region/PDF creation workflow described below was removed after hands-on product review. See `store-release-1.8.0.md` for the narrower candidate. This file preserves the earlier test evidence. Validated September 5, 2026 against the 1.8.0 browser-capture candidate based on `b933ce0b8d412972faf60ebcb8218ef006d45bec` (PR #12). This is source and test evidence for an unreleased candidate, not Store publication evidence. diff --git a/docs/manual-release-checklist.md b/docs/manual-release-checklist.md index 4c11d40..915977d 100644 --- a/docs/manual-release-checklist.md +++ b/docs/manual-release-checklist.md @@ -1,6 +1,6 @@ # Manual Release Checklist -Automated checks are necessary but do not replace the exact-package unpacked-extension gate. Headless QA may use an isolated synthetic-page profile while the owner uses their Mac; record any test-only permission differences. See `docs/hardening-validation.md` for this candidate’s evidence and remaining limits. +Automated checks are necessary but do not replace the exact-package unpacked-extension gate. Headless QA may use an isolated synthetic-page profile while the owner uses their Mac; record any test-only permission differences. See `docs/store-release-1.8.0.md` for the current candidate’s evidence and remaining limits. Store status on August 3, 2026: v1.7.0 is public in the Chrome Web Store, v1.7.1 was cancelled, and v1.7.2 is pending review for automatic publication. The distributed CRX contains the Browser Code icon, while the Store listing still renders the retired purple-flag artwork. Store approval is not proof that the deferred checks below passed. Keep them open and do not call these releases runtime-verified until the relevant evidence is recorded. @@ -28,42 +28,28 @@ For that historical package: - In the durable owner account, update the Store overview, screenshots, and optional video, then upload the exact verified v1.7.1 ZIP. Re-read the upload status before submitting for review. - After publication, confirm the Store listing and a clean Google result both show the Browser Code icon, revised title, short description, public version, and current screenshots. If the retired purple-flag asset remains despite the verified ZIP icon, record the listing asset URL and escalate through Chrome Web Store support rather than claiming the refresh worked. -## Active browser capture core check - -Before tagging or publishing the active browser capture core: - -- Reload the unpacked extension from this exact repository checkout. -- Start Element and Region modes and confirm the capture UI opens with the current product controls. -- In Element mode, select one element and save a Capture Record. -- In Region mode, capture one normal-page region and one rendered PDF region, then save both to History. -- Resize the browser window and confirm the private capture overlay remains usable. Verify that saving and cancelling preserve the source tab and that opening another capture cannot discard an existing draft. -- Confirm History renders both records after the source tab is closed. -- Set the browser download location to the configured MCP inbox and disable the per-download save prompt for this check. -- Confirm **Send to Codex** places one explicit handoff file in that inbox without requiring manual file movement. -- Confirm the Browser Code icon is legible in the browser toolbar and extension-management list at the packaged sizes. - -## Active PDF/export check - -Before tagging or publishing the active browser capture core, load the exact repository path as an unpacked extension in Edge or Chromium and verify: - -- Region capture from one hosted PDF and, when file access is enabled, one local PDF. -- History renders the saved PDF capture after the source tab is closed. -- JSON and self-contained HTML exports download and open. -- AI Bundle ZIP contains `prompt.md`, `feedback.json`, `page-context.json`, `report.html`, and matching before/annotated evidence. -- Opaque redaction remains applied in every exported “before” image; original pixels must not be recoverable. - -This gate remains required before the active release is described as runtime-verified. - -## Active Agent Handoff check - -Before tagging or publishing the active browser capture core: - -- Send a real Element and Region/PDF Capture Record through `Send to Codex`. -- Launch the MCP companion from an actual local MCP client with explicit project and inbox roots. -- List the inbox and import the newest valid handoff, then exercise project status, list, get, implementation brief, and evidence resource reads. -- Confirm evidence bytes are available only through resource reads and base64 data URLs are absent from stored item JSON. -- Create one agent-authored project item, verify an identical `clientRequestId` is idempotent, and exercise a revision conflict. -- Implement one small project change with the agent's normal coding tools; record `in-progress`, `implemented`, and separately `verified` status with a passing check. -- Confirm the extension still requests only `storage`, `activeTab`, and `scripting`, and that the extension ZIP contains no MCP server or Node dependency files. - -Do not claim a direct browser bridge. The current Agent Handoff is an explicit local inbox import; a native-messaging bridge remains a separately permissioned future gate. +## Active Element release check + +The product was narrowed after hands-on review. New Region/PDF capture is removed; do not use the earlier broad workflow as an acceptance checklist for this release. + +- Verify the exact release ZIP and minimal manifest permissions. +- When replacing an unpacked build in an existing test profile, enable Developer mode and use Chrome’s extension Reload control. Restarting Chrome alone can leave the old service-worker behavior active; a new manifest or files on disk is insufficient proof. +- Open History through the real toolbar and verify a session-bound frame on the source page with no additional tab. +- Open the production popup at its native size and start Element picking through the toolbar. +- Pick by mouse and keyboard; verify Escape stops picking. +- Save a note with acceptance checks; cancel another draft; verify Save & pick next resumes targeting. +- Confirm a new capture cannot replace an open draft and save errors preserve entered text. +- Edit a saved note and checks without changing its target, source URL, original timestamp, or evidence. +- Close the source tab and verify History persists. +- Filter and select records; confirm all five export formats use only the reviewed selection. +- Verify exact selected/shown deletion preserves hidden records. +- Confirm previously saved Region/PDF and Visual/Add records remain readable and exportable, including redacted images. +- Confirm PDF/browser-internal pages cannot start new capture. +- Send a selected JSON handoff to the configured Downloads inbox and import it through the MCP client. +- Read the imported record and any legacy evidence; record implementation and separately verified status. +- Update the Store listing and screenshots for Element-only capture. +- Record the exact uploaded package digest and Store API review/publication readback. + +## Historical broad workflow + +Earlier test evidence is preserved in `hardening-validation.md`. It does not authorize bringing back removed creation modes or publishing outdated Store copy. diff --git a/docs/mcp-local-agent.md b/docs/mcp-local-agent.md index a3f59ce..f3cf131 100644 --- a/docs/mcp-local-agent.md +++ b/docs/mcp-local-agent.md @@ -1,6 +1,6 @@ # Local MCP Agent Companion -The Agent Handoff is a project-scoped MCP server so a local coding agent can read Element and Region/PDF feedback, inspect evidence, create project feedback, and record implementation progress without cloud sync or direct browser control. +The Agent Handoff is a project-scoped MCP server so a local coding agent can read Element feedback and previously saved Region/PDF records, inspect evidence, create project feedback, and record implementation progress without cloud sync or direct browser control. ## Trust boundary @@ -9,7 +9,7 @@ The MCP companion is a separate Node process. It does not read Chromium profile The handoff is explicit, local, and inbox-based: 1. Capture feedback in the extension. -2. Open History and choose **Send to Codex** to download the current History handoff. +2. Open History and choose **Send to Codex** to download the selected History handoff. 3. Set the browser download location to a folder inside Downloads and use that folder without a per-download save prompt. 4. Configure the MCP server with the target project and the same Downloads path. The user does not move files manually. 5. Ask the local agent to call `dev_feedback_import_latest`. diff --git a/docs/store-release-1.8.0.md b/docs/store-release-1.8.0.md new file mode 100644 index 0000000..6158108 --- /dev/null +++ b/docs/store-release-1.8.0.md @@ -0,0 +1,53 @@ +# Chrome Web Store 1.8.0 release + +Status: scope narrowed after hands-on review. Do not upload the earlier broad candidate ZIP. + +The release now centers on Element capture, editable local History, and explicit selected exports. New Region/PDF capture is removed; old saved records remain supported. + +## Store listing + +Title: Dev Feedback Capture: AI UI Review & Prompts + +Short description: Pick webpage elements, write clear change requests, and export selected feedback for developers and coding agents. + +Overview: + +Pick a webpage element, describe the change, and give another developer enough context to act on it. + +Write your request in a compact private editor. Add optional acceptance checks, inspect the captured selector and page context, and choose Save & pick next to review several elements without leaving the page. + +History opens in a private panel on the working page, with an extension-menu fallback on restricted pages. It never opens a new tab. Your notes stay in local History after the source tab closes. Edit a request or its acceptance checks, filter the list, select the notes to share, and review the export preview before confirming. + +Copy a Markdown note or AI prompt, download a self-contained HTML report or AI Bundle, or use Send to Codex to download a JSON handoff for the separately configured local MCP companion. The extension does not connect directly to an AI account. + +There is no cloud sync, telemetry, remote executable code, or always-on page monitoring. The extension asks for access to the current tab only when you activate capture. Captured page content and your own notes can still contain private information; review them before exporting. + +New Region/PDF, Visual, and Add Content capture are no longer offered. Existing saved records remain readable and exportable. Element capture requires an accessible webpage; PDF viewers, browser-internal pages, and some embedded content are unsupported. + +Dev Feedback Capture is an independent tool, not an official integration from any coding-agent provider. + +## Privacy practices + +Single purpose: Collect structured feedback about selected webpage elements and export user-selected records for implementation. + +- activeTab: Temporary access after user activation to identify and collect the selected webpage element. +- scripting: Inject the requested picker, read-only element collector, and private note editor. No always-on content scripts. +- storage: Keep notes, captured context, acceptance checks, legacy records, and preferences locally. +- Remote code: None. Executable code is packaged with the extension. +- Data categories: Website content, captured page URL/title, and explicit capture interactions. Existing data-use declarations remain applicable. + +## Validation and package + +Validated locally on September 5, 2026: + +- `npm test`: 49 tests across extension/privacy, Electron, and MCP, plus release assertions; passed. +- `npm run check`, `npm run package`, `npm run verify:package`, and `git diff --check`: passed. +- Package: 19 files, 49,540 bytes, SHA-256 `975462d63b958a342430ac95f9d5d9159986e2c7b0861a67828fa209fb5ed4e9`. +- Owner accepted Element picking and feedback capture, then accepted the narrower on-page History panel after the installed extension was explicitly reloaded. +- Actual native toolbar activation in headed Chrome for Testing with the unmodified release manifest opened History as a session-bound frame on the original page. One browser tab remained; saved notes were preserved. +- Existing-profile caveat: restarting Chrome after replacing files retained the previous worker behavior. The real toolbar reproduced the unwanted new History tab. Enabling Developer mode in this isolated profile and using Chrome’s extension Reload control resolved it. Recheck the real toolbar after every unpacked update. +- Independent headless browser checks used a separate profile with a test-only localhost host permission. Verified Save & pick next, History note editing, selection/filter clearing, export preview, all three downloads, both clipboard actions, close/Escape, 440px panel width without horizontal overflow, and no note text in the source-page DOM. +- PDF requests returned the popup fallback. History rendered at 360px inside the popup document without creating another tab. Native popup fallback acceptance remains separate from that automated document check. +- Imported a real browser-exported JSON through an MCP SDK stdio client, listed the selected record, and built its implementation brief. The downloaded file was copied into the approved Downloads test inbox for this check; automatic inbox delivery and implementation/verification status are still separate acceptance steps. + +Remaining before submission: finish the outstanding acceptance checks in the manual checklist, capture current Store screenshots, land the reviewed source and CI, update the Store listing, and record upload/review readback. The previous main artifact is superseded and must not be submitted. diff --git a/element.css b/element.css new file mode 100644 index 0000000..e4da36d --- /dev/null +++ b/element.css @@ -0,0 +1,21 @@ +* { box-sizing:border-box; } +body { margin:0; padding:18px; font:13px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; background:#faf9fd; color:#29263a; } +header { display:flex; justify-content:space-between; align-items:center; gap:12px; } +h1 { margin:0; font-size:19px; } +button { padding:10px 12px; border:1px solid #d5d1e5; border-radius:8px; cursor:pointer; font:inherit; font-weight:600; } +#cancel { border:0; background:transparent; font-size:24px; padding:0 5px; color:#6f6a7d; } +#source { overflow-wrap:anywhere; margin:9px 0 12px; font-size:12px; } +textarea { display:block; width:100%; padding:11px; margin:10px 0; resize:vertical; min-height:80px; border:1px solid #aaa5ba; border-radius:8px; background:white; color:inherit; font:inherit; } +textarea:focus, button:focus-visible, summary:focus-visible { outline:2px solid #8179db; outline-offset:2px; } +details { padding:9px 0; border-bottom:1px solid #e5e2ee; } +summary { cursor:pointer; font-weight:600; } +pre { overflow-wrap:anywhere; white-space:pre-wrap; font-size:11px; } +.actions { display:flex; gap:8px; margin-top:16px; } +#save { background:#5b55c5; color:white; border-color:#5b55c5; } +.secondary { background:white; color:#4d479a; } +button:disabled { opacity:.5; cursor:default; } +footer,.muted { color:#6f6a7d; font-weight:400; } +footer { font-size:11px; margin-top:14px; } +#status:empty { display:none; } +#status { color:#a13232; } +.sr-only { position:absolute; width:1px; height:1px; overflow:hidden; clip-path:inset(50%); } diff --git a/element.html b/element.html index f7e43c4..f461b4f 100644 --- a/element.html +++ b/element.html @@ -1,5 +1,18 @@ -Capture Element Feedback -

Capture Element Feedback

Your draft is private to the extension. Save it locally, then review it in History before sharing.

-

Captured element details
-

Review the captured text before sharing from History. No form input values or surrounding parent text are collected.

+ +Write element feedback + +

What should change?

+

+
+ + +
Acceptance checks optional
+
Captured element
+
+
+

+
Private draft · Saved locally
⌘/Ctrl+Enter saves · Shift also picks the next element
+ + + diff --git a/element.js b/element.js index dfd9fa8..90d2b39 100644 --- a/element.js +++ b/element.js @@ -5,20 +5,22 @@ const note = document.getElementById('note'); const status = document.getElementById('status'); const save = document.getElementById('save'); + const saveNext = document.getElementById('save-next'); const cancel = document.getElementById('cancel'); - save.disabled = true; + save.disabled = saveNext.disabled = true; chrome.runtime.sendMessage({ action:'get-capture-session' }).then(result => { if (!result?.ok || !result.session) throw new Error(result?.reason || 'Capture session expired.'); session = result.session; - document.getElementById('source').textContent = session.pageUrl; + document.getElementById('source').textContent = session.snapshot.selector; document.getElementById('target').textContent = JSON.stringify(session.snapshot, null, 2); - save.disabled = false; + save.disabled = saveNext.disabled = false; note.focus(); }).catch(error => { status.textContent = error.message; }); document.getElementById('capture-form').addEventListener('submit', async event => { event.preventDefault(); if (!session || saving || !note.value.trim()) return; - saving = true; save.disabled = true; cancel.disabled = true; + const pickNext = event.submitter === saveNext; + saving = true; save.disabled = saveNext.disabled = true; cancel.disabled = true; status.textContent = 'Saving locally...'; try { const result = await chrome.runtime.sendMessage({ action:'add-feedback-item', item:{ @@ -26,14 +28,17 @@ }}); if (!result?.ok) throw new Error(result?.reason || 'Could not save.'); status.textContent = 'Saved to History.'; - await chrome.runtime.sendMessage({ action:'clear-capture-session' }).catch(()=>{}); + await chrome.runtime.sendMessage({ action:'clear-capture-session', pickNext }).catch(()=>{}); if (!session?.embedded) window.close(); } catch (error) { status.textContent = error.message + ' Your note is still here; retry when ready.'; - saving = false; save.disabled = false; cancel.disabled = false; + saving = false; save.disabled = saveNext.disabled = false; cancel.disabled = false; } }); window.addEventListener('keydown', event => { + if ((event.metaKey || event.ctrlKey) && event.key === 'Enter' && !saving) { + event.preventDefault(); document.getElementById('capture-form').requestSubmit(event.shiftKey ? saveNext : save); + } if (event.key === 'Escape' && !document.querySelector('dialog[open]')) { event.preventDefault(); cancel.click(); } }); cancel.addEventListener('click', async()=> { diff --git a/history.css b/history.css index 6755e27..5a88d4c 100644 --- a/history.css +++ b/history.css @@ -92,3 +92,33 @@ dialog pre { max-height:45vh; overflow:auto; background:#f1f5f9; color:#172139; #export-preview-images .evidence-preview { display:block; max-width:none; } #export-preview-images img { width:100%; height:auto; max-height:none; object-fit:contain; } #export-preview-images .placeholder { display:none; } + +.item-actions { display:flex; flex-direction:column; gap:8px; align-items:stretch; } +@media (max-width:500px) { .item-actions { flex-direction:row; } } + +.close-panel { float:right; position:relative; z-index:1; padding:2px 8px; font-size:24px; } +.share-menu { width:100%; } +.share-menu summary { cursor:pointer; color:var(--accent); font-weight:700; padding:8px 0; } +.popup-history, .popup-history body { width:360px; } +.overlay-history .shell, .popup-history .shell { width:100%; padding:16px; margin:0; } +.overlay-history .hero, .popup-history .hero { gap:12px; align-items:start; } +.overlay-history h1, .popup-history h1 { font-size:25px; } +.overlay-history .lede, .popup-history .lede { font-size:12px; } +.overlay-history .metrics, .popup-history .metrics { display:flex; gap:18px; } +.overlay-history .toolbar, .popup-history .toolbar { margin:14px 0; padding:12px; } +.overlay-history .group-header, .popup-history .group-header { padding:12px; } +.overlay-history .item, .popup-history .item { padding:12px; } + +/* Keep notes within the first screen of the on-page panel. */ +.overlay-history .hero, .popup-history .hero { padding-bottom:12px; gap:6px; } +.overlay-history .lede, .popup-history .lede { margin:8px 0; } +.overlay-history .metrics div, .popup-history .metrics div { min-width:0; padding:0; border:0; background:none; text-align:left; } +.overlay-history .metrics strong, .overlay-history .metrics span, +.popup-history .metrics strong, .popup-history .metrics span { display:inline; font-size:12px; } +.overlay-history .metrics span, .popup-history .metrics span { margin-left:4px; } +.overlay-history .toolbar, .popup-history .toolbar { gap:8px; padding:0; margin:12px 0; } +.overlay-history .toolbar p, .popup-history .toolbar p { margin:2px 0; font-size:12px; } +.overlay-history #storage-usage, .popup-history #storage-usage { color:var(--muted); font-size:11px; } +.status:empty { display:none; } +.overlay-history .groups, .popup-history .groups { margin-top:12px; } +.overlay-history .thumbnail.placeholder, .popup-history .thumbnail.placeholder { display:none; } diff --git a/history.html b/history.html index 71a0923..2d7c242 100644 --- a/history.html +++ b/history.html @@ -7,12 +7,12 @@ -
+

Dev Feedback Capture

History & Export

-

Review local Capture Records and export an implementation-ready handoff with evidence, DOM anchors, and acceptance checks.

+

Review your notes, refine the requested changes, and export only the feedback you select.

0items
@@ -26,13 +26,14 @@

History & Export

0 selected for sharing or deletion

+

@@ -40,6 +41,7 @@

History & Export

+

Edit feedback

Review before sharing

Only these selected records will be shared. Source URL query strings, fragments, credentials, and local directory paths are removed. Redacted regions omit captured text context. Your notes, annotation labels, and image contents still need your review.

Review captured fields
diff --git a/history.js b/history.js index dfe2d14..0569814 100644 --- a/history.js +++ b/history.js @@ -1,5 +1,20 @@ (function() { 'use strict'; + if (window.top !== window) document.documentElement.classList.add('overlay-history'); + if (new URLSearchParams(window.location.search).get('surface') === 'popup') document.documentElement.classList.add('popup-history'); + document.getElementById('close-history').addEventListener('click', async () => { + if (window.top === window) { window.close(); return; } + const result = await chrome.runtime.sendMessage({action:'close-history'}); + if (!result?.ok) setError(result?.reason || 'Could not close History.'); + }); + + + document.addEventListener('keydown', event => { + if (event.key === 'Escape' && !document.querySelector('dialog[open]')) { + event.preventDefault(); + document.getElementById('close-history').click(); + } + }); const { CAPTURE_TYPE_REGION, @@ -165,7 +180,13 @@ deleteButton.textContent = 'Delete'; deleteButton.setAttribute('aria-label', `Delete feedback: ${item.note.slice(0, 80)}`); deleteButton.addEventListener('click', () => deleteItem(history, item)); - article.append(body, deleteButton); + const editButton = document.createElement('button'); + editButton.className = 'secondary'; editButton.textContent = 'Edit'; + editButton.setAttribute('aria-label', `Edit feedback: ${item.note.slice(0, 80)}`); + editButton.addEventListener('click', () => editItem(history, item)); + const actions = document.createElement('div'); actions.className = 'item-actions'; + actions.append(editButton, deleteButton); + article.append(body, actions); return article; } @@ -240,6 +261,33 @@ }); } + function editItem(history, item) { + const dialog = document.getElementById('edit-feedback'); + const form = document.getElementById('edit-feedback-form'); + const note = document.getElementById('edit-note'); + const acceptance = document.getElementById('edit-acceptance'); + const save = document.getElementById('edit-save'); + const cancel = document.getElementById('edit-cancel'); + const error = document.getElementById('edit-error'); + let saving = false; + note.value = item.note; acceptance.value = (item.acceptance || []).join('\n'); error.textContent = ''; + save.disabled = cancel.disabled = false; + cancel.onclick = () => dialog.close(); + dialog.oncancel = event => { if (saving) event.preventDefault(); }; + form.onsubmit = async event => { + event.preventDefault(); + if (saving || !note.value.trim()) return; + saving = true; save.disabled = cancel.disabled = true; error.textContent = ''; + try { + const result = await chrome.runtime.sendMessage({action:'edit-feedback-note', storageKey:history.storageKey, itemId:item.id, note:note.value.trim(), acceptance:acceptance.value.split(/\r?\n/).map(value=>value.trim()).filter(Boolean)}); + if (!result?.ok) throw new Error(result?.reason || 'Could not save this note.'); + dialog.close(); await loadHistory(); setStatus('Note updated.'); + } catch (failure) { error.textContent = failure.message + ' Your changes are still here.'; } + finally { saving = false; save.disabled = cancel.disabled = false; } + }; + dialog.showModal(); note.focus(); + } + async function deleteItem(history, item) { if (!window.confirm('Delete this feedback item?')) { return; diff --git a/manifest.json b/manifest.json index b8111e3..e42c2a9 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 3, "name": "Dev Feedback Capture: AI UI Review & Prompts", "version": "1.8.0", - "description": "Pick elements and annotate regions. Export AI-ready prompts and region evidence for developers.", + "description": "Pick webpage elements, write clear change requests, and export selected feedback for developers and coding agents.", "permissions": [ "storage", "activeTab", @@ -37,7 +37,7 @@ { "resources": [ "element.html", - "capture.html" + "history.html" ], "matches": [ "" diff --git a/package.json b/package.json index ebee770..5ce11e4 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "private": true, "version": "1.8.0", "license": "MIT", - "description": "Capture browser elements and regions for local implementation handoffs", + "description": "Turn webpage element notes into a local developer handoff", "scripts": { "test": "npm run test:extension && npm run test:electron && npm run test:mcp", "test:extension": "node --test test/browser-core.test.cjs test/privacy.test.cjs && node test/release.test.cjs", diff --git a/popup.html b/popup.html index a9b33e0..3bb2d1c 100644 --- a/popup.html +++ b/popup.html @@ -1,445 +1,36 @@ - + - - + + Dev Feedback Capture -
-
- -
-

Dev Feedback Capture

-

Local-first UI notes for pages, PDFs, and browser-visible work.

-
-
- -
- - -
- -
-
Capture Mode
-
- - -
-
- -
- - -
- -
-
Current Tab
-
-
-
Page
-
No active tab
-
-
-
-
Element Mode
-
OFF
-
-
-
Saved Local Items
-
0
-
-
-
-
- -
-
Quick Start
-
    -
  1. Use Element for page targets or Region for crops and PDFs.
  2. -
  3. Start the selected workflow, then pick or crop the part you want changed.
  4. -
  5. Review saved specs and download implementation exports from History.
  6. -
-
- -
-
Keyboard Shortcut
-
- Toggle element mode - Ctrl+Shift+F -
-
- -
- +

Dev Feedback Capture

+

Pick an element. Describe the change.

+

Current webpage

+ + + +
0 saved on this site · Stays local until exported
to start picking
diff --git a/popup.js b/popup.js index 8833e92..15a8354 100644 --- a/popup.js +++ b/popup.js @@ -1,286 +1,42 @@ -/** - * Dev Feedback Capture - Popup Script - */ - (function() { 'use strict'; - - const { - SHORTCUT_LABEL, - MAC_SHORTCUT_LABEL, - canInjectIntoUrl, - getEffectivePageUrl, - makeStorageKey - } = globalThis.DevFeedbackShared; - - const STORAGE_KEYS = { - captureMode: 'dev-feedback-popup-mode' - }; - - let currentTab = null; - let currentTabId = null; - let selectedMode = window.localStorage.getItem(STORAGE_KEYS.captureMode) || 'element'; - if (!['element', 'region'].includes(selectedMode)) { - selectedMode = 'element'; - } - let currentFeedbackMode = false; - - function getShortcutLabel() { - return navigator.platform.toLowerCase().includes('mac') ? MAC_SHORTCUT_LABEL : SHORTCUT_LABEL; - } - - function setWarning(message) { - const warning = document.getElementById('warning'); - warning.textContent = message; - warning.style.display = message ? 'block' : 'none'; - } - - function setInfo(message) { - const info = document.getElementById('info'); - info.textContent = message; - info.style.display = message ? 'block' : 'none'; - } - + const { canInjectIntoUrl, detectSourceKind, makeStorageKey, SHORTCUT_LABEL, MAC_SHORTCUT_LABEL } = DevFeedbackShared; + let tab; + let state = {}; + const pick = document.getElementById('primary-action-btn'); + const warning = document.getElementById('warning'); + function showError(message) { warning.textContent = message; warning.hidden = !message; } async function init() { - document.getElementById('shortcut-label').textContent = getShortcutLabel(); - bindCaptureModeInputs(); - document.getElementById('primary-action-btn').addEventListener('click', handlePrimaryAction); - document.getElementById('history-btn').addEventListener('click', openHistory); - - if (!globalThis.chrome?.tabs || !globalThis.chrome?.storage || !globalThis.chrome?.runtime) { - document.getElementById('page-label').textContent = 'Extension preview'; - updateUI(false, 0); - document.getElementById('primary-action-btn').disabled = true; - setInfo('Open this popup from the installed extension to capture the active tab.'); - return; - } - - await loadCurrentTab(); - syncModeUi(); - } - - function bindCaptureModeInputs() { - document.querySelectorAll('input[name="capture-mode"]').forEach((input) => { - input.checked = input.value === selectedMode; - input.addEventListener('change', () => { - selectedMode = input.value; - window.localStorage.setItem(STORAGE_KEYS.captureMode, selectedMode); - syncModeUi(); - }); - }); - } - - async function loadCurrentTab() { - const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); - currentTab = tabs && tabs[0] ? tabs[0] : null; - currentTabId = currentTab && typeof currentTab.id === 'number' ? currentTab.id : null; - - const pageLabel = document.getElementById('page-label'); - if (!currentTab) { - pageLabel.textContent = 'No active tab'; - updateUI(false, 0); - return; - } - - pageLabel.textContent = getEffectivePageUrl(currentTab.url || currentTab.pendingUrl || currentTab.title || 'Current tab'); - await refreshState(); - } - - async function openHistory() { - await chrome.tabs.create({ url: chrome.runtime.getURL('history.html') }); - window.close(); - } - - async function refreshState() { - const primaryButton = document.getElementById('primary-action-btn'); - const itemCount = await getItemCount(); - const feedbackState = await getFeedbackState(); - - updateUI(feedbackState.feedbackMode, itemCount); - primaryButton.disabled = !currentTabId; - syncModeUi(); - } - - async function getItemCount() { - if (!currentTab?.url) { - return 0; - } - - const storageKey = makeStorageKey(currentTab.url); - const result = await chrome.storage.local.get([storageKey]); - return Array.isArray(result[storageKey]) ? result[storageKey].length : 0; - } - - async function getFeedbackState() { - if (!currentTabId) { - return { feedbackMode: false }; - } - + document.getElementById('shortcut-label').textContent = navigator.platform.toLowerCase().includes('mac') ? MAC_SHORTCUT_LABEL : SHORTCUT_LABEL; + [tab] = await chrome.tabs.query({active:true, currentWindow:true}); + document.getElementById('page-label').textContent = tab?.title || 'Current webpage'; + if (!tab?.id || !canInjectIntoUrl(tab.url) || detectSourceKind(tab.url) === 'pdf') { + showError('Open a webpage to pick an element. PDF and browser-internal pages are not supported.'); + return; + } + state = await chrome.tabs.sendMessage(tab.id, {action:'get-state'}, {frameId:0}).catch(()=>({})); + const key = makeStorageKey(tab.url); + const stored = await chrome.storage.local.get(key); + document.getElementById('item-count').textContent = (stored[key] || []).length; + pick.textContent = state.editorOpen ? 'Return to open panel' : state.feedbackMode ? 'Stop picking' : 'Pick an element'; + pick.disabled = false; + } + pick.addEventListener('click', async () => { + showError(''); + if (state.editorOpen) { window.close(); return; } + pick.disabled = true; try { - const response = await chrome.tabs.sendMessage(currentTabId, { action: 'get-state' }); - return response || { feedbackMode: false }; - } catch (error) { - return { feedbackMode: false }; - } - } - - function syncModeUi() { - const primaryButton = document.getElementById('primary-action-btn'); - const canInject = canInjectIntoUrl(currentTab?.url || ''); - const canCaptureRegion = canAttemptRegionCapture(currentTab); - setWarning(''); - setInfo(''); - - if (!currentTabId) { - primaryButton.disabled = true; - primaryButton.textContent = 'No Active Tab'; - return; - } - - if (selectedMode === 'region') { - primaryButton.disabled = !canCaptureRegion; - primaryButton.classList.remove('stop'); - primaryButton.textContent = 'Capture Region'; - - if (!canCaptureRegion) { - setWarning('Region capture needs a visible browser tab. Chrome may block browser-internal pages.'); - return; - } - - if ((currentTab?.url || '').startsWith('file://')) { - setInfo('If region capture fails on a local PDF, enable "Allow access to file URLs" on the extension first.'); - } else { - setInfo('Region capture opens the editor for cropping, DOM-linked annotations, and acceptance checks.'); - } - return; - } - - primaryButton.textContent = currentFeedbackMode - ? 'Stop Element Mode' - : 'Start Element Mode'; - primaryButton.classList.toggle('stop', currentFeedbackMode); - primaryButton.disabled = !canInject; - - if (!canInject) { - setWarning('Element mode needs an injectable page such as http, https, or file. Use Region mode for PDFs and browser viewer surfaces.'); - return; - } - - setInfo('Element mode injects the feedback UI into the current tab only after you start it.'); - } - - function canAttemptRegionCapture(tab) { - if (!tab || typeof tab.id !== 'number' || typeof tab.windowId !== 'number') { - return false; - } - - const rawUrl = tab.url || tab.pendingUrl || ''; - if (!rawUrl) { - return true; - } - - try { - const url = new URL(rawUrl); - return !['chrome:', 'edge:', 'about:'].includes(url.protocol); - } catch (error) { - return true; - } - } - - async function handlePrimaryAction() { - if (!currentTabId) { - return; - } - - if (selectedMode === 'region') { - await startRegionCapture(); - return; - } - - await toggleElementMode(); - } - - async function toggleElementMode() { - setWarning(''); - - const ensured = await chrome.runtime.sendMessage({ - action: 'ensure-content-script', - tabId: currentTabId, - url: currentTab?.url || '' - }); - - if (!ensured || !ensured.ok) { - setWarning(ensured?.reason || 'Unable to load the in-page feedback UI on this tab.'); - return; - } - - try { - const response = await chrome.tabs.sendMessage(currentTabId, { action: 'toggle-feedback-mode' }); - updateUI(Boolean(response?.feedbackMode), await getItemCount()); - syncModeUi(); - } catch (error) { - setWarning('Refresh the current page and try again. The feedback UI did not attach cleanly.'); - } - } - - async function startRegionCapture() { - setWarning(''); - - try { - const contentResponse = await chrome.tabs.sendMessage(currentTabId, { action: 'start-region-capture' }); - if (contentResponse?.ok) { - window.close(); - return; - } - if (contentResponse?.reason) { - setWarning(contentResponse.reason); - return; - } - } catch (error) { - // The page has no injected UI, so capture directly from the service worker. - } - - let viewportMetrics = null; - try { - viewportMetrics = await chrome.tabs.sendMessage(currentTabId, { action: 'get-viewport-metrics' }); - } catch (error) { - viewportMetrics = null; - } - - const response = await chrome.runtime.sendMessage({ - action: 'start-region-capture', - tab: { - id: currentTab.id, - windowId: currentTab.windowId, - url: currentTab.url, - title: currentTab.title, - width: currentTab.width, - height: currentTab.height - }, - viewportMetrics - }); - - if (!response || !response.ok) { - setWarning(response?.reason || 'Unable to start region capture on this tab.'); - return; - } - - window.close(); - } - - function updateUI(feedbackMode, itemCount) { - const statusText = document.getElementById('feedback-mode-status'); - const itemCountEl = document.getElementById('item-count'); - - currentFeedbackMode = Boolean(feedbackMode); - statusText.textContent = feedbackMode ? 'ON' : 'OFF'; - statusText.classList.toggle('active', feedbackMode); - itemCountEl.textContent = String(itemCount); - } - - document.addEventListener('DOMContentLoaded', () => { - init().catch((error) => { - setWarning(error.message || 'Unable to initialize the popup.'); - }); + const result = await chrome.runtime.sendMessage({action:'ensure-content-script',tabId:tab.id}); + if (!result?.ok) throw new Error(result?.reason || 'Could not start picking on this page.'); + await chrome.tabs.sendMessage(tab.id, {action:'toggle-feedback-mode'}, {frameId:0}); + window.close(); + } catch (error) { showError(error.message); pick.disabled = false; } + }); + document.getElementById('history-btn').addEventListener('click', async () => { + const result = await chrome.runtime.sendMessage({action:'open-history',tabId:tab?.id}); + if (!result?.ok) { showError(result?.reason || 'Could not open History.'); return; } + if (result.usePopup) window.location.replace(chrome.runtime.getURL('history.html?surface=popup')); + else window.close(); }); + init().catch(error=>showError(error.message)); })(); diff --git a/product.json b/product.json index 051193f..afbe32b 100644 --- a/product.json +++ b/product.json @@ -2,7 +2,7 @@ "slug": "dev-feedback-capture", "name": "Dev Feedback Capture", "kind": "browser-extension", - "summary": "Capture browser elements or regions and send local, structured feedback to a coding agent.", + "summary": "Pick webpage elements and turn clear change requests into a local developer handoff.", "status": "public", "repo": "https://github.com/StoneHub/webDevFeedbackExt", "liveUrl": "https://chromewebstore.google.com/detail/dev-feedback-capture/hhdmfaaplpiokafjieefpgoppckijafc", @@ -26,19 +26,18 @@ "summary": "Feedback stays in local extension storage or an explicitly selected project sidecar. Capture, export, and MCP import actions are user-triggered, and no cloud sync or hosted AI integration is included." }, "distribution": { - "current": "The active product scope is Element capture, Region/PDF capture, History, and one explicit Agent Handoff. Store and GitHub release records preserve earlier submissions separately.", - "planned": "Package and manually verify the browser capture core, then publish only after the Element, Region/PDF, History, and Agent Handoff gates pass.", + "current": "Element capture, local History, and explicit selected exports. Previously saved Region/PDF records remain readable.", + "planned": "Verify the focused Element workflow and publish the 1.8.0 update after Store review.", "latestReleaseApi": "https://api.github.com/repos/StoneHub/webDevFeedbackExt/releases/latest", "assetNamePattern": "dev-feedback-capture-v{version}.zip", "downloadUrlPolicy": "Resolve the latest release asset at publish time instead of hardcoding a stale versioned URL." }, "highlights": [ - "Element capture with selectors and notes", - "Annotated Region/PDF captures with DOM anchors and acceptance checks", - "Extension-owned History with local exports", - "Send to Codex handoff through a configured local inbox", - "Local AI Bundle export with structured context and evidence images", - "Project-scoped stdio MCP companion for local coding agents" + "Element notes with selectors and page context", + "Optional acceptance checks", + "Private note editor and local History", + "Review and export only selected notes", + "Project-scoped MCP companion for coding agents" ], "screenshots": [] } diff --git a/scripts/package-extension.cjs b/scripts/package-extension.cjs index 55c714d..3de679b 100644 --- a/scripts/package-extension.cjs +++ b/scripts/package-extension.cjs @@ -14,13 +14,12 @@ const outputPath = path.join(distDir, zipName); const extensionFiles = [ 'ai-bundle.js', 'background.js', - 'capture.html', - 'capture.js', 'content.js', 'collector.js', 'element.js', 'editor-dialog.js', 'element.html', + 'element.css', 'history.css', 'history.html', 'history.js', diff --git a/scripts/release-check.cjs b/scripts/release-check.cjs index f856df5..d026875 100644 --- a/scripts/release-check.cjs +++ b/scripts/release-check.cjs @@ -12,10 +12,9 @@ const shared = require(path.join(rootDir, 'shared.js')); const requiredFiles = [ 'element.html', + 'element.css', 'ai-bundle.js', 'background.js', - 'capture.html', - 'capture.js', 'content.js', 'collector.js', 'element.js', @@ -45,7 +44,6 @@ const requiredFiles = [ const shippedJavaScriptFiles = [ 'ai-bundle.js', 'background.js', - 'capture.js', 'content.js', 'collector.js', 'element.js', @@ -95,13 +93,13 @@ assert.equal(packageJson.license, 'MIT'); assert.match(license, /^MIT License/); assert.equal(manifest.name, 'Dev Feedback Capture: AI UI Review & Prompts'); assert.ok(manifest.name.length <= 45, 'Manifest name exceeds the Chrome Web Store limit'); -assert.equal(manifest.description, 'Pick elements and annotate regions. Export AI-ready prompts and region evidence for developers.'); +assert.equal(manifest.description, 'Pick webpage elements, write clear change requests, and export selected feedback for developers and coding agents.'); assert.ok(manifest.description.length <= 132, 'Manifest description exceeds the Chrome Web Store limit'); -assert.equal(productJson.summary, 'Capture browser elements or regions and send local, structured feedback to a coding agent.'); +assert.equal(productJson.summary, 'Pick webpage elements and turn clear change requests into a local developer handoff.'); assert.equal(manifest.background.service_worker, 'background.js'); assert.deepEqual(manifest.permissions, ['storage', 'activeTab', 'scripting']); assert.equal(Array.isArray(manifest.content_scripts), false); -assert.deepEqual(manifest.web_accessible_resources, [{ resources:['element.html','capture.html'], matches:[''] }]); +assert.deepEqual(manifest.web_accessible_resources, [{ resources:['element.html','history.html'], matches:[''] }]); assert.equal(manifest.commands['toggle-feedback-mode'].suggested_key.default, shared.SHORTCUT_LABEL); assert.equal(manifest.commands['toggle-feedback-mode'].suggested_key.mac, shared.MAC_SHORTCUT_LABEL); assert.equal(productJson.releaseUrl, 'https://github.com/StoneHub/webDevFeedbackExt/releases'); diff --git a/scripts/verify-package.cjs b/scripts/verify-package.cjs index 84401ba..d2c1115 100644 --- a/scripts/verify-package.cjs +++ b/scripts/verify-package.cjs @@ -9,13 +9,12 @@ const packageJson = readJson(path.join(rootDir, 'package.json')); const expectedFiles = [ 'ai-bundle.js', 'background.js', - 'capture.html', - 'capture.js', 'content.js', 'collector.js', 'element.js', 'editor-dialog.js', 'element.html', + 'element.css', 'history.css', 'history.html', 'history.js', diff --git a/test/privacy.test.cjs b/test/privacy.test.cjs index d36320e..3c319af 100644 --- a/test/privacy.test.cjs +++ b/test/privacy.test.cjs @@ -59,7 +59,7 @@ function background(options={}) { vm.runInNewContext(source('background.js'),context); const page=(name, session)=>({id:'unit',frameId:session?2:0,documentId:'editor-document',url:chrome.runtime.getURL(name+(session?'?session='+session:'')),tab:{id:session?1:10}}); const content={id:'unit',frameId:0,url:initialTab.url,tab:initialTab}; - return {local,sessions,content,page,windowTypes,get access(){return access;},set failWrite(value){failWrite=value;},send:(request,sender=page('history.html'))=>new Promise(resolve=>listener(request,sender,resolve))}; + return {local,sessions,content,page,windowTypes,get tabCount(){return tabs.size;},get access(){return access;},set failWrite(value){failWrite=value;},send:(request,sender=page('history.html'))=>new Promise(resolve=>listener(request,sender,resolve))}; } test('broker denies content-script History reads/writes, forged extension URLs, subframes, and wrong editor ownership', async () => { @@ -104,15 +104,10 @@ test('storage capacity rejection preserves the editor session and existing histo assert.equal(save.ok,false);assert.match(save.reason,/nearly full/);assert.equal(Object.keys(app.sessions).length,1); }); -test('Region capture rejects a tab switch instead of saving mismatched evidence',async()=>{ - const app=background({switchDuringCapture:true}); - const result=await app.send({action:'start-region-capture',tab:{id:1}},app.page('popup.html')); - assert.equal(result.ok,false);assert.match(result.reason,/source tab changed/);assert.equal(Object.keys(app.sessions).length,0); -}); test('History filter and selection exclude hidden items from export and deletion',async()=>{ const controls=new Map();const control=id=>{if(!controls.has(id))controls.set(id,{addEventListener(){},setAttribute(){},style:{}});return controls.get(id);}; - const context={DevFeedbackShared:shared,document:{getElementById:control},chrome:{storage:{onChanged:{addListener(){}}}},Set,JSON}; + const context={window:{top:null,location:{search:''}},URLSearchParams,DevFeedbackShared:shared,document:{addEventListener(){},documentElement:{classList:{add(){}}},getElementById:control},chrome:{storage:{onChanged:{addListener(){}}}},Set,JSON}; let script=source('history.js').replace('\n loadHistory();','\n // Suppress initial rendering in this contract test.'); script=script.replace(/\}\)\(\);\s*$/,`globalThis.audit={seed(h,q){histories=h;searchQuery=q;h.forEach(group=>group.items.forEach(item=>selected.add(identity(group,item))));},getSelectedHistories,getFilteredHistories,clearHistoryGroup};})();`); vm.runInNewContext(script,context); @@ -143,32 +138,50 @@ test('legacy histories above the item budget can still be cleaned up',async()=>{ assert.equal(app.local[key].length,501); }); -test('the first crop gesture works before any selection exists',async()=>{ - const controls=new Map(); - const control=id=>{ - if(!controls.has(id)) controls.set(id,{value:'',style:{},classList:{add(){},remove(){},toggle(){}},addEventListener(){},replaceChildren(){},setAttribute(){},getBoundingClientRect(){return {left:0,top:0,width:800,height:600};}}); - return controls.get(id); - }; - const context={DevFeedbackShared:shared,document:{getElementById:control,querySelectorAll(){return [];}},window:{addEventListener(){}},Set,Map,JSON,Math}; - let script=source('capture.js').replace(/ init\(\)\.catch\(\(error\) => \{[\s\S]*?\n \}\);/,''); - script=script.replace(/\}\)\(\);\s*$/,`globalThis.cropTest={seed(){session={viewportMetrics:{width:800,height:600}};},startGesture,updateGesture,finishGesture,getSelection(){return selection;}};})();`); - vm.runInNewContext(script,context); - context.cropTest.seed(); - const event=(x,y)=>({button:0,pointerId:1,clientX:x,clientY:y,preventDefault(){}}); - await context.cropTest.startGesture(event(100,100)); - context.cropTest.updateGesture(event(400,350)); - context.cropTest.finishGesture(event(400,350)); - assert.equal(context.cropTest.getSelection().width,300); - assert.equal(context.cropTest.getSelection().height,250); + + + +test('Region creation and retired editor routes are unavailable',async()=>{ + const app=background(); + assert.equal((await app.send({action:'start-region-capture',tab:{id:1}},app.page('popup.html'))).ok,false); + assert.equal((await app.send({action:'start-region-capture'},app.content)).ok,false); + assert.equal((await app.send({action:'get-capture-session'},app.page('capture.html','old'))).ok,false); + assert.equal(Object.keys(app.sessions).length,0); +}); + +test('Element saving cannot smuggle a screenshot into a new Region record',async()=>{ + const app=background(); + const start=await app.send({action:'start-element-capture',snapshot:{selector:'#button'}},app.content); + const result=await app.send({action:'add-feedback-item',item:{note:'Change label',screenshot:{dataUrl:PNG},type:'region'}},app.page('element.html',start.sessionId)); + assert.equal(result.ok,true); + const item=Object.values(app.local).flat()[0]; + assert.equal(item.type,'element');assert.equal(item.screenshot,undefined); +}); + +test('History edits change the note and checks while preserving original evidence and identity',async()=>{ + const key='dev-feedback-https://site.test'; const original={...element('a'),acceptance:['Old check']}; + const app=background({local:{[key]:[original,element('b')]}}); + const request={action:'edit-feedback-note',storageKey:key,itemId:'a',note:'Updated request',acceptance:['New check'],selector:'#forged',pageUrl:'https://forged.test'}; + assert.equal((await app.send(request,app.content)).ok,false); + assert.equal((await app.send(request)).ok,true); + assert.equal(app.local[key][0].note,'Updated request'); + assert.equal(app.local[key][0].changeRequest.summary,'Updated request'); + assert.equal(app.local[key][0].selector,original.selector); + assert.equal(app.local[key][0].timestamp,original.timestamp); + assert.equal(app.local[key][1].note,'Change spacing'); + assert.equal((await app.send({...request,itemId:'deleted'})).ok,false); }); -test('restricted surfaces use a capture popup window with session ownership',async()=>{ - const app=background({denyInjection:true}); - const started=await app.send({action:'start-region-capture',tab:{id:1}},app.page('popup.html')); +test('History overlays need an owned session and cannot be opened by an arbitrary embedded frame',async()=>{ + const app=background({local:{'dev-feedback-https://site.test':[element('a')]}}); + const started=await app.send({action:'open-history',tabId:1},app.page('popup.html')); assert.equal(started.ok,true); - assert.deepEqual(app.windowTypes,['popup']); - const sender={...app.page('capture.html',started.sessionId),frameId:0,tab:{id:10}}; - assert.equal((await app.send({action:'get-capture-session'},sender)).ok,true); - assert.equal((await app.send({action:'get-capture-session'},{...sender,tab:{id:1}})).ok,false); + assert.equal(app.tabCount,1,'History must keep the existing tab'); + assert.equal(app.windowTypes.length,0); + const owner=app.page('history.html',started.sessionId); + assert.equal((await app.send({action:'list-feedback-history'},owner)).histories.length,1); + assert.equal((await app.send({action:'list-feedback-history'},{...owner,documentId:'foreign-document'})).ok,false); + assert.equal((await app.send({action:'list-feedback-history'},app.page('history.html','unknown'))).ok,false); + assert.equal((await app.send({action:'close-history'},owner)).ok,true); }); diff --git a/test/release.test.cjs b/test/release.test.cjs index d10560a..7389ec0 100644 --- a/test/release.test.cjs +++ b/test/release.test.cjs @@ -12,7 +12,6 @@ const ciWorkflow = fs.readFileSync(path.join(__dirname, '..', '.github', 'workfl const releaseWorkflow = fs.readFileSync(path.join(__dirname, '..', '.github', 'workflows', 'release.yml'), 'utf8'); const historyMarkup = fs.readFileSync(path.join(__dirname, '..', 'history.html'), 'utf8'); const historySource = fs.readFileSync(path.join(__dirname, '..', 'history.js'), 'utf8'); -const captureSource = fs.readFileSync(path.join(__dirname, '..', 'capture.js'), 'utf8'); const contentSource = fs.readFileSync(path.join(__dirname, '..', 'content.js'), 'utf8'); const stylesSource = fs.readFileSync(path.join(__dirname, '..', 'styles.css'), 'utf8'); const backgroundSource = fs.readFileSync(path.join(__dirname, '..', 'background.js'), 'utf8'); @@ -555,8 +554,8 @@ assert.throws(() => bundleBuilder.buildAiBundle([{ storageKey: 'empty-webp', ite assert.deepEqual(manifest.permissions, ['storage', 'activeTab', 'scripting']); assert.equal(manifest.name, 'Dev Feedback Capture: AI UI Review & Prompts'); assert.equal(manifest.name.length, 44); -assert.equal(manifest.description, 'Pick elements and annotate regions. Export AI-ready prompts and region evidence for developers.'); -assert.equal(productJson.summary, 'Capture browser elements or regions and send local, structured feedback to a coding agent.'); +assert.equal(manifest.description, 'Pick webpage elements, write clear change requests, and export selected feedback for developers and coding agents.'); +assert.equal(productJson.summary, 'Pick webpage elements and turn clear change requests into a local developer handoff.'); assert.equal( manifest.commands['toggle-feedback-mode'].suggested_key.default, shared.SHORTCUT_LABEL @@ -566,7 +565,7 @@ assert.equal( shared.MAC_SHORTCUT_LABEL ); assert.equal(packageJson.version, manifest.version); -assert.deepEqual(manifest.web_accessible_resources, [{ resources:['element.html','capture.html'], matches:[''] }]); +assert.deepEqual(manifest.web_accessible_resources, [{ resources:['element.html','history.html'], matches:[''] }]); assert.equal(productJson.distribution.assetNamePattern, 'dev-feedback-capture-v{version}.zip'); assert.equal(packageJson.scripts['verify:package'], 'node scripts/verify-package.cjs'); assert.match(ciWorkflow, /pull_request:/); @@ -577,12 +576,11 @@ assert.match(historySource, /schemaVersion: 1/); assert.match(historyMarkup, /id="download-json">Send to Codex