From 6e8a986e92b36d0779c801434ad68afad16bc42e Mon Sep 17 00:00:00 2001 From: StoneHub Date: Fri, 21 Aug 2026 09:22:40 -0400 Subject: [PATCH 1/2] feat: refocus v1.8 on browser capture core --- AGENTS.md | 17 + CHANGELOG.md | 10 + CONTEXT.md | 10 + QUICKSTART.md | 48 +- README.md | 79 +- background.js | 21 +- capture.js | 6 +- content-proposal.js | 170 --- content.js | 1217 +-------------------- docs/add-content-mode-plan.html | 195 ---- docs/agents/domain.md | 7 + docs/agents/issue-tracker.md | 9 + docs/agents/triage-labels.md | 9 + docs/chrome-web-store-submission-draft.md | 14 +- docs/manual-release-checklist.md | 49 +- docs/mcp-local-agent.md | 35 +- docs/software-catalog-release-plan.md | 22 +- docs/store-monetization-readiness.html | 20 +- docs/v1.3-history-reliability-plan.html | 44 - docs/v1.6-local-mcp-plan.html | 41 - docs/v1.7-product-plan.html | 461 -------- history.html | 4 +- history.js | 5 +- manifest.json | 4 +- mcp/server.mjs | 18 + mcp/store.mjs | 334 ++++-- package-lock.json | 4 +- package.json | 6 +- popup.html | 16 +- popup.js | 105 +- product.json | 14 +- scripts/package-extension.cjs | 4 +- scripts/release-check.cjs | 14 +- scripts/verify-package.cjs | 4 +- shared.js | 40 + styles.css | 441 +------- test/browser-core.test.cjs | 173 +++ test/mcp.test.mjs | 288 ++++- test/release.test.cjs | 250 +---- visual-edit.js | 798 -------------- 40 files changed, 1018 insertions(+), 3988 deletions(-) create mode 100644 CONTEXT.md delete mode 100644 content-proposal.js delete mode 100644 docs/add-content-mode-plan.html create mode 100644 docs/agents/domain.md create mode 100644 docs/agents/issue-tracker.md create mode 100644 docs/agents/triage-labels.md delete mode 100644 docs/v1.3-history-reliability-plan.html delete mode 100644 docs/v1.6-local-mcp-plan.html delete mode 100644 docs/v1.7-product-plan.html create mode 100644 test/browser-core.test.cjs delete mode 100644 visual-edit.js diff --git a/AGENTS.md b/AGENTS.md index c955aed..83465c7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,23 @@ This project should support Monroe's public story as a software engineer who bui - Keep permissions minimal and user-triggered. - Preserve local-history compatibility when changing saved item shapes. +## Agent skills + +### Issue tracker + +Issues and specs are tracked in StoneHub/webDevFeedbackExt GitHub Issues. +See `docs/agents/issue-tracker.md`. + +### Triage labels + +The repo uses the five canonical Matt triage labels. +See `docs/agents/triage-labels.md`. + +### Domain docs + +This is a single-context repository. +See `docs/agents/domain.md`. + ## Verification - Run `npm test` and `npm run check` before claiming behavior is ready. diff --git a/CHANGELOG.md b/CHANGELOG.md index 77d7203..dbf8151 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 1.8.0 (Unreleased) + +- Refocused the active product on Element capture, Region/PDF capture, History, and one explicit Agent Handoff. +- Removed the active Visual Edit and Add Content creators while keeping their historical Capture Records readable. +- Added canonical Capture Record constructors shared by Element and Region saves. +- Renamed the History JSON handoff to `Send to Codex` and gave each Downloads export a unique filename. +- Added bounded MCP inbox discovery and newest-valid import under the user's Downloads directory. +- Removed stale implementation plans and added current issue-tracker, triage-label, and domain-document instructions. +- Kept Feedback Sessions on the checkpointed experiment branch rather than shipping them in this release. + ## 1.7.2 (Submitted August 3, 2026) - Added Add Content mode for anchoring proposed text, image placeholders, lists, and safe HTML/embed-frame placeholders to existing page elements. diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..f7d8b70 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,10 @@ +# 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. +- **Agent Handoff.** An explicit local transfer of Capture Records to a coding agent for implementation and separate verification. diff --git a/QUICKSTART.md b/QUICKSTART.md index 75fff19..0d7a8c5 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -2,7 +2,7 @@ Get Dev Feedback Capture running in a few minutes. -Chrome Web Store v1.7 is public. This source checkout prepares a behavior-neutral v1.7.1 discovery refresh; the latest GitHub Release ZIP remains v1.2.0 as a manual fallback. +The active product is the browser capture core: Element, Region/PDF, History, and one explicit Agent Handoff. Store and GitHub release notes in this repository preserve earlier submission evidence. ## 1. Install the extension @@ -31,27 +31,7 @@ Optional for local PDFs: 5. Click a page element, add your note, and save it. 6. The in-page capture list starts expanded. Use **⌄** to collapse it; the compact list stays on the nearest viewport edge as you drag it, and **⌃** expands it again. -## 3. Preview a visual edit - -1. Open an injectable webpage and choose `Visual` in the extension popup. -2. Click `Start Visual Edit`, then select one page element. -3. Drag the selected outline to move the element, or drag its corner handle to resize it. -4. Use Undo, Redo, or Reset as needed. -5. Add the implementation note and optional acceptance checks, then save the spec. -6. The live page is restored; the original/proposed evidence and requested mutations remain in local History. - -## 4. Propose new page content - -1. Open an injectable webpage and choose `Add` in the extension popup. -2. Click the existing element that should anchor the new block. -3. Choose Text, Image placeholder, List, or HTML/embed frame and select its placement. -4. Add filler content or explain what the new block should communicate or support. -5. Add optional acceptance checks, then save the insert spec. -6. The temporary block is removed from the live page while its anchor, intent, structured insert mutation, and before/proposed evidence remain in History. - -The HTML/embed frame is a safe placeholder; it does not execute supplied HTML or load remote content. - -## 5. Compile an annotated region spec +## 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`. @@ -61,30 +41,40 @@ The HTML/embed frame is a safe placeholder; it does not execute supplied HTML or 6. Describe the requested change and optionally add one acceptance check per line. 7. Save the visual change spec. -## 6. Export saved feedback +## 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. From History, you can: - Download one `AI Bundle` ZIP with `prompt.md`, structured feedback and page context, before/annotated PNGs, and `report.html` -- Download `JSON for MCP` for full payloads including crop image data +- 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. -## 7. Give a local agent project-scoped feedback +## 5. Give a local agent project-scoped feedback -1. In History, choose `Download JSON for MCP`. -2. Configure the MCP companion with the absolute target project path and the folder containing that export. -3. Ask the agent to call `dev_feedback_import` with the exact JSON path. If the export contains multiple site/file groups, also provide the exact `storageKey` shown by the first rejected import. +1. In History, choose `Send to Codex` to download the current History handoff. +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 mode unavailable: use `Region` mode on non-injectable browser surfaces +- Element capture unavailable: use `Region` mode on non-injectable browser surfaces - Region capture saves viewport-only crops in v1, not full-page screenshots diff --git a/README.md b/README.md index b8ab7c3..4e87dc8 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,29 @@ # Dev Feedback Capture -Pick and annotate browser elements or regions, propose new content blocks, then copy AI-ready prompts or export local visual change specs for Codex, Claude Code, Cursor, or another developer. Dev Feedback Capture supports four evidence-rich workflows: +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. -> [Chrome Web Store v1.7 is public](https://chromewebstore.google.com/detail/dev-feedback-capture/hhdmfaaplpiokafjieefpgoppckijafc). The v1.7.2 Add Content update is pending Google review and will publish automatically after approval; the latest GitHub Release ZIP remains v1.2.0 as a manual fallback. +> The Chrome Web Store and GitHub release records in this repository describe earlier submissions. The active product direction is the browser capture core documented here. -- `Element` mode injects a lightweight in-page UI so you can click DOM elements and save selectors, styles, and notes. -- `Visual` mode lets you directly drag and resize one live DOM element, records original versus proposed intent, and restores the page after Save or Cancel. -- `Add` mode anchors a proposed text, image, list, or HTML/embed frame to an existing element and saves a structured insert request without persisting the preview into the page. -- `Region` mode captures the visible viewport and compiles a crop, vector annotations, best-effort DOM anchors, requested change, and acceptance checks into one visual change spec. +- `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: export the current History to the browser Downloads folder, let the local MCP companion import the newest valid handoff, and keep implementation and verification as separate agent steps. -All feedback stays local in extension storage. Open History to review captures, use the legacy standalone exports, or download one AI Bundle with instructions, structured data, page context, and before/annotated images. +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. ## Features -- Element capture with selector, text, styles, and note metadata -- Pointer-first Visual Edit previews with drag-to-move, corner-handle resize, undo, redo, and reset -- Original/proposed evidence plus explicit requested-mutation data; the live page is always restored -- Reversible Add Content previews for text, image placeholders, lists, and safe HTML/embed frame placeholders -- Visual Change Spec editor with crop, arrow, rectangle, ellipse, pin, text, blur/redact, color, undo, and redo +- 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, surrounding text, 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 -- PDF-friendly screenshot workflow for local and hosted PDFs - 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 -- Expanded-by-default in-page capture list that stays anchored to the viewport edge when collapsed and dragged +- Minimal permissions and user-triggered activation ## Installation @@ -75,49 +72,43 @@ Use this path when developing the extension or reviewing source changes: 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. -### Visual Edit Mode +### History and Agent Handoff -1. Open an injectable webpage and choose `Visual` in the popup. -2. Start Visual Edit and select one element. -3. Drag the selected outline to move the element, or drag its corner handle to resize it. Use Undo, Redo, or Reset as needed. -4. Add the requested implementation change and optional acceptance criteria. -5. Save the spec, or Cancel to discard it. Either path restores the original live page. +Open `History` from the popup to review captures from any supported source. Choose `Send to Codex` to download the current History as an explicit handoff. The local MCP companion discovers the newest valid handoff in Downloads and imports it into the target project's ignored `.dev-feedback` sidecar. -Visual Edit does not change source files, persist mutations into the page, or replay saved edits automatically. It records intent so a developer or coding agent can implement the change in the correct source layer. +The handoff contract is deliberately explicit: -### Add Content Mode +1. The extension captures and saves a Capture Record. +2. The user sends the current History 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. -1. Open an injectable webpage and choose `Add` in the popup. -2. Click the existing element that should anchor the new content. -3. Choose Text, Image placeholder, List, or HTML/embed frame, then choose before, after, inside-start, or inside-end placement when the anchor supports children. -4. Add basic filler, an optional heading, and what the content should communicate or support. -5. Save the insert spec, or Cancel to remove the preview. Either path restores the original live page. - -The HTML/embed frame is intentionally a non-executable placeholder. Add Content never runs supplied markup, scripts, or remote embeds; it records the requested content and placement for implementation in source. +No step gives the extension browser control, source-editing authority, or an automatic cloud bridge. ## Data Model Stored feedback items use a discriminated shape: -- `type: "element"` items include selector, element info, and position. -- v1.5 element items may also include a sanitized `changeRequest`, original/proposed element state, and local evidence images. Older element items normalize as visual suggestions without invented mutations. -- Add Content items remain compatible `type: "element"` records and use a sanitized `insert` mutation containing an anchor, placement, content type, filler, support intent, and optional acceptance checks. -- `type: "region"` items include one evidence crop, vector annotations, DOM anchors when available, acceptance criteria, and page context. Annotated PNGs are rendered locally when the AI Bundle is built. +- `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. -Older element-only captures are still loaded and normalized automatically. +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. ## Export Formats -- `Download AI Bundle` creates `prompt.md`, `feedback.json`, `page-context.json`, before/proposed/annotated PNG evidence when available, and `report.html` in one ZIP. The bundle is assembled locally. +- `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. -- `Download JSON for MCP` includes the full saved payload, including region image data URLs, for explicit local import. +- 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 -v1.6 includes a separate Node MCP server under `mcp/`. A local coding agent can import an explicit History JSON export into the target project's ignored `.dev-feedback` sidecar, then list/get feedback, read evidence resources, create agent-authored feedback, build an implementation brief, and record revision-checked status. +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. @@ -142,16 +133,14 @@ The extension does not use static host permissions, always-on content scripts, t ### Project Files - `manifest.json`: Manifest V3 configuration -- `background.js`: runtime injection and region-capture session orchestration +- `background.js`: runtime injection and Region-capture orchestration - `content.js`: in-page panel and element capture -- `visual-edit.js`: dependency-free reversible mutation engine used by Visual Edit Mode -- `content-proposal.js`: dependency-free safe placeholder builder used by Add Content Mode - `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, and History entry point +- `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, normalization, and export formatting +- `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 @@ -173,20 +162,20 @@ See [CHANGELOG.md](CHANGELOG.md) for release notes. ## Limitations - Element mode depends on DOM/script injection and is not intended for browser-internal surfaces. -- Visual Edit is intentionally limited to direct move and resize of one normal-page DOM target. Text, visibility, order, style, matching, alignment, cross-origin frames, arbitrary CSS, responsive breakpoints, animation, and reparenting are outside this release. -- Add Content previews basic content structure and placement only. It does not generate production markup, upload image assets, load remote embeds, or execute user-supplied HTML. +- 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. - 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. +- Electron support is not part of the browser extension. An Electron application must explicitly load an extension package from its own code; Chrome's extension menu cannot attach this extension to an arbitrary already-running Electron 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 -- Add optional provider-specific AI handoff after the provider/auth shape is defined +- Evaluate a native-messaging handoff only after the Downloads-inbox workflow proves useful and its permission/install boundary is defined ## License diff --git a/background.js b/background.js index eb45263..d6afefe 100644 --- a/background.js +++ b/background.js @@ -26,11 +26,6 @@ return true; } - if (request.action === 'capture-visual-edit-viewport') { - respondAsync(captureVisualEditViewport(sender.tab), sendResponse); - return true; - } - if (request.action === 'notify-feedback-updated') { respondAsync(notifyFeedbackUpdated(request.tabId), sendResponse); return true; @@ -127,7 +122,7 @@ try { await chrome.scripting.executeScript({ target: { tabId }, - files: ['shared.js', 'visual-edit.js', 'content-proposal.js', 'content.js'] + files: ['shared.js', 'content.js'] }); return { ok: true }; } catch (error) { @@ -135,20 +130,6 @@ } } - async function captureVisualEditViewport(tab) { - if (!tab?.id || !tab.windowId || !canInjectIntoUrl(tab.url || '')) { - return { ok: false, reason: 'Visual evidence requires the active injectable page.' }; - } - - const activeTabs = await chrome.tabs.query({ active: true, windowId: tab.windowId }); - if (!activeTabs.some((activeTab) => activeTab.id === tab.id)) { - return { ok: false, reason: 'Keep the edited page active while capturing visual evidence.' }; - } - - const dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, { format: 'png' }); - return { ok: true, dataUrl }; - } - async function startRegionCapture(tab, viewportMetrics) { if (!tab || !tab.id) { return { ok: false, reason: 'No active tab is available for capture.' }; diff --git a/capture.js b/capture.js index eb64adf..2fe81d4 100644 --- a/capture.js +++ b/capture.js @@ -442,10 +442,8 @@ annotations = resolvedAnnotations; const beforeImage = cropSelectedRegion(); const storageKey = makeStorageKey(session.pageUrl || session.rawTabUrl || ''); - const item = { + const item = globalThis.DevFeedbackShared.createRegionRecord({ id: buildFeedbackId(), - type: CAPTURE_TYPE_REGION, - captureType: CAPTURE_TYPE_REGION, pageUrl: session.pageUrl || session.rawTabUrl || '', pageTitle: session.pageTitle || '', viewportRect: roundRect(selection), @@ -464,7 +462,7 @@ 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) { diff --git a/content-proposal.js b/content-proposal.js deleted file mode 100644 index 5cb55ce..0000000 --- a/content-proposal.js +++ /dev/null @@ -1,170 +0,0 @@ -/** - * Dev Feedback Capture - Safe proposed-content previews - * - * User input is rendered with DOM text nodes only. The HTML frame option is a - * visual placeholder and never executes markup, scripts, or remote content. - */ - -(function(root, factory) { - const api = factory(); - root.DevFeedbackContentProposal = api; - if (typeof module === 'object' && module.exports) { - module.exports = api; - } -})(typeof globalThis !== 'undefined' ? globalThis : this, function() { - 'use strict'; - - const BLOCK_TYPES = Object.freeze(['text', 'image', 'list', 'frame']); - const PLACEMENTS = Object.freeze(['before', 'after', 'inside-start', 'inside-end']); - const CONTAINER_TAGS = new Set([ - 'article', 'aside', 'blockquote', 'body', 'dd', 'div', 'dl', 'dt', - 'fieldset', 'figcaption', 'figure', 'footer', 'form', 'header', 'li', - 'main', 'nav', 'ol', 'section', 'td', 'tfoot', 'th', 'thead', 'tr', 'ul' - ]); - - function sanitizeDefinition(input) { - const raw = input && typeof input === 'object' ? input : {}; - const type = BLOCK_TYPES.includes(raw.type) ? raw.type : 'text'; - const placement = PLACEMENTS.includes(raw.placement) ? raw.placement : 'after'; - const body = cleanText(raw.body, 2000); - const items = (Array.isArray(raw.items) ? raw.items : body.split(/\r?\n/)) - .map((item) => cleanText(item, 280)) - .filter(Boolean) - .slice(0, 12); - - return { - type, - placement, - title: cleanText(raw.title, 160), - body, - items, - altText: cleanText(raw.altText, 500), - support: cleanText(raw.support, 1000) - }; - } - - function createPreviewElement(documentRef, input) { - if (!documentRef?.createElement) { - throw new TypeError('createPreviewElement requires a document.'); - } - - const definition = sanitizeDefinition(input); - const preview = documentRef.createElement('section'); - preview.className = `dev-feedback-content-preview dev-feedback-content-preview-${definition.type}`; - preview.dataset.devFeedbackContentPreview = 'true'; - preview.setAttribute('aria-label', `Proposed ${getTypeLabel(definition.type)} content`); - - const proposalLabel = documentRef.createElement('div'); - proposalLabel.className = 'dev-feedback-content-preview-label'; - proposalLabel.textContent = `Proposed ${getTypeLabel(definition.type)}`; - preview.appendChild(proposalLabel); - - if (definition.title) { - const title = documentRef.createElement('strong'); - title.className = 'dev-feedback-content-preview-title'; - title.textContent = definition.title; - preview.appendChild(title); - } - - if (definition.type === 'image') { - const imagePlaceholder = documentRef.createElement('div'); - imagePlaceholder.className = 'dev-feedback-content-preview-image'; - imagePlaceholder.textContent = 'Image placeholder'; - preview.appendChild(imagePlaceholder); - - const caption = documentRef.createElement('span'); - caption.className = 'dev-feedback-content-preview-copy'; - caption.textContent = definition.altText || definition.body || 'Describe the image, subject, or asset needed.'; - preview.appendChild(caption); - } else if (definition.type === 'list') { - const list = documentRef.createElement('ul'); - const items = definition.items.length ? definition.items : ['First item', 'Second item', 'Third item']; - items.forEach((item) => { - const listItem = documentRef.createElement('li'); - listItem.textContent = item; - list.appendChild(listItem); - }); - preview.appendChild(list); - } else if (definition.type === 'frame') { - const frame = documentRef.createElement('div'); - frame.className = 'dev-feedback-content-preview-frame'; - frame.textContent = definition.body || 'HTML / embed frame placeholder'; - preview.appendChild(frame); - } else { - const paragraph = documentRef.createElement('p'); - paragraph.className = 'dev-feedback-content-preview-copy'; - paragraph.textContent = definition.body || 'Add supporting copy here.'; - preview.appendChild(paragraph); - } - - if (definition.support) { - const support = documentRef.createElement('small'); - support.className = 'dev-feedback-content-preview-support'; - support.textContent = `Supports: ${definition.support}`; - preview.appendChild(support); - } - - return preview; - } - - function insertPreview(anchor, preview, placement) { - if (!anchor?.parentNode || !preview) { - throw new TypeError('insertPreview requires a connected anchor and preview.'); - } - - const normalizedPlacement = PLACEMENTS.includes(placement) ? placement : 'after'; - if (normalizedPlacement.startsWith('inside-') && !canPlaceInside(anchor)) { - throw new Error(`Cannot place content inside a ${String(anchor.tagName || 'void element').toLowerCase()}.`); - } - - if (normalizedPlacement === 'before') { - anchor.parentNode.insertBefore(preview, anchor); - } else if (normalizedPlacement === 'inside-start') { - anchor.insertBefore(preview, anchor.firstChild || null); - } else if (normalizedPlacement === 'inside-end') { - anchor.appendChild(preview); - } else { - anchor.parentNode.insertBefore(preview, anchor.nextSibling || null); - } - - return preview; - } - - function removePreview(preview) { - if (preview?.parentNode) { - preview.parentNode.removeChild(preview); - } - } - - function canPlaceInside(anchor) { - const tag = String(anchor?.tagName || '').toLowerCase(); - return CONTAINER_TAGS.has(tag); - } - - function getTypeLabel(type) { - return ({ - text: 'text', - image: 'image', - list: 'list', - frame: 'HTML frame' - })[type] || 'text'; - } - - function cleanText(value, maxLength) { - return String(value ?? '') - .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '') - .trim() - .slice(0, maxLength); - } - - return { - BLOCK_TYPES, - PLACEMENTS, - canPlaceInside, - createPreviewElement, - getTypeLabel, - insertPreview, - removePreview, - sanitizeDefinition - }; -}); diff --git a/content.js b/content.js index a466655..fbb902a 100644 --- a/content.js +++ b/content.js @@ -41,11 +41,7 @@ const INTERACTION_MODES = Object.freeze({ OFF: 'off', - ELEMENT: 'element', - VISUAL_PICK: 'visual-pick', - VISUAL_EDIT: 'visual-edit', - CONTENT_PICK: 'content-pick', - CONTENT_EDIT: 'content-edit' + ELEMENT: 'element' }); let feedbackMode = false; @@ -61,20 +57,6 @@ let markerLayer = null; let decorationFrame = 0; let modalReturnFocus = null; - let visualSession = null; - let visualTarget = null; - let visualOriginalInfo = null; - let visualBeforeViewport = null; - let visualInitialContext = null; - let visualLastRect = null; - let visualBusy = false; - let visualGesture = null; - let contentAnchor = null; - let contentAnchorInfo = null; - let contentBeforeViewport = null; - let contentInitialContext = null; - let contentPreview = null; - let contentBusy = false; function init() { if (!document.body) { @@ -101,8 +83,6 @@ Local page review history 0 - V* - C*
@@ -114,97 +94,9 @@ - -
-
-
-
-
Visual Edit
-
Pick one page element
-
- -
-

Click an element on the page to begin.

-
- - -
- Direct edit - Drag the outline to move the selected element. - Drag the corner handle to resize it. -
- -
- Save change spec - - - - -
- - -
-
-
-
-
-
-
Add Content
-
Pick a placement anchor
-
- -
-

Click an existing page element to choose where the new content belongs.

- -
- Proposed block -
- - - - -
- - - - - - - - - - - - - - - -
- - -
-
-
`; document.body.appendChild(feedbackPanel); @@ -216,11 +108,7 @@ feedbackPanel.querySelector('#dev-feedback-copy-markdown').addEventListener('click', copyAsMarkdown); feedbackPanel.querySelector('#dev-feedback-copy-ai').addEventListener('click', copyAsAiPrompt); feedbackPanel.querySelector('#dev-feedback-capture-region').addEventListener('click', startRegionCapture); - feedbackPanel.querySelector('#dev-feedback-start-visual').addEventListener('click', startVisualEditMode); - feedbackPanel.querySelector('#dev-feedback-start-content').addEventListener('click', startAddContentMode); feedbackPanel.querySelector('#dev-feedback-clear').addEventListener('click', clearAllFeedback); - bindVisualInspector(); - bindContentInspector(); } function togglePanelCollapsed() { @@ -247,35 +135,6 @@ togglePanelCollapsed(); } - function bindVisualInspector() { - feedbackPanel.querySelector('#dev-feedback-visual-pick-again').addEventListener('click', pickAnotherVisualTarget); - feedbackPanel.querySelector('#dev-feedback-visual-undo').addEventListener('click', () => runVisualCommand(() => visualSession.undo())); - feedbackPanel.querySelector('#dev-feedback-visual-redo').addEventListener('click', () => runVisualCommand(() => visualSession.redo())); - feedbackPanel.querySelector('#dev-feedback-visual-reset').addEventListener('click', resetVisualSession); - feedbackPanel.querySelector('#dev-feedback-save-visual').addEventListener('click', saveVisualSpec); - feedbackPanel.querySelector('#dev-feedback-cancel-visual').addEventListener('click', cancelVisualEdit); - } - - function bindContentInspector() { - const ids = [ - 'dev-feedback-content-type', - 'dev-feedback-content-placement', - 'dev-feedback-content-heading', - 'dev-feedback-content-body', - 'dev-feedback-content-alt', - 'dev-feedback-content-support' - ]; - ids.forEach((id) => { - const field = feedbackPanel.querySelector(`#${id}`); - field.addEventListener(field.tagName === 'SELECT' ? 'change' : 'input', updateContentPreview); - }); - feedbackPanel.querySelector('#dev-feedback-content-type').addEventListener('change', syncContentFieldLabels); - feedbackPanel.querySelector('#dev-feedback-content-pick-again').addEventListener('click', pickAnotherContentAnchor); - feedbackPanel.querySelector('#dev-feedback-save-content').addEventListener('click', saveContentSpec); - feedbackPanel.querySelector('#dev-feedback-cancel-content').addEventListener('click', cancelContentProposal); - syncContentFieldLabels(); - } - function clampPanelToViewport() { const rect = feedbackPanel.getBoundingClientRect(); feedbackPanel.style.left = `${clamp(rect.left, 8, Math.max(8, window.innerWidth - rect.width - 8))}px`; @@ -374,10 +233,6 @@ window.addEventListener('resize', handleViewportResize, { passive: true }); window.addEventListener('scroll', scheduleDecorationRefresh, true); document.addEventListener('keydown', handleGlobalKeydown); - window.addEventListener('pagehide', restoreVisualOnPageExit); - window.addEventListener('beforeunload', restoreVisualOnPageExit); - window.addEventListener('popstate', restoreVisualAfterSameDocumentNavigation); - window.addEventListener('hashchange', restoreVisualAfterSameDocumentNavigation); } function handleViewportResize() { @@ -401,25 +256,6 @@ return; } - if (event.key === 'Escape' && contentPreview && !contentBusy) { - event.preventDefault(); - cancelContentProposal(); - return; - } - - const editable = /^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement?.tagName || ''); - if (!editable && visualSession && (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'z') { - event.preventDefault(); - runVisualCommand(() => event.shiftKey ? visualSession.redo() : visualSession.undo()); - return; - } - - if (!editable && visualSession && event.ctrlKey && event.key.toLowerCase() === 'y') { - event.preventDefault(); - runVisualCommand(() => visualSession.redo()); - return; - } - if (event.key === 'Tab' && captureModal.classList.contains('visible')) { trapModalFocus(event); } @@ -450,50 +286,19 @@ setInteractionMode(enabled ? INTERACTION_MODES.ELEMENT : INTERACTION_MODES.OFF); } - function setInteractionMode(nextMode, options = {}) { + function setInteractionMode(nextMode) { if (interactionMode === nextMode) { return true; } - if ((visualBusy && visualSession) || contentBusy) { - showNotification('Wait for the current visual evidence capture to finish.', 'error'); - return false; - } - if ( - visualSession - && !String(nextMode).startsWith('visual-') - && visualSession.getState().dirty - && !options.discardVisual - ) { - showNotification('Save or Cancel the visual preview before changing modes.', 'error'); - return false; - } - if (visualSession && !String(nextMode).startsWith('visual-')) { - restoreVisualSession(); - } - if ( - contentPreview - && !String(nextMode).startsWith('content-') - && !options.discardContent - ) { - showNotification('Save or Cancel the content proposal before changing modes.', 'error'); - return false; - } - if (contentPreview && !String(nextMode).startsWith('content-')) { - restoreContentProposal(); - } interactionMode = nextMode; feedbackMode = nextMode === INTERACTION_MODES.ELEMENT; feedbackPanel.classList.toggle('visible', nextMode !== INTERACTION_MODES.OFF); - feedbackPanel.classList.toggle('visual-active', String(nextMode).startsWith('visual-')); - feedbackPanel.classList.toggle('content-active', String(nextMode).startsWith('content-')); closeCaptureModal(); if (nextMode === INTERACTION_MODES.OFF) { disableElementHighlighting(); } else { enableElementHighlighting(); } - renderVisualInspector(); - renderContentInspector(); scheduleDecorationRefresh(); if (nextMode !== INTERACTION_MODES.OFF) { window.requestAnimationFrame(() => { @@ -508,49 +313,7 @@ } function stopInteractionMode() { - setInteractionMode(INTERACTION_MODES.OFF, { discardVisual: true, discardContent: true }); - } - - function startVisualEditMode() { - if (!globalThis.DevFeedbackVisualEdit?.createSession) { - showNotification('Visual Edit did not load. Refresh the page and try again.', 'error'); - return { ok: false, reason: 'Visual Edit engine unavailable.' }; - } - if (visualBusy) { - return { ok: false, reason: 'Wait for the current visual evidence capture to finish.' }; - } - if (visualSession?.getState().dirty) { - return { ok: false, reason: 'Save or Cancel the current visual preview before starting another.' }; - } - if (visualSession) { - restoreVisualSession(); - } - setInteractionMode(INTERACTION_MODES.VISUAL_PICK); - setPanelCollapsed(true); - showNotification('Visual Edit: pick one page element.'); - return { ok: true }; - } - - function startAddContentMode() { - if (!globalThis.DevFeedbackContentProposal?.createPreviewElement) { - showNotification('Add Content did not load. Refresh the page and try again.', 'error'); - return { ok: false, reason: 'Add Content engine unavailable.' }; - } - if (visualBusy || contentBusy) { - return { ok: false, reason: 'Wait for the current evidence capture to finish.' }; - } - if (visualSession?.getState().dirty) { - return { ok: false, reason: 'Save or Cancel the visual preview before adding content.' }; - } - if (contentPreview) { - return { ok: false, reason: 'Save or Cancel the current content proposal before starting another.' }; - } - if (!setInteractionMode(INTERACTION_MODES.CONTENT_PICK)) { - return { ok: false, reason: 'Finish the current preview before adding content.' }; - } - setPanelCollapsed(true); - showNotification('Add Content: pick the element that should anchor the new block.'); - return { ok: true }; + setInteractionMode(INTERACTION_MODES.OFF); } function enableElementHighlighting() { @@ -571,7 +334,7 @@ } function handleMouseOver(event) { - if (interactionMode === INTERACTION_MODES.OFF || interactionMode === INTERACTION_MODES.VISUAL_EDIT || interactionMode === INTERACTION_MODES.CONTENT_EDIT || isOurElement(event.target)) { + if (interactionMode === INTERACTION_MODES.OFF || isOurElement(event.target)) { return; } @@ -579,7 +342,7 @@ } function handleMouseOut(event) { - if (interactionMode === INTERACTION_MODES.OFF || interactionMode === INTERACTION_MODES.VISUAL_EDIT || interactionMode === INTERACTION_MODES.CONTENT_EDIT || isOurElement(event.target)) { + if (interactionMode === INTERACTION_MODES.OFF || isOurElement(event.target)) { return; } @@ -587,7 +350,7 @@ } function handleElementClick(event) { - if (interactionMode === INTERACTION_MODES.OFF || interactionMode === INTERACTION_MODES.VISUAL_EDIT || interactionMode === INTERACTION_MODES.CONTENT_EDIT) { + if (interactionMode !== INTERACTION_MODES.ELEMENT) { return; } @@ -598,13 +361,7 @@ event.preventDefault(); event.stopPropagation(); - if (interactionMode === INTERACTION_MODES.ELEMENT) { - captureElement(target); - } else if (interactionMode === INTERACTION_MODES.VISUAL_PICK) { - selectVisualTarget(target); - } else if (interactionMode === INTERACTION_MODES.CONTENT_PICK) { - selectContentAnchor(target); - } + captureElement(target); } function isOurElement(element) { @@ -614,8 +371,7 @@ (element.id && element.id.startsWith('dev-feedback')) || (typeof element.closest === 'function' && element.closest(SELECTORS.panel)) || (typeof element.closest === 'function' && element.closest(SELECTORS.modal)) || - (typeof element.closest === 'function' && element.closest(SELECTORS.markerLayer)) || - (typeof element.closest === 'function' && element.closest('[data-dev-feedback-content-preview]')) + (typeof element.closest === 'function' && element.closest(SELECTORS.markerLayer)) ) ); } @@ -628,790 +384,6 @@ showCaptureModal(); } - async function selectVisualTarget(element) { - if (visualBusy || isOurElement(element)) { - return; - } - if (element === document.body || element === document.documentElement || element.contains(feedbackPanel)) { - showNotification('Pick a specific page element rather than the document root.', 'error'); - return; - } - visualBusy = true; - element.classList.remove('dev-feedback-highlight'); - try { - const originalInfo = buildElementSnapshot(element); - const beforeViewport = await captureVisualViewport(); - if (!beforeViewport) { - throw new Error('Unable to capture before evidence. Keep this tab visible and try again.'); - } - visualTarget = element; - visualOriginalInfo = originalInfo; - visualBeforeViewport = beforeViewport; - visualInitialContext = buildPageContext(); - visualLastRect = originalInfo.rect; - visualSession = globalThis.DevFeedbackVisualEdit.createSession({ - target: element, - maxCommands: 24, - buildTargetSnapshot: buildVisualTargetSnapshot - }); - interactionMode = INTERACTION_MODES.VISUAL_EDIT; - feedbackPanel.classList.add('visual-active'); - clearVisualRequestFields(); - placePanelOppositeTarget(originalInfo.rect); - setPanelCollapsed(false); - renderVisualInspector('Preview changes here. The page is restored after Save or Cancel.'); - scheduleDecorationRefresh(); - } catch (error) { - restoreVisualSession(); - interactionMode = INTERACTION_MODES.VISUAL_PICK; - showNotification(error.message || 'Unable to start a visual edit for this element.', 'error'); - } finally { - visualBusy = false; - if (visualSession) { - renderVisualInspector(); - } - } - } - - async function selectContentAnchor(element) { - if (contentBusy || isOurElement(element)) { - return; - } - if (element === document.body || element === document.documentElement || element.contains(feedbackPanel)) { - showNotification('Pick a specific page element to anchor the new content.', 'error'); - return; - } - - contentBusy = true; - element.classList.remove('dev-feedback-highlight'); - try { - const anchorInfo = buildElementSnapshot(element); - const beforeViewport = await captureVisualViewport(); - if (!beforeViewport) { - throw new Error('Unable to capture before evidence. Keep this tab visible and try again.'); - } - restoreContentProposal(); - contentBusy = true; - contentAnchor = element; - contentAnchor.classList.add('dev-feedback-selected'); - contentAnchorInfo = anchorInfo; - contentBeforeViewport = beforeViewport; - contentInitialContext = buildPageContext(); - clearContentFields(); - interactionMode = INTERACTION_MODES.CONTENT_EDIT; - feedbackPanel.classList.add('content-active'); - placePanelOppositeTarget(anchorInfo.rect); - setPanelCollapsed(false); - updateContentPreview(); - renderContentInspector('Preview this block, add its purpose, then save the insert spec.'); - scheduleDecorationRefresh(); - } catch (error) { - restoreContentProposal(); - interactionMode = INTERACTION_MODES.CONTENT_PICK; - showNotification(error.message || 'Unable to start a content proposal for this element.', 'error'); - } finally { - contentBusy = false; - renderContentInspector(); - } - } - - function clearContentFields() { - feedbackPanel.querySelector('#dev-feedback-content-type').value = 'text'; - feedbackPanel.querySelector('#dev-feedback-content-placement').value = 'after'; - feedbackPanel.querySelector('#dev-feedback-content-heading').value = ''; - feedbackPanel.querySelector('#dev-feedback-content-body').value = ''; - feedbackPanel.querySelector('#dev-feedback-content-alt').value = ''; - feedbackPanel.querySelector('#dev-feedback-content-support').value = ''; - feedbackPanel.querySelector('#dev-feedback-content-acceptance').value = ''; - syncContentFieldLabels(); - } - - function readContentDefinition() { - const body = feedbackPanel.querySelector('#dev-feedback-content-body').value; - return globalThis.DevFeedbackContentProposal.sanitizeDefinition({ - type: feedbackPanel.querySelector('#dev-feedback-content-type').value, - placement: feedbackPanel.querySelector('#dev-feedback-content-placement').value, - title: feedbackPanel.querySelector('#dev-feedback-content-heading').value, - body, - items: body.split(/\r?\n/), - altText: feedbackPanel.querySelector('#dev-feedback-content-alt').value, - support: feedbackPanel.querySelector('#dev-feedback-content-support').value - }); - } - - function syncContentFieldLabels() { - const type = feedbackPanel.querySelector('#dev-feedback-content-type').value; - const bodyLabel = feedbackPanel.querySelector('#dev-feedback-content-body-label'); - const bodyField = feedbackPanel.querySelector('#dev-feedback-content-body'); - const altGroup = feedbackPanel.querySelector('#dev-feedback-content-alt-group'); - altGroup.hidden = type !== 'image'; - - if (type === 'list') { - bodyLabel.textContent = 'List items (one per line)'; - bodyField.placeholder = 'First item\nSecond item\nThird item'; - } else if (type === 'frame') { - bodyLabel.textContent = 'Frame placeholder details'; - bodyField.placeholder = 'Describe the widget, embed, HTML fragment, or external content area...'; - } else if (type === 'image') { - bodyLabel.textContent = 'Caption or filler copy'; - bodyField.placeholder = 'Optional caption or supporting text...'; - } else { - bodyLabel.textContent = 'Filler content'; - bodyField.placeholder = 'Draft the copy or basic filler...'; - } - } - - function updateContentPreview() { - syncContentFieldLabels(); - globalThis.DevFeedbackContentProposal.removePreview(contentPreview); - contentPreview = null; - if (!contentAnchor?.isConnected) { - renderContentInspector(); - return; - } - - const definition = readContentDefinition(); - try { - const preview = globalThis.DevFeedbackContentProposal.createPreviewElement(document, definition); - globalThis.DevFeedbackContentProposal.insertPreview(contentAnchor, preview, definition.placement); - contentPreview = preview; - renderContentInspector(); - scheduleDecorationRefresh(); - } catch (error) { - renderContentInspector(error.message || 'That placement is not supported for this anchor.'); - } - } - - function renderContentInspector(statusMessage) { - if (!feedbackPanel) { - return; - } - const inspector = feedbackPanel.querySelector('.dev-feedback-content-inspector'); - if (!inspector) { - return; - } - const contentActive = String(interactionMode).startsWith('content-'); - const canPlaceInside = globalThis.DevFeedbackContentProposal?.canPlaceInside(contentAnchor); - feedbackPanel.querySelectorAll('#dev-feedback-content-placement option').forEach((option) => { - option.disabled = option.value.startsWith('inside-') && !canPlaceInside; - }); - feedbackPanel.classList.toggle('content-active', contentActive); - feedbackPanel.classList.toggle('content-dirty', Boolean(contentPreview)); - feedbackPanel.querySelector('#dev-feedback-content-title').textContent = contentAnchor - ? `${contentAnchorInfo?.tag || 'element'} · ${contentAnchorInfo?.selector || ''}` - : 'Pick a placement anchor'; - const status = feedbackPanel.querySelector('#dev-feedback-content-status'); - if (statusMessage) { - status.textContent = statusMessage; - } else if (!contentAnchor) { - status.textContent = 'Click an existing page element to choose where the new content belongs.'; - } else if (contentPreview) { - status.textContent = 'Reversible content preview is active.'; - } - inspector.querySelector('fieldset').disabled = !contentAnchor || contentBusy; - feedbackPanel.querySelector('#dev-feedback-save-content').disabled = !contentPreview || contentBusy; - feedbackPanel.querySelector('#dev-feedback-content-pick-again').disabled = !contentAnchor; - const closeButton = feedbackPanel.querySelector('#dev-feedback-panel-close'); - if (contentActive) { - closeButton.title = 'Stop Add Content and restore page'; - closeButton.setAttribute('aria-label', closeButton.title); - } - } - - function pickAnotherContentAnchor() { - if (contentBusy) { - showNotification('Wait for the current evidence capture to finish.', 'error'); - return; - } - restoreContentProposal(); - interactionMode = INTERACTION_MODES.CONTENT_PICK; - feedbackPanel.classList.add('content-active'); - setPanelCollapsed(true); - renderContentInspector('Click another page element to choose a new anchor.'); - scheduleDecorationRefresh(); - } - - function cancelContentProposal() { - if (contentBusy) { - showNotification('Wait for the current evidence capture to finish.', 'error'); - return; - } - restoreContentProposal(); - interactionMode = INTERACTION_MODES.CONTENT_PICK; - feedbackPanel.classList.add('content-active'); - setPanelCollapsed(true); - renderContentInspector('Preview removed. Pick another placement anchor.'); - showNotification('Content proposal cancelled and page restored.'); - scheduleDecorationRefresh(); - } - - function restoreContentProposal() { - globalThis.DevFeedbackContentProposal?.removePreview(contentPreview); - contentAnchor?.classList.remove('dev-feedback-selected'); - contentPreview = null; - contentAnchor = null; - contentAnchorInfo = null; - contentBeforeViewport = null; - contentInitialContext = null; - contentBusy = false; - feedbackPanel?.classList.remove('content-dirty'); - } - - async function saveContentSpec() { - if (!contentAnchor || !contentPreview || contentBusy) { - return; - } - const definition = readContentDefinition(); - const hasUsefulInput = Boolean( - definition.title || definition.body || definition.altText || definition.items.length || definition.support - ); - if (!hasUsefulInput) { - const supportField = feedbackPanel.querySelector('#dev-feedback-content-support'); - showNotification('Add filler content or describe what this block should support.', 'error'); - supportField.focus(); - return; - } - if (!contentAnchor.isConnected || !contentPreview.isConnected || !sameContentViewport()) { - showNotification('The page moved or changed. Pick the placement anchor again.', 'error'); - return; - } - - contentBusy = true; - renderContentInspector('Capturing proposed evidence...'); - try { - const proposedViewport = await captureVisualViewport(); - if (!proposedViewport?.dataUrl || !contentBeforeViewport?.dataUrl) { - throw new Error('Could not capture both before and proposed evidence. Nothing was saved.'); - } - const evidenceRect = buildEvidenceRect(contentAnchorInfo.rect, getViewportRect(contentPreview)); - const [beforeDataUrl, proposedDataUrl] = await Promise.all([ - cropViewportImage(contentBeforeViewport.dataUrl, evidenceRect), - cropViewportImage(proposedViewport.dataUrl, evidenceRect) - ]); - if (!beforeDataUrl || !proposedDataUrl) { - throw new Error('Content proposal evidence could not be cropped. Nothing was saved.'); - } - - const summary = definition.support - || definition.title - || `Add a ${globalThis.DevFeedbackContentProposal.getTypeLabel(definition.type)} block`; - const acceptance = feedbackPanel.querySelector('#dev-feedback-content-acceptance').value - .split(/\r?\n/) - .map((criterion) => criterion.trim()) - .filter(Boolean) - .slice(0, 12); - const item = { - specVersion: 2, - id: buildFeedbackId(), - type: CAPTURE_TYPE_ELEMENT, - captureType: CAPTURE_TYPE_ELEMENT, - selector: contentAnchorInfo.selector, - pageUrl: window.location.href, - pageTitle: document.title, - elementInfo: toStoredElementInfo(contentAnchorInfo), - proposedElementInfo: toStoredElementInfo(buildElementSnapshot(contentPreview)), - position: contentAnchorInfo.position, - pageContext: contentInitialContext, - changeRequest: { - kind: 'requested-mutation', - summary: summary.slice(0, MAX_NOTE_LENGTH), - requestedMutations: [{ - id: `insert-${Date.now()}`, - action: 'insert', - target: toMutationTargetSnapshot(contentAnchorInfo), - parameters: { - placement: definition.placement, - content: definition - } - }] - }, - evidence: { - before: { mimeType: 'image/png', dataUrl: beforeDataUrl, source: { kind: 'captured' } }, - proposed: { mimeType: 'image/png', dataUrl: proposedDataUrl, source: { kind: 'rendered-preview' } } - }, - acceptance, - note: summary.slice(0, MAX_NOTE_LENGTH), - timestamp: new Date().toISOString() - }; - - restoreContentProposal(); - contentBusy = true; - const nextItems = await runFeedbackMutation('add-feedback-item', { item }); - if (!nextItems) { - throw new Error('Unable to store the content insertion spec.'); - } - feedbackItems = sanitizeFeedbackItems(nextItems, window.location.href, document.title); - interactionMode = INTERACTION_MODES.CONTENT_PICK; - feedbackPanel.classList.add('content-active'); - setPanelCollapsed(true); - updateFeedbackPanel(); - renderContentInspector('Saved locally. Pick another placement anchor.'); - scheduleDecorationRefresh(); - showNotification('Content insert spec saved and page restored.'); - } catch (error) { - restoreContentProposal(); - interactionMode = INTERACTION_MODES.CONTENT_PICK; - renderContentInspector('Save failed after restoring the page. Pick the anchor again.'); - showNotification(error.message || 'Unable to save the content insert spec.', 'error'); - } finally { - contentBusy = false; - renderContentInspector(); - } - } - - function sameContentViewport() { - const viewport = contentInitialContext?.viewport; - return contentInitialContext?.url === window.location.href - && viewport - && Math.abs(viewport.scrollX - window.scrollX) <= 2 - && Math.abs(viewport.scrollY - window.scrollY) <= 2 - && Math.abs(viewport.width - window.innerWidth) <= 2 - && Math.abs(viewport.height - window.innerHeight) <= 2 - && Math.abs(viewport.devicePixelRatio - window.devicePixelRatio) <= 0.02; - } - - function buildVisualTargetSnapshot(element) { - return toMutationTargetSnapshot(buildElementSnapshot(element)); - } - - function toMutationTargetSnapshot(snapshot) { - return { - selectors: snapshot.selectors, - tag: snapshot.tag, - role: snapshot.role, - text: snapshot.text, - rect: snapshot.rect, - surroundingText: snapshot.surroundingText, - parentLayout: snapshot.parentLayout - }; - } - - function clearVisualRequestFields() { - feedbackPanel.querySelector('#dev-feedback-visual-note').value = ''; - feedbackPanel.querySelector('#dev-feedback-visual-acceptance').value = ''; - } - - function placePanelOppositeTarget(rect) { - feedbackPanel.style.top = `${clamp(18, 8, Math.max(8, window.innerHeight - feedbackPanel.offsetHeight - 8))}px`; - if (rect.x + rect.width / 2 > window.innerWidth / 2) { - feedbackPanel.style.left = '18px'; - feedbackPanel.style.right = 'auto'; - } else { - feedbackPanel.style.left = 'auto'; - feedbackPanel.style.right = '18px'; - } - } - - function runVisualCommand(command, onApplied) { - if (!visualSession || visualBusy) { - return false; - } - try { - const result = command(); - if (result !== null && result !== false && onApplied) { - onApplied(); - } - if (visualTarget?.isConnected) { - const rect = getViewportRect(visualTarget); - if (rect.width > 0 && rect.height > 0) { - visualLastRect = rect; - } - } - renderVisualInspector(); - scheduleDecorationRefresh(); - return result !== null && result !== false; - } catch (error) { - showNotification(error.message || 'Unable to apply that visual edit.', 'error'); - return false; - } - } - - function resetVisualSession() { - if (!visualSession) { - return; - } - visualSession.reset(); - renderVisualInspector('All preview edits reset.'); - scheduleDecorationRefresh(); - } - - function pickAnotherVisualTarget() { - if (visualBusy) { - showNotification('Wait for the current visual evidence capture to finish.', 'error'); - return; - } - if (visualSession?.getState().dirty) { - showNotification('Save, Reset, or Cancel the current preview before picking another element.', 'error'); - return; - } - restoreVisualSession(); - interactionMode = INTERACTION_MODES.VISUAL_PICK; - feedbackPanel.classList.add('visual-active'); - setPanelCollapsed(true); - renderVisualInspector('Click another page element to begin.'); - scheduleDecorationRefresh(); - } - - function cancelVisualEdit() { - if (visualBusy) { - showNotification('Wait for the current visual evidence capture to finish.', 'error'); - return; - } - restoreVisualSession(); - interactionMode = INTERACTION_MODES.VISUAL_PICK; - feedbackPanel.classList.add('visual-active'); - setPanelCollapsed(true); - renderVisualInspector('Preview restored. Pick another element.'); - showNotification('Visual preview cancelled and page restored.'); - } - - function restoreVisualSession() { - clearVisualGestureListeners(); - if (visualSession) { - try { - visualSession.restore(); - } catch (error) { - console.debug('Unable to restore visual edit session:', error.message); - } - } - visualSession = null; - visualTarget = null; - visualOriginalInfo = null; - visualBeforeViewport = null; - visualInitialContext = null; - visualLastRect = null; - visualBusy = false; - if (feedbackPanel) { - feedbackPanel.classList.remove('visual-dirty'); - } - } - - function restoreVisualOnPageExit() { - if (!visualSession && !contentPreview) { - return; - } - restoreVisualSession(); - restoreContentProposal(); - interactionMode = INTERACTION_MODES.OFF; - feedbackMode = false; - disableElementHighlighting(); - if (feedbackPanel) { - feedbackPanel.classList.remove('visible', 'visual-active', 'content-active'); - } - } - - function restoreVisualAfterSameDocumentNavigation() { - if (!visualSession && !contentPreview) { - return; - } - const wasContent = Boolean(contentPreview); - restoreVisualSession(); - restoreContentProposal(); - interactionMode = wasContent ? INTERACTION_MODES.CONTENT_PICK : INTERACTION_MODES.VISUAL_PICK; - feedbackMode = false; - feedbackPanel.classList.add('visible', wasContent ? 'content-active' : 'visual-active'); - setPanelCollapsed(true); - if (wasContent) { - renderContentInspector('Page navigation restored the preview. Pick an anchor again.'); - } else { - renderVisualInspector('Page navigation restored the preview. Pick an element again.'); - } - scheduleDecorationRefresh(); - } - - function renderVisualInspector(statusMessage) { - if (!feedbackPanel) { - return; - } - const inspector = feedbackPanel.querySelector('.dev-feedback-visual-inspector'); - if (!inspector) { - return; - } - const visualActive = String(interactionMode).startsWith('visual-'); - feedbackPanel.classList.toggle('visual-active', visualActive); - const state = visualSession?.getState(); - feedbackPanel.classList.toggle('visual-dirty', Boolean(state?.dirty)); - const title = feedbackPanel.querySelector('#dev-feedback-visual-title'); - title.textContent = visualTarget - ? `${visualOriginalInfo?.tag || 'element'} · ${visualOriginalInfo?.selector || ''}` - : 'Pick one page element'; - if (statusMessage) { - feedbackPanel.querySelector('#dev-feedback-visual-status').textContent = statusMessage; - } else if (!visualTarget) { - feedbackPanel.querySelector('#dev-feedback-visual-status').textContent = 'Click an element on the page to begin.'; - } else { - feedbackPanel.querySelector('#dev-feedback-visual-status').textContent = state?.dirty - ? `${state.appliedCommandCount} reversible edit${state.appliedCommandCount === 1 ? '' : 's'} applied.` - : 'No preview edits yet.'; - } - const chips = feedbackPanel.querySelector('#dev-feedback-operation-chips'); - chips.replaceChildren(); - const commands = visualSession?.snapshot().commands || []; - commands.forEach((command) => { - const chip = document.createElement('span'); - chip.className = 'dev-feedback-operation-chip'; - chip.textContent = command.label; - chips.appendChild(chip); - }); - feedbackPanel.querySelector('#dev-feedback-visual-undo').disabled = !state?.canUndo; - feedbackPanel.querySelector('#dev-feedback-visual-redo').disabled = !state?.canRedo; - feedbackPanel.querySelector('#dev-feedback-visual-reset').disabled = !state?.dirty; - feedbackPanel.querySelector('#dev-feedback-save-visual').disabled = !state?.dirty || visualBusy; - feedbackPanel.querySelector('#dev-feedback-visual-pick-again').disabled = !visualTarget; - const closeButton = feedbackPanel.querySelector('#dev-feedback-panel-close'); - closeButton.title = visualActive ? 'Stop Visual Edit and restore page' : 'Stop element mode'; - closeButton.setAttribute('aria-label', closeButton.title); - inspector.querySelectorAll('fieldset').forEach((fieldset) => { - fieldset.disabled = !visualSession || visualBusy; - }); - } - - async function saveVisualSpec() { - if (!visualSession || visualBusy) { - return; - } - const noteField = feedbackPanel.querySelector('#dev-feedback-visual-note'); - const note = noteField.value.trim(); - if (!note) { - showNotification('Add an implementation request before saving.', 'error'); - noteField.focus(); - return; - } - const state = visualSession.getState(); - if (!state.dirty) { - showNotification('Apply at least one visual edit before saving.', 'error'); - return; - } - if (!visualTarget?.isConnected || !sameVisualViewport()) { - showNotification('The page moved or changed. Reset and pick the element again.', 'error'); - return; - } - - visualBusy = true; - let finalStatus = ''; - renderVisualInspector('Capturing proposed evidence...'); - try { - const proposedViewport = await captureVisualViewport(); - if (!proposedViewport?.dataUrl || !visualBeforeViewport?.dataUrl) { - throw new Error('Could not capture both before and proposed evidence. Nothing was saved.'); - } - const currentRect = getViewportRect(visualTarget); - const evidenceRect = buildEvidenceRect(visualOriginalInfo.rect, currentRect.width > 0 ? currentRect : visualLastRect); - const [beforeDataUrl, proposedDataUrl] = await Promise.all([ - cropViewportImage(visualBeforeViewport.dataUrl, evidenceRect), - cropViewportImage(proposedViewport.dataUrl, evidenceRect) - ]); - if (!beforeDataUrl || !proposedDataUrl) { - throw new Error('Visual evidence could not be cropped. Nothing was saved.'); - } - - const sessionSnapshot = visualSession.snapshot(); - const proposedElementInfo = buildElementSnapshot(visualTarget); - const requestedMutations = buildRequestedMutations(sessionSnapshot, proposedElementInfo); - if (!requestedMutations.length) { - throw new Error('No supported visual mutations were produced. Nothing was saved.'); - } - const acceptance = feedbackPanel.querySelector('#dev-feedback-visual-acceptance').value - .split(/\r?\n/) - .map((criterion) => criterion.trim()) - .filter(Boolean) - .slice(0, 12); - const item = { - specVersion: 2, - id: buildFeedbackId(), - type: CAPTURE_TYPE_ELEMENT, - captureType: CAPTURE_TYPE_ELEMENT, - selector: visualOriginalInfo.selector, - pageUrl: window.location.href, - pageTitle: document.title, - elementInfo: toStoredElementInfo(visualOriginalInfo), - proposedElementInfo: toStoredElementInfo(proposedElementInfo), - position: visualOriginalInfo.position, - pageContext: visualInitialContext, - changeRequest: { - kind: 'requested-mutation', - summary: note.slice(0, MAX_NOTE_LENGTH), - requestedMutations - }, - evidence: { - before: { mimeType: 'image/png', dataUrl: beforeDataUrl, source: { kind: 'captured' } }, - proposed: { mimeType: 'image/png', dataUrl: proposedDataUrl, source: { kind: 'rendered-preview' } } - }, - acceptance, - note: note.slice(0, MAX_NOTE_LENGTH), - timestamp: new Date().toISOString() - }; - - visualSession.restore(); - visualSession = null; - const nextItems = await runFeedbackMutation('add-feedback-item', { item }); - if (!nextItems) { - throw new Error('Unable to store the visual change spec.'); - } - feedbackItems = sanitizeFeedbackItems(nextItems, window.location.href, document.title); - restoreVisualSession(); - interactionMode = INTERACTION_MODES.VISUAL_PICK; - feedbackPanel.classList.add('visual-active'); - setPanelCollapsed(true); - updateFeedbackPanel(); - finalStatus = 'Saved locally. Pick another element.'; - scheduleDecorationRefresh(); - showNotification('Visual change spec saved and page restored.'); - } catch (error) { - showNotification(error.message || 'Unable to save the visual change spec.', 'error'); - if (visualSession) { - finalStatus = 'Save failed. Your reversible preview is still active.'; - } else { - restoreVisualSession(); - interactionMode = INTERACTION_MODES.VISUAL_PICK; - finalStatus = 'Save failed after restoring the page. Pick the element again.'; - } - } finally { - visualBusy = false; - renderVisualInspector(finalStatus); - } - } - - function toStoredElementInfo(info) { - return { - tag: info.tag, - classes: info.classes, - text: info.text, - styles: info.styles, - role: info.role, - surroundingText: info.surroundingText, - parentLayout: info.parentLayout - }; - } - - function buildRequestedMutations(sessionSnapshot, proposedInfo) { - return sessionSnapshot.commands.slice(0, 24).flatMap((command, index) => { - const operations = command.operations || []; - const target = command.target || sessionSnapshot.target; - let action = command.kind; - if (action === 'text') action = 'rewrite'; - if (action === 'style' || action === 'match-style') action = 'restyle'; - if (action === 'align') action = 'move'; - let parameters = {}; - - if (action === 'rewrite') { - const operation = operations.find((candidate) => candidate.kind === 'text'); - parameters = { text: operation?.after?.value || proposedInfo.text }; - } else if (action === 'hide') { - parameters = { hidden: operations.some((operation) => operation.after?.value === 'none') }; - } else if (action === 'reorder') { - parameters = { index: Math.max(0, Array.from(visualTarget.parentElement?.children || []).indexOf(visualTarget)) }; - } else if (action === 'move') { - const operation = operations.find((candidate) => candidate.property === 'translate'); - const before = parseTranslateValue(operation?.before?.value); - const after = parseTranslateValue(operation?.after?.value); - parameters = { deltaX: after.x - before.x, deltaY: after.y - before.y }; - } else if (action === 'resize') { - operations.forEach((operation) => { - if (operation.property === 'width' || operation.property === 'height') { - parameters[operation.property] = Math.max(0, Number.parseFloat(operation.after?.value) || 0); - } - }); - } else if (action === 'restyle') { - parameters = { - styles: operations.reduce((styles, operation) => { - if (operation.property && operation.after?.value !== undefined) { - styles[operation.property] = operation.after.value; - } - return styles; - }, {}) - }; - } - - return [{ - id: command.id || `mutation-${index + 1}`, - action, - target, - parameters - }]; - }); - } - - function parseTranslateValue(value) { - const match = String(value || '').trim().match(/^(-?\d+(?:\.\d+)?)px(?:\s+(-?\d+(?:\.\d+)?)px)?$/); - return match ? { x: Number(match[1]), y: Number(match[2] || 0) } : { x: 0, y: 0 }; - } - - function sameVisualViewport() { - const viewport = visualInitialContext?.viewport; - return visualInitialContext?.url === window.location.href - && viewport - && Math.abs(viewport.scrollX - window.scrollX) <= 2 - && Math.abs(viewport.scrollY - window.scrollY) <= 2 - && Math.abs(viewport.width - window.innerWidth) <= 2 - && Math.abs(viewport.height - window.innerHeight) <= 2 - && Math.abs(viewport.devicePixelRatio - window.devicePixelRatio) <= 0.02; - } - - async function captureVisualViewport() { - const elements = [feedbackPanel, captureModal, markerLayer]; - const visibility = elements.map((element) => element?.style.visibility || ''); - const highlighted = Array.from(document.querySelectorAll('.dev-feedback-highlight')); - const selected = Array.from(document.querySelectorAll('.dev-feedback-selected')); - elements.forEach((element) => { - if (element) element.style.visibility = 'hidden'; - }); - highlighted.forEach((element) => element.classList.remove('dev-feedback-highlight')); - selected.forEach((element) => element.classList.remove('dev-feedback-selected')); - try { - await nextAnimationFrame(); - await nextAnimationFrame(); - const response = await chrome.runtime.sendMessage({ action: 'capture-visual-edit-viewport' }); - return response?.ok && /^data:image\/png;base64,/i.test(response.dataUrl || '') - ? { dataUrl: response.dataUrl, metrics: getViewportMetrics() } - : null; - } finally { - elements.forEach((element, index) => { - if (element) element.style.visibility = visibility[index]; - }); - if (interactionMode !== INTERACTION_MODES.VISUAL_EDIT) { - highlighted.forEach((element) => element.classList.add('dev-feedback-highlight')); - } - scheduleDecorationRefresh(); - } - } - - function buildEvidenceRect(originalRect, proposedRect) { - const padding = 24; - const left = Math.max(0, Math.min(originalRect.x, proposedRect.x) - padding); - const top = Math.max(0, Math.min(originalRect.y, proposedRect.y) - padding); - const right = Math.min(window.innerWidth, Math.max(originalRect.x + originalRect.width, proposedRect.x + proposedRect.width) + padding); - const bottom = Math.min(window.innerHeight, Math.max(originalRect.y + originalRect.height, proposedRect.y + proposedRect.height) + padding); - return { x: left, y: top, width: Math.max(1, right - left), height: Math.max(1, bottom - top) }; - } - - async function cropViewportImage(dataUrl, rect) { - const image = await loadDataImage(dataUrl); - const scaleX = image.naturalWidth / window.innerWidth; - const scaleY = image.naturalHeight / window.innerHeight; - const sourceX = Math.round(rect.x * scaleX); - const sourceY = Math.round(rect.y * scaleY); - const sourceWidth = Math.max(1, Math.round(rect.width * scaleX)); - const sourceHeight = Math.max(1, Math.round(rect.height * scaleY)); - const canvas = document.createElement('canvas'); - canvas.width = sourceWidth; - canvas.height = sourceHeight; - const context = canvas.getContext('2d'); - if (!context) { - return ''; - } - context.drawImage(image, sourceX, sourceY, sourceWidth, sourceHeight, 0, 0, sourceWidth, sourceHeight); - return canvas.toDataURL('image/png'); - } - - function loadDataImage(dataUrl) { - return new Promise((resolve, reject) => { - const image = new Image(); - image.onload = () => resolve(image); - image.onerror = () => reject(new Error('Unable to decode captured visual evidence.')); - image.src = dataUrl; - }); - } function buildElementSnapshot(element) { const computedStyles = window.getComputedStyle(element); @@ -1631,10 +603,8 @@ return; } - const item = { + const item = globalThis.DevFeedbackShared.createElementRecord({ id: buildFeedbackId(), - type: CAPTURE_TYPE_ELEMENT, - captureType: CAPTURE_TYPE_ELEMENT, selector: elementInfo.selector, pageUrl: window.location.href, pageTitle: document.title, @@ -1651,7 +621,7 @@ pageContext: buildPageContext(), note: note.slice(0, MAX_NOTE_LENGTH), timestamp: new Date().toISOString() - }; + }); const nextItems = await runFeedbackMutation('add-feedback-item', { item }); if (!nextItems) { @@ -1666,26 +636,6 @@ } async function startRegionCapture() { - if (visualBusy || contentBusy) { - const reason = 'Wait for the current visual evidence capture to finish.'; - showNotification(reason, 'error'); - return { ok: false, reason }; - } - if (visualSession?.getState().dirty) { - const reason = 'Save or Cancel the visual preview before starting Region capture.'; - showNotification(reason, 'error'); - return { ok: false, reason }; - } - if (contentPreview) { - const reason = 'Save or Cancel the content proposal before starting Region capture.'; - showNotification(reason, 'error'); - return { ok: false, reason }; - } - if (visualSession || String(interactionMode).startsWith('visual-')) { - setInteractionMode(INTERACTION_MODES.OFF, { discardVisual: true }); - } else if (String(interactionMode).startsWith('content-')) { - setInteractionMode(INTERACTION_MODES.OFF, { discardContent: true }); - } const visibility = [feedbackPanel, captureModal, markerLayer].map((element) => element?.style.visibility || ''); [feedbackPanel, captureModal, markerLayer].forEach((element) => { if (element) { @@ -2016,109 +966,7 @@ }); } - function startVisualGesture(event, kind, outline) { - if (!visualSession || !visualTarget?.isConnected || visualBusy || !event.isPrimary) { - return; - } - if (event.pointerType === 'mouse' && event.button !== 0) { - return; - } - const rect = getViewportRect(visualTarget); - if (!(rect.width > 0) || !(rect.height > 0)) { - return; - } - event.preventDefault(); - event.stopPropagation(); - visualGesture = { - kind, - pointerId: event.pointerId, - startX: event.clientX, - startY: event.clientY, - startRect: rect, - preview: outline, - dx: 0, - dy: 0, - width: rect.width, - height: rect.height - }; - outline.classList.add('direct-editing'); - outline.setPointerCapture?.(event.pointerId); - window.addEventListener('pointermove', updateVisualGesture, true); - window.addEventListener('pointerup', finishVisualGesture, true); - window.addEventListener('pointercancel', cancelVisualGesture, true); - } - - function updateVisualGesture(event) { - if (!visualGesture || event.pointerId !== visualGesture.pointerId) { - return; - } - event.preventDefault(); - const dx = Math.round(event.clientX - visualGesture.startX); - const dy = Math.round(event.clientY - visualGesture.startY); - visualGesture.dx = dx; - visualGesture.dy = dy; - if (visualGesture.kind === 'move') { - visualGesture.preview.style.left = `${visualGesture.startRect.x + dx}px`; - visualGesture.preview.style.top = `${visualGesture.startRect.y + dy}px`; - return; - } - visualGesture.width = Math.max(24, Math.round(visualGesture.startRect.width + dx)); - visualGesture.height = Math.max(24, Math.round(visualGesture.startRect.height + dy)); - visualGesture.preview.style.width = `${visualGesture.width}px`; - visualGesture.preview.style.height = `${visualGesture.height}px`; - } - - function finishVisualGesture(event) { - if (!visualGesture || event.pointerId !== visualGesture.pointerId) { - return; - } - event.preventDefault(); - event.stopPropagation(); - const gesture = visualGesture; - clearVisualGestureListeners(); - if (gesture.kind === 'move') { - if (gesture.dx || gesture.dy) { - runVisualCommand(() => visualSession.nudge(gesture.dx, gesture.dy)); - } else { - scheduleDecorationRefresh(); - } - return; - } - if ( - Math.round(gesture.width) !== Math.round(gesture.startRect.width) - || Math.round(gesture.height) !== Math.round(gesture.startRect.height) - ) { - runVisualCommand(() => visualSession.commitStyle('Resize element', { - width: `${gesture.width}px`, - height: `${gesture.height}px`, - 'box-sizing': 'border-box' - }, 'resize')); - } else { - scheduleDecorationRefresh(); - } - } - - function cancelVisualGesture(event) { - if (!visualGesture || (event?.pointerId !== undefined && event.pointerId !== visualGesture.pointerId)) { - return; - } - event?.preventDefault(); - clearVisualGestureListeners(); - scheduleDecorationRefresh(); - } - - function clearVisualGestureListeners() { - visualGesture?.preview?.classList.remove('direct-editing'); - visualGesture = null; - window.removeEventListener('pointermove', updateVisualGesture, true); - window.removeEventListener('pointerup', finishVisualGesture, true); - window.removeEventListener('pointercancel', cancelVisualGesture, true); - } - function applyDecorations() { - if (visualGesture) { - return; - } clearDecorations(); const fragment = document.createDocumentFragment(); @@ -2144,41 +992,6 @@ fragment.appendChild(badge); }); - if (visualTarget && String(interactionMode).startsWith('visual-')) { - const measured = visualTarget.isConnected ? getViewportRect(visualTarget) : null; - const rect = measured?.width > 0 && measured?.height > 0 ? measured : visualLastRect; - if (rect) { - const outline = document.createElement('div'); - outline.className = 'dev-feedback-visual-outline'; - outline.style.left = `${rect.x}px`; - outline.style.top = `${rect.y}px`; - outline.style.width = `${Math.max(1, rect.width)}px`; - outline.style.height = `${Math.max(1, rect.height)}px`; - outline.setAttribute('role', 'group'); - outline.setAttribute('aria-label', 'Selected element. Drag to move; drag the corner handle to resize.'); - - const label = document.createElement('span'); - label.className = 'dev-feedback-visual-outline-label'; - label.textContent = 'Drag to move'; - outline.appendChild(label); - - const handle = document.createElement('button'); - handle.type = 'button'; - handle.className = 'dev-feedback-visual-resize-handle'; - handle.setAttribute('aria-label', 'Drag to resize selected element'); - handle.addEventListener('pointerdown', (event) => startVisualGesture(event, 'resize', outline)); - outline.appendChild(handle); - - outline.addEventListener('pointerdown', (event) => { - if (event.target === handle) { - return; - } - startVisualGesture(event, 'move', outline); - }); - fragment.appendChild(outline); - } - } - markerLayer.replaceChildren(fragment); } @@ -2362,16 +1175,6 @@ return; } - if (request.action === 'start-visual-edit') { - sendResponse(startVisualEditMode()); - return; - } - - if (request.action === 'start-add-content') { - sendResponse(startAddContentMode()); - return; - } - if (request.action === 'refresh-feedback') { loadFeedbackItems(); sendResponse({ feedbackMode, itemCount: feedbackItems.length }); diff --git a/docs/add-content-mode-plan.html b/docs/add-content-mode-plan.html deleted file mode 100644 index 13639a1..0000000 --- a/docs/add-content-mode-plan.html +++ /dev/null @@ -1,195 +0,0 @@ - - - - - - Add Content Mode - Product and UX Contract - - - -
-
-
Add Content Mode
-

Propose what the page needs, not only what should change.

-

Choose an existing element as the implementation anchor, preview a safe content block, and save an explicit insert request with placement, filler, purpose, acceptance criteria, and before/proposed evidence.

-
- -
-
- -
-
Workflow software
-

Turn browser feedback into a buildable change spec.

-

Capture context, describe intent, and hand a developer the evidence needed to act.

- Start a capture -
-
- Existing anchor: feature summary - This element remains untouched after the proposal is saved. -
-
-
- - -
- -
-

Saved requirement

insert mutation with DOM anchor, placement, typed content, support intent, and acceptance checks.

-

Reversible preview

The temporary block appears only for evidence capture and is removed after Save, Cancel, navigation, or mode exit.

-

Safe frame

HTML/embed content is represented as a placeholder. User markup, scripts, and remote frames are never executed.

-
-
- - - diff --git a/docs/agents/domain.md b/docs/agents/domain.md new file mode 100644 index 0000000..93c2507 --- /dev/null +++ b/docs/agents/domain.md @@ -0,0 +1,7 @@ +# Domain docs + +This is a single-context repository. + +Before domain-sensitive work, read `CONTEXT.md` and relevant ADRs under +`docs/adr/` when they exist. Create them lazily only when terminology or a +load-bearing architectural decision is actually resolved. diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md new file mode 100644 index 0000000..81de295 --- /dev/null +++ b/docs/agents/issue-tracker.md @@ -0,0 +1,9 @@ +# Issue tracker: GitHub + +Issues and specs live in StoneHub/webDevFeedbackExt GitHub Issues. +Use `gh` for creating, reading, commenting, labeling, and closing issues. + +Pull requests are not the default triage request surface. + +When a skill says to publish a ticket, create a GitHub issue. +When it requests the relevant ticket, read the issue and its comments. diff --git a/docs/agents/triage-labels.md b/docs/agents/triage-labels.md new file mode 100644 index 0000000..e63ea0e --- /dev/null +++ b/docs/agents/triage-labels.md @@ -0,0 +1,9 @@ +# Triage labels + +| Canonical role | Repository label | +| --- | --- | +| needs-triage | needs-triage | +| needs-info | needs-info | +| ready-for-agent | ready-for-agent | +| ready-for-human | ready-for-human | +| wontfix | wontfix | diff --git a/docs/chrome-web-store-submission-draft.md b/docs/chrome-web-store-submission-draft.md index 8bf2234..dee5c4c 100644 --- a/docs/chrome-web-store-submission-draft.md +++ b/docs/chrome-web-store-submission-draft.md @@ -1,5 +1,7 @@ # Chrome Web Store Submission Record +Scope note: this file preserves the historical v1.7.1/v1.7.2 Store submission evidence. The active product is now the browser capture core: Element, Region/PDF, History, and one explicit Agent Handoff. Historical Visual and Add Content copy below must not be reused as current-product behavior or release proof. + Status: v1.7.0 is public in the Chrome Web Store. The v1.7.1 review was cancelled and replaced by the exact v1.7.2 Add Content package on August 3, 2026. Google accepted v1.7.2 for normal review with no warnings; it is pending review at a 100% target and will publish automatically after approval. Google distributes the Browser Code icon inside the public v1.7 CRX, but the Store listing still rendered the retired purple-flag asset before these submissions. The submitted v1.7.2 package adds the user-triggered Add Content workflow plus the v1.7.1 discovery metadata and refreshed icon encoding without adding permissions, remote code, or network services. It excludes the separate v1.8 Feedback Session work. Store acceptance and automated checks do not prove the deferred browser runtime gates in `docs/manual-release-checklist.md`. @@ -12,9 +14,9 @@ The submitted v1.7.2 package adds the user-triggered Add Content workflow plus t - Public product page: `https://monroes.tech/software/dev-feedback-capture/` - Privacy policy: `https://monroes.tech/software/dev-feedback-capture/privacy/` - Support email: `monroe@flyingchangesfarm.net` -- Single purpose: Capture structured, local visual change specifications from the current browser-visible page or PDF and export them for implementation. +- Historical single purpose: Capture structured, local visual change specifications from the current browser-visible page or PDF and export them for implementation. -## Discovery metadata for v1.7.2 +## Historical discovery metadata for v1.7.2 Manifest title, 44 of 45 characters: @@ -50,7 +52,7 @@ Store overview: > > Dev Feedback Capture produces portable handoff files and copyable prompts. It does not claim an official integration with Codex, Claude Code, Cursor, or their publishers. -## Submitted privacy practices copy +## Historical submitted privacy practices copy These values were entered in the Chrome Web Store Privacy practices tab and are retained here for reviewer follow-up and future releases. @@ -92,7 +94,7 @@ Use this privacy-policy URL: The publisher reviewed and checked the three Developer Program Policies data-use certifications before submitting the item on July 19, 2026. -## Store screenshot plan +## Historical Store screenshot plan Capture the real v1.7 extension operating on its own public product page. Produce full-bleed `1280x800` PNG files with square corners and no padding. @@ -120,7 +122,7 @@ Before capture: - Confirm saved and exported redact evidence cannot reveal original pixels. - Confirm every screenshot reflects v1.7 behavior, including the Browser Code icon and edge-anchored compact list. -## Small promotional tile +## Historical small promotional tile Create after the final screenshots establish the visual direction. @@ -129,7 +131,7 @@ Create after the final screenshots establish the visual direction. - Add only the product name and the short line `Visual feedback, ready to build.` - Do not use a raw screenshot, Store badge, ranking claim, or excessive text. -## Feature video plan +## Historical feature video plan Target: 75-90 seconds, `1920x1080`, recorded in a clean Edge window on the public Dev Feedback Capture product page. diff --git a/docs/manual-release-checklist.md b/docs/manual-release-checklist.md index 93ddbd4..c7bde26 100644 --- a/docs/manual-release-checklist.md +++ b/docs/manual-release-checklist.md @@ -8,20 +8,15 @@ Submission record: Monroe explicitly waived the new unpacked-browser smoke and r v1.7.2 submission record: The v1.7.1 review was cancelled, and the exact clean-main v1.7.2 ZIP with SHA-256 `4c40215036eef05f5459f408d8e11ed74327db39d16892a78a14345e95f65bef` was uploaded and submitted for normal review on August 3, 2026. API readback showed v1.7.2 `PENDING_REVIEW` at a 100% target with no warning or takedown flag. v1.7.0 remains public until Google approves and automatically publishes v1.7.2. -## v1.7.2 Add Content package and submission +## Historical v1.7.2 Store submission -Before submitting v1.7.2: +The v1.7.2 submission records above are preserved evidence for an earlier package. Its Add Content workflow is not part of the active product scope. Do not use that package, its screenshots, or its Store copy as proof of the browser capture core described below. -- Confirm the package adds only the reviewed Add Content feature plus the v1.7.1 discovery refresh; do not include v1.8 Feedback Session files or new permissions. -- Confirm Text, Image placeholder, List, and HTML/embed frame definitions are rendered with DOM text nodes and that the frame remains a non-executable placeholder. -- Confirm saved content requests normalize to a structured `insert` mutation and remain compatible with existing History and AI Bundle exports. -- Run `npm test`, `npm run check`, `npm run package`, `npm run verify:package`, JavaScript syntax checks, and `git diff --check` from the isolated release worktree. -- Because Monroe waived the new unpacked-browser smoke and replacement screenshots, record those as open proof gaps rather than claiming runtime verification. -- Read the live Store API state, cancel the active v1.7.1 submission, upload the exact verified v1.7.2 ZIP, submit with review enabled and warnings blocking, then read the status back. +## Historical v1.7.1 discovery package and listing check -## v1.7.1 discovery package and listing check +These checks describe an earlier Store package and remain here only as release evidence. They are not current-product acceptance criteria. -Before tagging or uploading v1.7.1: +For that historical package: - Confirm the package contains only v1.7 behavior plus the manifest title, short description, version, and refreshed Browser Code icon assets. Do not include v1.8 Feedback Session files or permissions. - Current Chrome documentation takes the required 128x128 extension icon from the uploaded ZIP; the Store listing tab separately owns the long description, screenshots, promotional tile, and video. Do not invent a separate icon-upload step unless the live dashboard exposes one for this item. @@ -33,20 +28,22 @@ Before tagging or uploading v1.7.1: - 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. -## v1.7 compact-panel and icon check +## Active browser capture core check -Before tagging or uploading v1.7: +Before tagging or publishing the active browser capture core: - Reload the unpacked extension from this exact repository checkout. -- Start Element and Visual modes and confirm the change list opens expanded with the paper-and-indigo palette. -- In Element and Visual modes, drag the collapsed change list near every viewport edge and confirm it stays anchored to the nearest edge. +- 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 collapsed list remains visible on its selected edge. -- Confirm **⌃** expands the list, **⌄** collapses it, and both controls have matching accessible labels. +- Confirm History renders both records after the source tab is closed. +- Confirm **Send to Codex** places one explicit handoff file in the configured local 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. -## Deferred v1.4 PDF/export check +## Active PDF/export check -Before tagging or publishing v1.4 or later, load the exact repository path as an unpacked extension in Edge or Chromium and verify: +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. @@ -54,24 +51,18 @@ Before tagging or publishing v1.4 or later, load the exact repository path as an - 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 was intentionally deferred from the v1.4 source merge. It is expected to be routine, but it remains required before the submitted release is described as runtime-verified. +This gate remains required before the active release is described as runtime-verified. -## v1.7 direct Visual Edit check +## Active Agent Handoff check -Before tagging, publishing, or uploading v1.7, select one normal-page element and drag its outline to move it with a mouse or pointer. Resize it with the corner handle and, when touch-capable hardware is available, repeat both gestures with a finger. Confirm Undo, Redo, and Reset work after direct gestures. Confirm Cancel, Save, navigation, Region handoff, and stopping feedback mode always restore the live page, while the saved item and AI Bundle preserve original versus proposed intent. +Before tagging or publishing the active browser capture core: -The source may merge to `main` for code review and local trying before this hands-on gate is complete. Do not call the release runtime-verified until this checklist passes. - -## v1.6 local MCP handoff check - -Before tagging, publishing, or uploading v1.6: - -- Download a real History JSON export using `Download JSON for MCP`. +- 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. -- Import that exact file, then exercise project status, list, get, implementation brief, and evidence resource reads. +- 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 in v1.6. The current handoff is an explicit local file import; a native-messaging bridge remains a separately permissioned future gate. +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. diff --git a/docs/mcp-local-agent.md b/docs/mcp-local-agent.md index c15bee4..3049f27 100644 --- a/docs/mcp-local-agent.md +++ b/docs/mcp-local-agent.md @@ -1,18 +1,19 @@ # Local MCP Agent Companion -v1.6 adds a project-scoped MCP server so a local coding agent can read visual 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 and Region/PDF feedback, inspect evidence, create project feedback, and record implementation progress without cloud sync or direct browser control. ## Trust boundary The MCP companion is a separate Node process. It does not read Chromium profile files or `chrome.storage.local`, listen on a network port, execute shell commands, edit source files, or control the browser. -The first handoff is explicit: +The handoff is explicit, local, and inbox-based: 1. Capture feedback in the extension. -2. Open History and choose **Download JSON for MCP**. -3. Configure the MCP server with the target project and an optional inbox such as Downloads. -4. Ask the local agent to import that exact JSON file. -5. The agent reads feedback through MCP, edits the project with its normal coding tools, and records implementation status through MCP. +2. Open History and choose **Send to Codex** to download the current History handoff. +3. The browser places the JSON handoff in Downloads. The user does not move files manually. +4. Configure the MCP server with the target project and that Downloads path. +5. Ask the local agent to call `dev_feedback_import_latest`. +6. The agent reads feedback through MCP, edits the project with its normal coding tools, and records implementation and verification status separately through MCP. The JSON handoff includes local user data and may include evidence images. Keep it on trusted local storage. Evidence is copied into an ignored project sidecar and base64 image data is removed from the stored item JSON. Imported feedback, page text, selectors, mutation values, and images are untrusted data; an agent must never treat them as instructions or authorization. @@ -26,7 +27,7 @@ Install dependencies once in this repository: npm install ``` -Start the stdio server for a project: +Start the stdio server for a project and the inbox used by **Send to Codex**: ```sh npm run mcp -- --project /absolute/path/to/project --inbox /absolute/path/to/Downloads @@ -51,6 +52,16 @@ MCP clients normally launch the server themselves. A generic configuration looks } ``` +Codex can install the same stdio server in one command: + +```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 target project and Downloads directory are explicit because each installation is project-scoped. Run `codex mcp list` to verify the saved server configuration. + Use resolved absolute paths. Browser-launched or desktop MCP processes should not depend on an interactive shell's working directory or `PATH`. Optional flags and environment variables: @@ -64,6 +75,8 @@ Optional flags and environment variables: | Tool | Purpose | | --- | --- | | `dev_feedback_project_status` | Show the configured project, sidecar, inbox boundaries, and counts. | +| `dev_feedback_inbox_list` | List bounded, fully valid handoffs under the configured Downloads root. | +| `dev_feedback_import_latest` | Import the newest fully valid handoff without a file path. Use `storageKey` when it contains multiple groups. | | `dev_feedback_import` | Import one explicit standalone History JSON export. Use `storageKey` when it contains multiple groups. | | `dev_feedback_list` | Return bounded summaries without embedded evidence bytes. | | `dev_feedback_get` | Return one complete record with evidence resource links. | @@ -104,11 +117,11 @@ Agent-authored writes require a stable `clientRequestId`, so retries are idempot ## Current limits -- The MCP companion does not automatically see unsent extension history. -- ZIP and extracted AI Bundle import are not part of the first checkpoint. Use the standalone **Download JSON for MCP** History export. Sibling image paths are intentionally never followed. -- The MCP server does not apply saved visual mutations, navigate pages, click UI, or edit project files. +- The MCP companion does not automatically see unsent extension history; **Send to Codex** still requires explicit user action. +- ZIP and extracted AI Bundle import are not part of the active checkpoint. **Send to Codex** uses the standalone JSON handoff. Sibling image paths are intentionally never followed. +- The MCP server does not apply saved historical mutations, navigate pages, click UI, or edit project files. - There is no native messaging installer, localhost HTTP/WebSocket bridge, cloud account, sync, auth service, telemetry, or payment path. -- A future user-triggered native-messaging bridge could remove the manual file handoff, but it would require an optional browser permission, exact extension-ID allowlisting, and a separate installed native host. +- A future user-triggered native-messaging bridge could remove the inbox handoff, but it would require an optional browser permission, exact extension-ID allowlisting, and a separately installed native host. It is not part of the active Agent Handoff. ## Security references diff --git a/docs/software-catalog-release-plan.md b/docs/software-catalog-release-plan.md index addd041..93ac7fc 100644 --- a/docs/software-catalog-release-plan.md +++ b/docs/software-catalog-release-plan.md @@ -4,18 +4,18 @@ Make Dev Feedback Capture a small, credible software offering in the `monroes.tech/software` catalog. -The product story is: a Chromium extension for collecting structured feedback from pages and PDFs, with local-first storage and exports that help developers or agents act on the feedback. +The product story is: a Chromium extension for collecting structured feedback from pages and PDFs, with local-first History and one explicit Agent Handoff that helps a developer or coding agent act on the feedback. ## Current State - `product.json` exists for catalog ingestion. -- `CHANGELOG.md` has the published `1.2.0` entry and an unreleased `1.3.0` section. +- `CHANGELOG.md` preserves historical release entries and submission notes. - `LICENSE` marks the project as source-visible with all rights reserved. -- `npm run package` creates the next versioned release zip at `dist/dev-feedback-capture-v1.3.0.zip` from the current source checkout. +- `npm run package` creates the versioned release zip from the current source checkout. - `.github/workflows/release.yml` publishes a zip asset when a matching `v*` tag is pushed. - GitHub Release `v1.2.0` is published with `dev-feedback-capture-v1.2.0.zip`. -`product.json.downloadUrl` currently points to the published `v1.2.0` asset. Do not update it to v1.3.0 until the manual browser gate passes and that asset is actually published. The latest-release API remains the preferred source for consumers that can resolve the newest matching asset automatically. +`product.json.downloadUrl` remains a fallback to a known published asset. Do not update it until the active browser capture core passes the manual browser gate and the matching asset is actually published. The latest-release API remains the preferred source for consumers that can resolve the newest matching asset automatically. ## Catalog Metadata @@ -43,17 +43,17 @@ Counterpoint: a checked-in static `downloadUrl` is simpler, but it will drift ev ``` 3. Manually load the unpacked extension in Chrome or Edge. -4. Test Element mode on an HTTP page. -5. Test Region mode on a normal page or rendered PDF. -6. Open the extension-owned History page and confirm JSON and self-contained HTML downloads plus Markdown and AI Prompt copy actions work for both Element and Region captures. -7. Create and push a matching tag: +4. Test Element capture on an HTTP page. +5. Test Region capture on a normal page and a rendered PDF. +6. Open History and confirm saved Element and Region/PDF records, local exports, and `Send to Codex` MCP import. +7. Create and push a matching tag only after the active release gates are approved: ```bash - git tag v1.3.0 - git push origin v1.3.0 + git tag v + git push origin v ``` -8. Verify GitHub Releases contains `dev-feedback-capture-v1.3.0.zip`. +8. Verify GitHub Releases contains `dev-feedback-capture-v.zip`. 9. Update `product.json.downloadUrl` only if the site requires a static URL. Prefer automatic latest-release resolution. ## Public Copy Rules diff --git a/docs/store-monetization-readiness.html b/docs/store-monetization-readiness.html index 3244248..e20d63d 100644 --- a/docs/store-monetization-readiness.html +++ b/docs/store-monetization-readiness.html @@ -60,8 +60,8 @@
Recommended release path
-

Ship v1.6 free and local-first

-

Keep payments, accounts, licensing, network sync, and new browser permissions out of v1.6. The first Store submission was sent for review on July 19, 2026; design a paid product as a later web-service lane.

+

Keep the browser capture core free and local-first

+

The active product is Element capture, Region/PDF capture, History, and one explicit Agent Handoff. Keep payments, accounts, licensing, network sync, and new browser permissions out of the browser extension; design a paid product as a later web-service lane.

@@ -73,7 +73,7 @@

Publisher identity

Publisher quotaA registered developer receives a publisher and may create one additional publisher. Publisher creation has a one-activation-per-account-lifetime quota, so do not create or delete one casually. RolesViewer, Item Manager, Editor, and Admin. -

Current status: durable owner confirmed, publisher created, contact verified, non-trader selected, and the first item submitted. Inviting the backup Admin remains optional.

+

Recorded status: durable owner confirmed, publisher created, contact verified, non-trader selected, and the first item submitted. Inviting the backup Admin remains optional. This is Store-account evidence, not proof of current runtime behavior.

@@ -82,7 +82,7 @@

Account and submission gates

  • Accept the Chrome Web Store Developer Agreement and pay the one-time registration fee shown in the dashboard. Official documentation does not publish a fixed amount, so this artifact does not hardcode one.
  • Declare trader or non-trader status. Trader verification may expose verified business and contact details publicly.
  • Provide an accurate description, extension icons, listing screenshots, category/language details, support and contact information, privacy-policy URL, and complete data-use disclosures.
  • -
  • Disclose screenshots, page content, and browsing activity as user data even though v1.6 processes and stores them only on the device.
  • +
  • Disclose screenshots, page content, and browsing activity as user data even though the browser capture core processes and stores them only on the device.
  • Keep the manifest at the current minimal permissions: storage, activeTab, and scripting; no host permissions.
  • External action gate: do not replace the active submission, change publication timing, publish a paid feature, or change account ownership without Monroe's approval.

    @@ -95,14 +95,14 @@

    Listing and asset checklist

    Creative assets
    • Final 128 px store icon and packaged 16/48/128 px icons
    • -
    • One valid 1280x800 screenshot is in the initial submission; replace it after review with a fresh v1.6 set covering Element, Region annotations/redact, History, and export
    • +
    • One valid 1280x800 screenshot is in the initial submission; replace it after review with a current set covering Element, Region/PDF annotations and redaction, History, and Agent Handoff
    • Optional promotional images only after the core listing is ready
    Copy and support
      -
    • Concise single-purpose description: compiling visual change specs from browser-visible surfaces
    • +
    • Concise single-purpose description: capturing browser-visible feedback and compiling a local handoff
    • Install/use instructions, version notes, support email or support page
    • Privacy policy that explains local capture, storage, deletion, exports, and absence of network transmission
    @@ -110,7 +110,7 @@

    Listing and asset checklist

    Review proof
      -
    • Unpacked Edge/Chromium runtime proof for Element, Region, PDF, History, and every export
    • +
    • Unpacked Edge/Chromium runtime proof for Element, Region/PDF, History, and Agent Handoff
    • Evidence that redact permanently removes source pixels from both “before” and annotated exports
    • Old-history compatibility and package verification
    @@ -119,10 +119,10 @@

    Listing and asset checklist

    -

    Future paid-product lane - separate from v1.6

    +

    Future paid-product lane - separate from the browser capture core

    Chrome Web Store Payments is deprecated. Any paid edition needs an external processor plus a separate account/service boundary for authentication, entitlements, licensing records, refunds, support, tax handling, and failure recovery.

    Recommended later shape: a small web account service using Stripe Checkout and Customer Portal, with the extension contacting it only after a separately reviewed permission, privacy, security, and offline-behavior design. Declare any external paid features in the store listing. A physical address is required for items offering purchases, subscriptions, or paid additional features.

    -

    Do not add a payment SDK, auth, network sync, or new permission to the submitted v1.6 extension.

    +

    Do not add a payment SDK, auth, network sync, or new permission to the browser capture core without a separately reviewed product, privacy, security, and billing boundary.

    @@ -135,7 +135,7 @@

    Official references

  • User-data policy FAQ
  • Trader verification FAQ
  • -

    Prepared on July 18, 2026 and updated after the first v1.6 Store submission on July 19, 2026. This records readiness and submission state; it is not proof of approval, publication, or deferred runtime verification.

    +

    Prepared on July 18, 2026 and updated after the first Store submission on July 19, 2026. This records readiness and submission state; it is not proof of approval, publication, or runtime verification.

    diff --git a/docs/v1.3-history-reliability-plan.html b/docs/v1.3-history-reliability-plan.html deleted file mode 100644 index fd36643..0000000 --- a/docs/v1.3-history-reliability-plan.html +++ /dev/null @@ -1,44 +0,0 @@ - - - - - - Dev Feedback Capture v1.3 Plan - - - -

    v1.3 — History & Reliability

    -

    Make Region/PDF feedback as retrievable and portable as it is easy to capture, while closing screenshot-retention and same-site concurrency gaps.

    - -

    Delivery slices

    -
    -
    1. Safe sessions

    Keep full viewport screenshots in transient session storage, remove them when the editor closes, and sweep expired sessions.

    -
    2. Serialized history

    Route add, delete, and clear mutations through the service worker with a per-storage-key queue.

    -
    3. History page

    Add an extension-owned, accessible view for every saved site or file history, including PDFs and protected pages.

    -
    4. Portable exports

    Download JSON and a self-contained HTML report with embedded crops; keep Markdown and AI Prompt boundaries explicit.

    -
    5. Release confidence

    Run lightweight PR CI, syntax-check shipped scripts, inspect the ZIP layout, and publish a SHA-256 checksum.

    -
    6. Public proof

    Update release metadata now; capture real Element and Region screenshots during the manual browser gate.

    -
    - -

    Storage flow

    -

    popup/content/capture/history → background mutation queue → chrome.storage.local

    -

    visible-tab screenshot → chrome.storage.session → crop saved to local history → transient session removed

    - -

    Verification gate

    -
    -

    Automated: npm test, npm run check, npm run package, npm run verify:package, and git diff --check.

    -

    Manual: load unpacked in Edge/Chrome; save one Element note, save one Region/PDF crop, open History, filter/delete, and verify JSON plus embedded-image HTML exports.

    -
    - - diff --git a/docs/v1.6-local-mcp-plan.html b/docs/v1.6-local-mcp-plan.html deleted file mode 100644 index 70f05ea..0000000 --- a/docs/v1.6-local-mcp-plan.html +++ /dev/null @@ -1,41 +0,0 @@ - - - - - - Dev Feedback Capture v1.6 — Local MCP Plan - - - -
    -

    Dev Feedback Capture · v1.6

    -

    Local feedback becomes an agent-readable project contract.

    -

    The extension remains the human visual capture surface. A separate stdio MCP companion materializes explicit exports into a project-scoped sidecar that a local coding agent can read and update.

    - -
    -
    1 · CaptureElement, Visual, or Region feedback stays in extension storage.
    -
    2 · HandoffThe user downloads one JSON export for the target project.
    -
    3 · ImportMCP validates the explicit path and extracts safe evidence.
    -
    4 · BuildThe connected agent reads specs and edits with its normal project tools.
    -
    5 · RecordMCP stores revision-checked implementation and verification status.
    -
    - -
    -
    Checkpoint implemented

    Project-scoped stdio MCP

    • Eight feedback-specific tools
    • Item and evidence resources
    • Read-only mode
    • No listening port
    -
    Checkpoint implemented

    Safe local sidecar

    • Atomic item writes
    • Append-only event trail
    • Optimistic revisions
    • All content ignored by default
    -
    Checkpoint implemented

    Explicit JSON bridge

    • Existing history schema accepted
    • Legacy captures normalized
    • Embedded base64 removed from records
    • Import roots are explicit
    -
    Later gate

    One-click browser bridge

    Only after the file workflow proves useful: user-triggered native messaging with an optional permission, exact extension-ID allowlisting, and a separately installed host.

    -
    - -
    -

    Hard boundary

    The MCP companion does not execute shell commands, edit source, control the browser, read Chrome profile databases, or claim unobserved visual evidence.

    -

    Release gate

    Before tagging v1.6: load the unpacked extension, export real feedback, import it through an actual local MCP client, read evidence, implement a small project change, and verify the status round trip.

    -

    Deliberately excluded

    Cloud sync, remote MCP, HTTP/WebSocket daemons, payments, telemetry, automatic source edits, autonomous acceptance claims, and new required extension permissions.

    -
    -
    - - diff --git a/docs/v1.7-product-plan.html b/docs/v1.7-product-plan.html deleted file mode 100644 index dec31b0..0000000 --- a/docs/v1.7-product-plan.html +++ /dev/null @@ -1,461 +0,0 @@ - - - - - - Dev Feedback Capture v1.7 Product Plan - - - -
    -
    -
    Dev Feedback Capture · July 25, 2026
    -

    Make v1.7 obvious before making it paid

    -

    The strongest next release is a trust-and-activation pass: finish the Browser Code identity, make the first successful capture effortless, prove every existing workflow, and tell that story clearly in the Store.

    -
    - Local-first - Manifest V3 - No accounts - No telemetry - Explicit export -
    -
    - -
    -

    Executive Summary

    -
    -
    - Ship v1.7 free. -

    There is not enough demand evidence yet to justify accounts, billing, licensing, and the support burden that follows.

    -
    -
    - Sell the workflow later. -

    The paid value is not “take a screenshot.” It is a reliable project inbox, agent handoff, status loop, and team collaboration.

    -
    -
    - Win on trust now. -

    Local storage, minimal permissions, and explicit exports are a real differentiator—especially under Chrome’s stricter 2026 data-disclosure rules.

    -
    -
    -
    - -
    -
    Decision
    -

    Best path: one tight free release, then a paid validation offer

    -

    v1.7 should improve comprehension and close proof gaps, not add a cloud backend. The Browser Code icon and edge-anchored compact panel are already implemented locally. Finish the current manual gates, add a short first-run path, tighten the handoff, and refresh the Store listing.

    -

    Counterpoint: delaying payment delays revenue. The larger risk is charging before the extension has repeat users or a clear paid boundary; that creates billing complexity without proving anyone wants the product.

    -
    - -
    -

    v1.7 priority score

    - -

    Product-judgment score, not measured usage data. It combines user value, confidence, effort, privacy risk, and fit with the current local-first promise.

    -
    - -
    -

    What goes into the next push

    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    WhenChangeWhy it earns the slot
    NowBrowser Code icon plus the edge-anchored compact list and ⌃ / ⌄ controls.The product reads as a web-development tool and the smallest persistent UI is easier to understand.
    NowComplete the three deferred runtime gates: PDF/export/redaction, Visual Edit restoration, and a real MCP handoff.Existing capability is more valuable than another unverified feature. These are release gates, not polish.
    NowFirst-run walkthrough: choose Element, Visual, or Region; capture one sample; open History; export.The first win should take under a minute without reading the README.
    NowOne named handoff action: “Download for agent,” followed by the exact local import instruction.Users should not need to understand AI Bundle vs JSON for MCP before they get value.
    NowTruth cleanup: published version, changelog status, README download language, product metadata, support URL, and privacy text.The repository currently describes v1.6 as in development even though v1.6 is public.
    NextAcceptance-check verification with pass/fail and a short implementation note.This closes the feedback loop and creates a more defensible paid workflow later.
    LaterFull-page capture, hosted sharing, accounts, integrations, and telemetry.Each expands scope, privacy disclosures, failure modes, and support before demand is proven.
    -
    - -
    -

    Store listing draft

    -
    -
    Name
    -

    Dev Feedback Capture

    -
    -
    -
    Short description
    -

    Capture element, region, and visual-edit feedback as implementation-ready specs—saved locally and exported only when you choose.

    -
    -
    -
    Opening description
    -
    -

    Turn what you see in the browser into a change spec a developer or coding agent can act on.

    -

    Capture a DOM element, annotate a visible region or PDF, or preview a visual edit. Dev Feedback Capture keeps the selector, screenshot evidence, requested change, page context, and acceptance checks together.

    -

    Your captures stay in local extension storage. Nothing is uploaded, synced, or sent to an AI service unless you explicitly export it and choose where to send it.

    -
    -
    -
    -
    Key bullets
    -
    -

    • Element mode — capture selectors, text, styles, and notes
    - • Visual mode — directly drag and resize one element with reversible previews
    - • Region mode — annotate pages and PDFs with crops, pins, arrows, text, and redaction
    - • History — review, edit, and export saved feedback
    - • Agent-ready exports — JSON, HTML, Markdown, and an evidence bundle
    - • Local-first — minimal permissions, no always-on page access, no telemetry, no cloud sync

    -
    -
    -
    - -
    -

    Four screenshots should tell the whole story

    -
    -
    -
    01
    -

    Pick the exact element

    -

    Show Element mode with the edge-anchored compact list and one clearly highlighted target.

    -
    -
    -
    02
    -

    Mark the visual change

    -

    Show Region mode with crop, arrow, numbered pin, requested change, and one acceptance check.

    -
    -
    -
    03
    -

    Preview without changing source

    -

    Show Visual mode while dragging or resizing one selected element, with original versus proposed state and the restoration promise.

    -
    -
    -
    04
    -

    Hand it off with context

    -

    Show History and the one-click agent export, with evidence and structured requirements visible.

    -
    -
    -

    Do not lead with MCP terminology. Lead with the job: “capture a change, keep its context, hand it off.”

    -
    - -
    -

    How this can make money

    -
    - -
    -
    After team demand
    -

    Hosted Team

    -
    $12–20 / creator / month
    -
      -
    • Shared projects and guest links
    • -
    • Assignments, comments, and audit history
    • -
    • GitHub, Linear, Jira, or Slack handoff
    • -
    • Admin and retention controls
    • -
    -

    Risk: backend security, privacy, billing, support, and integration maintenance become the product.

    -
    -
    -
    Do not do
    -

    Paid basic capture

    -
    Not yet
    -
      -
    • No paywall on Element or Region capture
    • -
    • No ads in the page overlay
    • -
    • No sale of browsing or page data
    • -
    • No cloud account merely to export
    • -
    -

    Why: it weakens acquisition and trust before the product has demonstrated repeat use.

    -
    -
    -

    Comparable products charge for collaboration and workflow depth: Jam lists a free tier and a $14-per-creator Team tier; BugHerd’s Standard plan is $50/month for five members; Marker.io starts at $39/month. That is the signal: capture earns attention, workflow earns money.

    -
    - -
    -

    Validation gate before building billing

    -
      -
    1. Recruit five real users—freelance developers, agency owners, QA, or PMs—and watch each complete one capture and export.
    2. -
    3. Get ten people to use it on two separate days within a two-week window. Measure manually or through Store analytics; do not add extension telemetry for this test.
    4. -
    5. Ask for the sale: offer a $49 founding Local Pro license with the promised workflow above. Build it only after at least three people commit to paying.
    6. -
    7. Use one success definition: a user captures a change, hands it to a developer/agent, and records whether the acceptance checks passed.
    8. -
    -
    Guardrails: permissions remain storage, activeTab, and scripting; no page content leaves the device without an explicit export; no evidence is lost; and every Store privacy disclosure matches actual behavior.
    -
    - -
    -

    Further questions

    -
      -
    • Who is the first paying customer: solo developer, agency, QA team, or product manager?
    • -
    • Is the highest-value loop “feedback to agent,” “client to agency,” or “QA to developer”?
    • -
    • Will Local Pro be a separate desktop companion, a supported MCP package, or both?
    • -
    • Which single integration would three paying users choose first?
    • -
    -
    - -
    -

    Caveats and assumptions

    -

    This plan uses the current source tree, the public v1.6 listing, official Chrome policy, and current competitor pricing. The priority scores and proposed prices are decision aids—not observed conversion data. Before setting a final price, check the Developer Dashboard’s actual listing impressions, installs, and retention signals.

    -

    Chrome’s updated user-data policy takes effect August 1, 2026. Any future telemetry, accounts, cloud sync, or paid service must be both necessary to the disclosed purpose and prominently disclosed. Chrome’s developer agreement also states that Google is not responsible for processing product payments, authentication of paid downloads, payment records, or taxes.

    -
    - -
    -

    Sources

    - -
    -
    - - diff --git a/history.html b/history.html index eaf1131..15f1532 100644 --- a/history.html +++ b/history.html @@ -12,7 +12,7 @@

    Dev Feedback Capture

    History & Export

    -

    Review local visual change specs and export one implementation-ready bundle with evidence, DOM anchors, and acceptance checks.

    +

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

    0items
    @@ -27,7 +27,7 @@

    History & Export

    - + diff --git a/history.js b/history.js index eeb5db0..0d75356 100644 --- a/history.js +++ b/history.js @@ -260,8 +260,9 @@ } function downloadJson() { - downloadFile('dev-feedback-history.json', JSON.stringify(buildExportPayload(), null, 2), 'application/json'); - setStatus('JSON export downloaded. A local MCP companion can import this explicit handoff.'); + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + downloadFile(`dev-feedback-codex-inbox-${timestamp}.json`, JSON.stringify(buildExportPayload(), null, 2), 'application/json'); + setStatus('Codex handoff downloaded to your browser Downloads folder. The local MCP companion can import the newest valid capture.'); } async function downloadAiBundle() { diff --git a/manifest.json b/manifest.json index e233603..e1037f8 100644 --- a/manifest.json +++ b/manifest.json @@ -1,8 +1,8 @@ { "manifest_version": 3, "name": "Dev Feedback Capture: AI UI Review & Prompts", - "version": "1.7.2", - "description": "Pick elements, propose new page content, and annotate regions. Export AI-ready prompts and visual change specs for developers.", + "version": "1.8.0", + "description": "Pick elements and annotate regions. Export AI-ready prompts and region evidence for developers.", "permissions": [ "storage", "activeTab", diff --git a/mcp/server.mjs b/mcp/server.mjs index 31885fe..dda32d8 100644 --- a/mcp/server.mjs +++ b/mcp/server.mjs @@ -47,6 +47,24 @@ export async function createDevFeedbackServer(options = {}) { } }, wrapMutation(readOnly, async ({ path, storageKey }) => store.importFeedbackExport({ path, storageKey }))); + server.registerTool('dev_feedback_inbox_list', { + title: 'List Dev Feedback Inbox Captures', + description: 'List valid standalone History JSON exports in the configured inbox, newest first. Invalid and non-capture JSON files are omitted.', + annotations: READ_ANNOTATIONS, + inputSchema: { + limit: z.number().int().min(1).max(constants.MAX_INBOX_CAPTURES).optional() + } + }, wrapTool(async ({ limit }) => store.listInboxCaptures({ limit }))); + + server.registerTool('dev_feedback_import_latest', { + title: 'Import Latest Dev Feedback Capture', + description: 'Import the newest valid standalone History JSON export from the configured inbox without requiring a file path. The import is stored in the project sidecar like an explicit-path import.', + annotations: IDEMPOTENT_WRITE_ANNOTATIONS, + inputSchema: { + storageKey: z.string().max(2000).optional().describe('Required when the newest export contains more than one site/file history group.') + } + }, wrapMutation(readOnly, async ({ storageKey }) => store.importLatestInboxCapture({ storageKey }))); + server.registerTool('dev_feedback_list', { title: 'List Dev Feedback', description: 'List bounded feedback summaries without embedding image bytes or base64 evidence.', diff --git a/mcp/store.mjs b/mcp/store.mjs index 2343023..3710178 100644 --- a/mcp/store.mjs +++ b/mcp/store.mjs @@ -1,5 +1,7 @@ import crypto from 'node:crypto'; +import { constants as fsConstants } from 'node:fs'; import fs from 'node:fs/promises'; +import os from 'node:os'; import path from 'node:path'; import shared from '../shared.js'; @@ -12,6 +14,10 @@ const MAX_EVIDENCE_BYTES = 25 * 1024 * 1024; const MAX_IMPORT_EVIDENCE_BYTES = 100 * 1024 * 1024; const MAX_IMPORT_HISTORIES = 200; const MAX_IMPORT_ITEMS = 2000; +const MAX_INBOX_CAPTURES = 200; +const MAX_INBOX_DISCOVERY_DEPTH = 2; +const MAX_INBOX_CANDIDATE_FILES = 200; +const MAX_INBOX_DISCOVERY_BYTES = 100 * 1024 * 1024; const MAX_IMAGE_DIMENSION = 16384; const MAX_IMAGE_PIXELS = 40_000_000; const STATUS_VALUES = Object.freeze([ @@ -32,9 +38,17 @@ const IMAGE_TYPES = Object.freeze({ export async function createProjectStore(options = {}) { const projectRoot = await resolveExistingDirectory(options.projectRoot || process.cwd()); - const allowedImportRoots = await Promise.all( - [projectRoot, ...(options.inboxRoots || [])].map(resolveExistingDirectory) - ); + const configuredInboxRoots = Array.isArray(options.inboxRoots) ? options.inboxRoots : []; + const approvedDownloadsRoot = configuredInboxRoots.length + ? await resolveExistingDirectory(options.approvedDownloadsRoot || path.join(os.homedir(), 'Downloads')) + : ''; + const inboxRoots = Array.from(new Set(await Promise.all(configuredInboxRoots.map(resolveExistingDirectory)))); + for (const inboxRoot of inboxRoots) { + if (!isPathInside(approvedDownloadsRoot, inboxRoot)) { + throw new Error(`Inbox root must be the approved Downloads directory or one of its children: ${inboxRoot}.`); + } + } + const allowedImportRoots = Array.from(new Set([projectRoot, ...inboxRoots])); const clock = typeof options.clock === 'function' ? options.clock : () => new Date(); const readOnly = Boolean(options.readOnly); const sidecarRoot = path.join(projectRoot, '.dev-feedback'); @@ -136,6 +150,8 @@ export async function createProjectStore(options = {}) { schemaVersion: STORE_SCHEMA_VERSION, project: { ...project, root: projectRoot }, storePath: sidecarRoot, + approvedDownloadsRoot, + inboxRoots, allowedImportRoots, itemCount: items.length, statusCounts: counts @@ -162,6 +178,85 @@ export async function createProjectStore(options = {}) { return readItem(feedbackId); } + async function listInboxCaptures(options = {}) { + const discovered = await discoverInboxCaptures( + inboxRoots, + (payload) => preflightImportPayload(payload, {}, { allowMultiple: true, requireItems: true }) + ); + const limit = clampInteger(options.limit, 1, MAX_INBOX_CAPTURES, MAX_INBOX_CAPTURES); + return { + schemaVersion: STORE_SCHEMA_VERSION, + inboxRoots, + captures: discovered.captures.slice(0, limit), + total: discovered.total + }; + } + + async function importLatestInboxCapture(input = {}) { + const listed = await discoverInboxCaptures(inboxRoots, (payload) => preflightImportPayload(payload, input, { requireItems: true })); + const latest = listed.captures[0]; + if (!latest) throw new Error('No valid feedback capture JSON files found in the configured inbox.'); + return importFeedbackExport({ path: latest.path, storageKey: input.storageKey }); + } + + function preflightImportPayload(payload, input = {}, validationOptions = {}) { + const rawHistories = Array.isArray(payload?.histories) ? payload.histories : null; + if (!rawHistories) throw new Error('Import must be a standalone History JSON export.'); + if (rawHistories.length > MAX_IMPORT_HISTORIES) { + throw new Error(`Import exceeds ${MAX_IMPORT_HISTORIES} history groups.`); + } + const requestedStorageKey = sanitizeText(input.storageKey, 2000); + const requiresStorageKey = rawHistories.length > 1 && !requestedStorageKey; + if (requiresStorageKey && !validationOptions.allowMultiple) { + const keys = rawHistories.map((history) => sanitizeText(history?.storageKey, 2000) || '(missing)').join(', '); + throw new Error(`This export contains multiple site/file groups. Re-run with one explicit storageKey: ${keys}`); + } + const histories = requestedStorageKey + ? rawHistories.filter((history) => history?.storageKey === requestedStorageKey) + : rawHistories; + if (!histories.length) { + throw new Error(`storageKey was not found in the export: ${requestedStorageKey}.`); + } + const itemCount = histories.reduce((sum, history) => sum + (Array.isArray(history?.items) ? history.items.length : 0), 0); + if (itemCount > MAX_IMPORT_ITEMS) throw new Error(`Import exceeds ${MAX_IMPORT_ITEMS} feedback items.`); + if (validationOptions.requireItems && !itemCount) throw new Error('Import contains no feedback items.'); + + const entries = []; + let totalEvidenceBytes = 0; + for (const history of histories) { + const storageKey = sanitizeText(history?.storageKey, 2000); + const rawItems = Array.isArray(history?.items) ? history.items : []; + for (let itemIndex = 0; itemIndex < rawItems.length; itemIndex += 1) { + const rawItem = rawItems[itemIndex]; + const normalized = shared.normalizeFeedbackItem(rawItem, rawItem?.pageUrl, rawItem?.pageTitle); + if (!normalized) { + throw new Error(`Invalid feedback item at history ${storageKey || '(missing)'}, index ${itemIndex}.`); + } + const canonicalId = buildImportedId(normalized, rawItem, storageKey, itemIndex); + const preparedEvidence = prepareImportEvidence(rawItem, normalized); + totalEvidenceBytes += preparedEvidence.reduce((sum, evidence) => sum + evidence.bytes.length, 0); + if (totalEvidenceBytes > MAX_IMPORT_EVIDENCE_BYTES) { + throw new Error(`Import evidence exceeds ${MAX_IMPORT_EVIDENCE_BYTES} aggregate bytes.`); + } + entries.push({ + normalized, + canonicalId, + sourceItemId: typeof rawItem?.id === 'string' ? rawItem.id : canonicalId, + sourceItemSha256: hashJson(rawItem), + preparedEvidence + }); + } + } + return { + histories, + requestedStorageKey, + requiresStorageKey, + storageKeys: histories.map((history) => sanitizeText(history?.storageKey, 2000)).filter(Boolean), + itemCount, + entries + }; + } + async function createFeedback(input = {}, actor = {}) { return withMutation(async () => { const clientRequestId = sanitizeText(input.clientRequestId, 240); @@ -276,8 +371,8 @@ export async function createProjectStore(options = {}) { if (!kind) { throw new Error(`kind must be one of: ${EVIDENCE_KINDS.join(', ')}.`); } - const sourcePath = await resolveAllowedFile(input.path, allowedImportRoots); - const evidence = await copyEvidenceFile(sourcePath, item.id, kind, input.redactionState, 'agent'); + const source = await readAllowedFileBounded(input.path, allowedImportRoots, MAX_EVIDENCE_BYTES, 'Evidence'); + const evidence = await copyEvidenceBytes(source.bytes, path.basename(source.path), item.id, kind, input.redactionState, 'agent'); const timestamp = nowIso(); item.evidence[kind] = evidence; item.revision += 1; @@ -290,61 +385,26 @@ export async function createProjectStore(options = {}) { async function importFeedbackExport(input = {}) { return withMutation(async () => { - const sourcePath = await resolveAllowedFile(input.path, allowedImportRoots); - const sourceBytes = await readFileBounded(sourcePath, MAX_IMPORT_BYTES, 'Import'); + const source = await readAllowedFileBounded(input.path, allowedImportRoots, MAX_IMPORT_BYTES, 'Import'); + const sourcePath = source.path; + const sourceBytes = source.bytes; const importSha256 = crypto.createHash('sha256').update(sourceBytes).digest('hex'); const payload = JSON.parse(sourceBytes.toString('utf8')); - const rawHistories = Array.isArray(payload?.histories) ? payload.histories : null; - if (!rawHistories) { - throw new Error('Import must be a standalone History JSON export.'); - } - if (rawHistories.length > MAX_IMPORT_HISTORIES) { - throw new Error(`Import exceeds ${MAX_IMPORT_HISTORIES} history groups.`); - } - const requestedStorageKey = sanitizeText(input.storageKey, 2000); - if (rawHistories.length > 1 && !requestedStorageKey) { - const keys = rawHistories.map((history) => sanitizeText(history?.storageKey, 2000) || '(missing)').join(', '); - throw new Error(`This export contains multiple site/file groups. Re-run with one explicit storageKey: ${keys}`); - } - const histories = requestedStorageKey - ? rawHistories.filter((history) => history?.storageKey === requestedStorageKey) - : rawHistories; - if (!histories.length) { - throw new Error(`storageKey was not found in the export: ${requestedStorageKey}.`); - } - const itemCount = histories.reduce((sum, history) => sum + (Array.isArray(history?.items) ? history.items.length : 0), 0); - if (itemCount > MAX_IMPORT_ITEMS) { - throw new Error(`Import exceeds ${MAX_IMPORT_ITEMS} feedback items.`); - } + const preflight = preflightImportPayload(payload, input); const imported = []; const updated = []; const skipped = []; const prepared = []; - let totalEvidenceBytes = 0; - for (const history of histories) { - const storageKey = sanitizeText(history?.storageKey, 2000); - const rawItems = Array.isArray(history?.items) ? history.items : []; - for (let itemIndex = 0; itemIndex < rawItems.length; itemIndex += 1) { - const rawItem = rawItems[itemIndex]; - const normalized = shared.normalizeFeedbackItem(rawItem, rawItem?.pageUrl, rawItem?.pageTitle); - if (!normalized) { - throw new Error(`Invalid feedback item at history ${storageKey || '(missing)'}, index ${itemIndex}.`); - } - const canonicalId = buildImportedId(normalized, rawItem, storageKey, itemIndex); - const existing = await readItemIfPresent(canonicalId); - const sourceItemId = typeof rawItem?.id === 'string' ? rawItem.id : canonicalId; - const sourceItemSha256 = hashJson(rawItem); - if (existing?.provenance?.sourceItemSha256 === sourceItemSha256 && existing.provenance.sourceItemId === sourceItemId) { - skipped.push(canonicalId); - continue; - } - const preparedEvidence = prepareImportEvidence(rawItem, normalized); - totalEvidenceBytes += preparedEvidence.reduce((sum, evidence) => sum + evidence.bytes.length, 0); - if (totalEvidenceBytes > MAX_IMPORT_EVIDENCE_BYTES) { - throw new Error(`Import evidence exceeds ${MAX_IMPORT_EVIDENCE_BYTES} aggregate bytes.`); - } - prepared.push({ normalized, canonicalId, existing, sourceItemId, sourceItemSha256, preparedEvidence }); + for (const entry of preflight.entries) { + const existing = await readItemIfPresent(entry.canonicalId); + if ( + existing?.provenance?.sourceItemSha256 === entry.sourceItemSha256 + && existing.provenance.sourceItemId === entry.sourceItemId + ) { + skipped.push(entry.canonicalId); + continue; } + prepared.push({ ...entry, existing }); } for (const entry of prepared) { @@ -370,7 +430,7 @@ export async function createProjectStore(options = {}) { return { schemaVersion: STORE_SCHEMA_VERSION, sourceFile: path.basename(sourcePath), - storageKey: requestedStorageKey || sanitizeText(histories[0]?.storageKey, 2000), + storageKey: preflight.requestedStorageKey || sanitizeText(preflight.histories[0]?.storageKey, 2000), importSha256, imported, updated, @@ -527,10 +587,9 @@ export async function createProjectStore(options = {}) { ]; } - async function copyEvidenceFile(sourcePath, feedbackId, kind, redactionState, source) { - const bytes = await readFileBounded(sourcePath, MAX_EVIDENCE_BYTES, 'Evidence'); + async function copyEvidenceBytes(bytes, sourceName, feedbackId, kind, redactionState, source) { const mimeType = detectImageType(bytes); - if (!mimeType) throw new Error(`Unsupported or invalid evidence image: ${path.basename(sourcePath)}.`); + if (!mimeType) throw new Error(`Unsupported or invalid evidence image: ${sourceName}.`); return writeEvidenceBytes(bytes, mimeType, feedbackId, kind, redactionState, source); } @@ -579,8 +638,12 @@ export async function createProjectStore(options = {}) { return Object.freeze({ projectRoot, sidecarRoot, + approvedDownloadsRoot, + inboxRoots, allowedImportRoots, getProjectStatus, + listInboxCaptures, + importLatestInboxCapture, listFeedback, getFeedback, createFeedback, @@ -948,15 +1011,154 @@ async function resolveExistingDirectory(value) { return resolved; } -async function resolveAllowedFile(value, allowedRoots) { +async function readAllowedFileBounded(value, allowedRoots, maximumBytes, label) { if (typeof value !== 'string' || !value) throw new Error('A file path is required.'); - const real = await fs.realpath(path.resolve(value)); - const stat = await fs.stat(real); - if (!stat.isFile()) throw new Error(`Not a file: ${real}.`); - if (!allowedRoots.some((root) => isPathInside(root, real))) { - throw new Error(`File is outside the configured project/inbox roots: ${real}.`); + const inputPath = path.resolve(value); + const requestedPath = path.join(await fs.realpath(path.dirname(inputPath)), path.basename(inputPath)); + const containingRoot = allowedRoots + .filter((root) => isPathInside(root, requestedPath)) + .sort((left, right) => right.length - left.length)[0]; + if (!containingRoot) { + throw new Error(`File is outside the configured project/inbox roots: ${requestedPath}.`); + } + await assertNoSymlinkPath(containingRoot, requestedPath); + const realPath = await fs.realpath(requestedPath); + if (!isPathInside(containingRoot, realPath)) { + throw new Error(`File is outside the configured project/inbox roots: ${realPath}.`); + } + + const handle = await fs.open(requestedPath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW); + try { + const before = await handle.stat(); + if (!before.isFile()) throw new Error(`Not a file: ${realPath}.`); + if (before.size > maximumBytes) throw new Error(`${label} exceeds ${maximumBytes} bytes.`); + const verifiedRealPath = await fs.realpath(requestedPath); + const pathStat = await fs.stat(verifiedRealPath); + if ( + verifiedRealPath !== realPath + || pathStat.dev !== before.dev + || pathStat.ino !== before.ino + ) { + throw new Error(`${label} path changed while it was being opened.`); + } + const bytes = await handle.readFile(); + const after = await handle.stat(); + if ( + after.dev !== before.dev + || after.ino !== before.ino + || after.size !== before.size + || bytes.length !== before.size + ) { + throw new Error(`${label} changed while it was being read.`); + } + return { path: realPath, bytes, stat: before }; + } finally { + await handle.close(); + } +} + +async function assertNoSymlinkPath(root, candidate) { + const relative = path.relative(root, candidate); + if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) { + throw new Error(`File is outside the configured project/inbox roots: ${candidate}.`); + } + let current = root; + for (const segment of relative.split(path.sep).filter(Boolean)) { + current = path.join(current, segment); + const stat = await fs.lstat(current); + if (stat.isSymbolicLink()) throw new Error(`Refusing symlinked import path: ${current}.`); } - return real; +} + +async function discoverInboxCaptures(inboxRoots, validatePayload) { + const discovery = { candidates: [], seenPaths: new Set(), totalBytes: 0 }; + for (const inboxRoot of inboxRoots) await collectInboxFiles(inboxRoot, discovery); + + const captures = []; + for (const candidate of discovery.candidates) { + const capture = await inspectInboxCapture(candidate, validatePayload); + if (capture) captures.push(capture); + } + captures.sort((left, right) => { + if (right._modifiedAtMs !== left._modifiedAtMs) return right._modifiedAtMs - left._modifiedAtMs; + const leftKey = `${left._root}\0${left.fileName}`; + const rightKey = `${right._root}\0${right.fileName}`; + return compareStableText(leftKey, rightKey); + }); + const publicCaptures = captures.map(({ _modifiedAtMs, _root, ...capture }) => capture); + return { captures: publicCaptures, total: publicCaptures.length }; +} + +async function collectInboxFiles(inboxRoot, discovery) { + async function visit(directory, depth) { + let entries; + try { + entries = await fs.readdir(directory, { withFileTypes: true }); + } catch (error) { + if (error.code === 'ENOENT') return; + throw error; + } + entries.sort((left, right) => compareStableText(left.name, right.name)); + for (const entry of entries) { + const entryPath = path.join(directory, entry.name); + const stat = await fs.lstat(entryPath).catch((error) => { + if (error.code === 'ENOENT') return null; + throw error; + }); + if (!stat || stat.isSymbolicLink()) continue; + if (stat.isDirectory()) { + if (depth >= MAX_INBOX_DISCOVERY_DEPTH) continue; + const realDirectory = await fs.realpath(entryPath); + if (isPathInside(inboxRoot, realDirectory)) await visit(realDirectory, depth + 1); + } else if (stat.isFile() && path.extname(entry.name).toLowerCase() === '.json') { + const realPath = await fs.realpath(entryPath); + if (!isPathInside(inboxRoot, realPath) || discovery.seenPaths.has(realPath)) continue; + discovery.seenPaths.add(realPath); + if (discovery.candidates.length >= MAX_INBOX_CANDIDATE_FILES) { + throw new Error(`Inbox discovery exceeds the ${MAX_INBOX_CANDIDATE_FILES} candidate-file limit.`); + } + discovery.totalBytes += stat.size; + if (discovery.totalBytes > MAX_INBOX_DISCOVERY_BYTES) { + throw new Error(`Inbox discovery exceeds the ${MAX_INBOX_DISCOVERY_BYTES}-byte budget.`); + } + discovery.candidates.push({ path: entryPath, root: inboxRoot, bytes: stat.size }); + } + } + } + await visit(inboxRoot, 0); +} + +async function inspectInboxCapture(candidate, validatePayload) { + try { + const source = await readAllowedFileBounded( + candidate.path, + [candidate.root], + Math.min(MAX_IMPORT_BYTES, candidate.bytes), + 'Inbox capture' + ); + if (source.stat.size !== candidate.bytes) return null; + const payload = JSON.parse(source.bytes.toString('utf8')); + const summary = validatePayload(payload); + return { + fileName: path.relative(candidate.root, source.path).split(path.sep).join('/'), + path: source.path, + bytes: source.bytes.length, + modifiedAt: new Date(source.stat.mtimeMs).toISOString(), + historyCount: summary.histories.length, + itemCount: summary.itemCount, + storageKeys: summary.storageKeys, + requiresStorageKey: summary.requiresStorageKey, + _modifiedAtMs: source.stat.mtimeMs, + _root: candidate.root + }; + } catch (error) { + return null; + } +} + +function compareStableText(left, right) { + if (left === right) return 0; + return left < right ? -1 : 1; } function isPathInside(root, candidate) { @@ -1089,6 +1291,10 @@ export const constants = Object.freeze({ MAX_IMPORT_HISTORIES, MAX_IMPORT_ITEMS, MAX_IMPORT_EVIDENCE_BYTES, + MAX_INBOX_CAPTURES, + MAX_INBOX_DISCOVERY_DEPTH, + MAX_INBOX_CANDIDATE_FILES, + MAX_INBOX_DISCOVERY_BYTES, MAX_IMAGE_DIMENSION, MAX_IMAGE_PIXELS }); diff --git a/package-lock.json b/package-lock.json index c14a7cd..0089af7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dev-feedback-capture", - "version": "1.7.2", + "version": "1.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dev-feedback-capture", - "version": "1.7.2", + "version": "1.8.0", "license": "UNLICENSED", "dependencies": { "@modelcontextprotocol/sdk": "1.29.0", diff --git a/package.json b/package.json index b3721ce..a8e82a7 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { "name": "dev-feedback-capture", "private": true, - "version": "1.7.2", + "version": "1.8.0", "license": "UNLICENSED", - "description": "Pick and annotate browser UI for AI-ready prompts and local visual change specs", + "description": "Capture browser elements and regions for local implementation handoffs", "scripts": { "test": "npm run test:extension && npm run test:mcp", - "test:extension": "node test/release.test.cjs", + "test:extension": "node --test test/browser-core.test.cjs && node test/release.test.cjs", "test:mcp": "node --test test/mcp.test.mjs", "check": "node scripts/release-check.cjs", "package": "node scripts/package-extension.cjs", diff --git a/popup.html b/popup.html index 594ac09..a9b33e0 100644 --- a/popup.html +++ b/popup.html @@ -383,20 +383,6 @@

    Dev Feedback Capture

    Inspect and click page elements. - -