diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..020c627 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,49 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +concurrency: + group: ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + validate: + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Run tests + run: npm test + + - name: Run release checks + run: npm run check + + - name: Package extension + run: npm run package + + - name: Verify package artifact + run: npm run verify:package + + - name: Upload verified package + if: github.event_name == 'push' + uses: actions/upload-artifact@v4 + with: + name: dev-feedback-capture-${{ github.sha }} + path: | + dist/*.zip + dist/*.sha256 + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8cf9175..16486a6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -49,12 +49,12 @@ jobs: run: npm run package - name: Verify package artifact - run: test -f "$ZIP_PATH" + run: npm run verify:package -- "$ZIP_PATH" - name: Publish GitHub Release env: GH_TOKEN: ${{ github.token }} run: | - gh release create "$GITHUB_REF_NAME" "$ZIP_PATH" \ + gh release create "$GITHUB_REF_NAME" "$ZIP_PATH" "$ZIP_PATH.sha256" \ --title "Dev Feedback Capture $GITHUB_REF_NAME" \ - --notes-file CHANGELOG.md + --generate-notes diff --git a/CHANGELOG.md b/CHANGELOG.md index 521708c..28b2fa0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 1.3.0 (Unreleased) + +- Added an extension-owned History page with filtering, item deletion, site clearing, and access to feedback from PDFs and non-injectable surfaces. +- Added downloadable JSON and self-contained HTML reports with embedded region images alongside copyable Markdown and AI Prompt exports. +- Updated public install and release metadata to reflect the published `v1.2.0` release. + ## 1.2.0 - Added region capture for pages and browser-rendered PDFs. diff --git a/QUICKSTART.md b/QUICKSTART.md index 211cf61..1e6e074 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -2,6 +2,8 @@ Get Dev Feedback Capture running in a few minutes. +The latest published ZIP is v1.2.0. The extension-owned History page described below is currently available from the unreleased v1.3 source checkout. + ## 1. Load the extension 1. Download the latest `dev-feedback-capture-v.zip` asset from GitHub Releases. @@ -34,11 +36,14 @@ Optional for local PDFs: ## 4. Export saved feedback -From the in-page history panel on injectable pages, you can copy: +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 `JSON` for full payloads including crop image data +- 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 -- `JSON` for full payloads including crop image data -- `Markdown` for issue trackers or docs -- `AI Prompt` for a ready-to-paste numbered summary +AI Prompt is text-only. Its numbered crop references correspond to the embedded images in the companion HTML or JSON download. ## Need Help? diff --git a/README.md b/README.md index 67c1cff..9f756c8 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,12 @@ Dev Feedback Capture is a Chromium extension for collecting structured UI feedback from live pages, PDFs, and other browser-visible surfaces. It supports two capture modes: +> The source checkout contains the unreleased v1.3 History & Reliability work. The latest published download remains v1.2.0 until the v1.3 manual browser gate and release are complete. + - `Element` mode injects a lightweight in-page UI so you can click DOM elements and save selectors, styles, and notes. - `Region` mode captures the visible viewport, opens a screenshot editor, and lets you draw a crop around any area, including browser-rendered PDFs. -All feedback stays local in extension storage and can be re-exported later as JSON, Markdown, or an AI-oriented prompt. +All feedback stays local in extension storage. Open the extension-owned History page from the popup to review captures, copy Markdown or implementation instructions, and download JSON or a self-contained HTML report. ## Features @@ -13,16 +15,15 @@ All feedback stays local in extension storage and can be re-exported later as JS - Region capture with cropped screenshot, viewport rectangle, and note metadata - Works on arbitrary sites through explicit user-triggered activation - PDF-friendly screenshot workflow for local and hosted PDFs -- Local history with re-export support -- JSON, Markdown, and AI prompt clipboard exports -- Draggable in-page history panel for injected pages +- Extension-owned History page that works even when the source page cannot accept injected UI +- Downloadable JSON and self-contained HTML reports with embedded region images +- Copyable Markdown and implementation-prompt exports +- Draggable in-page history panel for quick review on injectable pages ## Installation ### Latest Release ZIP -Use this path after the first GitHub Release has been published. Until then, use the source checkout path below. - 1. Download the latest `dev-feedback-capture-v.zip` asset from [GitHub Releases](https://github.com/StoneHub/webDevFeedbackExt/releases). 2. Unzip the file. 3. Open `chrome://extensions/` or `edge://extensions/`. @@ -57,7 +58,7 @@ Use this path when developing the extension or reviewing source changes: 4. In the editor tab, drag a box over the screenshot. 5. Add your note and save it. -The cropped image, viewport rectangle, and source context are saved into the same page history as element captures. +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. ## Data Model @@ -70,9 +71,10 @@ Older element-only captures are still loaded and normalized automatically. ## Export Formats -- `Copy JSON` includes the full saved payload, including region image data URLs. -- `Copy Markdown` creates a readable review document for issues or docs. -- `Copy AI Prompt` creates numbered instructions that refer to the saved crops in extension history. +- `Download JSON` includes the full saved payload, including region image data URLs. +- `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, copy-ready implementation instructions from the saved text and source context. The numbered crop images are available in the companion HTML or JSON download. ## Permissions @@ -96,9 +98,10 @@ The extension does not use static host permissions, always-on content scripts, t - `manifest.json`: Manifest V3 configuration - `background.js`: runtime injection and region-capture session orchestration -- `content.js`: in-page panel, element capture, exports, and history rendering +- `content.js`: in-page panel and element capture - `capture.html` / `capture.js`: screenshot region selection editor -- `popup.html` / `popup.js`: mode switch and current-tab actions +- `popup.html` / `popup.js`: mode switch, current-tab actions, and History entry point +- `history.html` / `history.js`: extension-owned history review and exports - `shared.js`: shared helpers, normalization, and export formatting - `styles.css`: injected in-page UI styles @@ -112,7 +115,7 @@ The extension does not use static host permissions, always-on content scripts, t 1. Confirm `package.json` and `manifest.json` versions match. 2. Run `npm test`, `npm run check`, and `npm run package`. -3. Create and push a matching tag such as `v1.2.0`. +3. Create and push a matching tag such as `v1.3.0`. 4. The release workflow builds `dist/dev-feedback-capture-v.zip` and publishes it as a GitHub Release asset. See [CHANGELOG.md](CHANGELOG.md) for release notes. @@ -126,9 +129,8 @@ See [CHANGELOG.md](CHANGELOG.md) for release notes. ## Roadmap -- Export region crops as files instead of only embedding them in JSON - Add full-page or multi-step PDF region capture -- Add import/export for saved histories +- Add import for saved histories - Add optional provider-specific AI handoff after the provider/auth shape is defined ## License diff --git a/background.js b/background.js index 95cdde8..6972d8d 100644 --- a/background.js +++ b/background.js @@ -3,34 +3,77 @@ importScripts('shared.js'); - const { buildFeedbackId, canInjectIntoUrl, getEffectivePageUrl } = globalThis.DevFeedbackShared; - const REGION_CAPTURE_SESSION_PREFIX = 'dev-feedback-region-session-'; + const { + FEEDBACK_STORAGE_PREFIX, + REGION_CAPTURE_SESSION_PREFIX, + buildFeedbackId, + canInjectIntoUrl, + sanitizeFeedbackItems, + getEffectivePageUrl + } = globalThis.DevFeedbackShared; + const REGION_SESSION_MAX_AGE_MS = 30 * 60 * 1000; + const mutationQueues = new Map(); chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { if (request.action === 'ensure-content-script') { - ensureContentScript(request.tabId, request.url).then(sendResponse); + respondAsync(ensureContentScript(request.tabId, request.url), sendResponse); return true; } if (request.action === 'start-region-capture') { const tab = request.tab || sender.tab; - startRegionCapture(tab, request.viewportMetrics).then(sendResponse); + respondAsync(startRegionCapture(tab, request.viewportMetrics), sendResponse); return true; } if (request.action === 'notify-feedback-updated') { - notifyFeedbackUpdated(request.tabId).then(sendResponse); + respondAsync(notifyFeedbackUpdated(request.tabId), sendResponse); return true; } if (request.action === 'clear-region-session') { - clearRegionSession(request.sessionId).then(sendResponse); + respondAsync(clearRegionSession(request.sessionId), sendResponse); + return true; + } + + if (request.action === 'list-feedback-history') { + respondAsync(listFeedbackHistory(), sendResponse); + return true; + } + + if (request.action === 'get-feedback-items') { + respondAsync(getFeedbackItems(request.storageKey), sendResponse); + return true; + } + + if (request.action === 'add-feedback-item') { + respondAsync(addFeedbackItem(request.storageKey, request.item), sendResponse); + return true; + } + + if (request.action === 'delete-feedback-item') { + respondAsync(deleteFeedbackItem(request.storageKey, request.itemId), sendResponse); + return true; + } + + if (request.action === 'clear-feedback-items') { + respondAsync(clearFeedbackItems(request.storageKey), sendResponse); return true; } return false; }); + chrome.tabs.onRemoved.addListener((tabId) => { + clearRegionSessionsForEditorTab(tabId).catch((error) => { + console.debug('Unable to clear closed region editor session:', error.message); + }); + }); + + sweepExpiredRegionSessions().catch((error) => { + console.debug('Unable to sweep expired region sessions:', error.message); + }); + chrome.commands.onCommand.addListener((command) => { if (command !== 'toggle-feedback-mode') { return; @@ -87,10 +130,12 @@ return { ok: false, reason: 'No active tab is available for capture.' }; } + let storageKey = ''; try { + await sweepExpiredRegionSessions(); const screenshotDataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, { format: 'png' }); const sessionId = buildFeedbackId(); - const storageKey = `${REGION_CAPTURE_SESSION_PREFIX}${sessionId}`; + storageKey = `${REGION_CAPTURE_SESSION_PREFIX}${sessionId}`; const pageUrl = getEffectivePageUrl(tab.url || ''); const session = { sessionId, @@ -104,13 +149,20 @@ createdAt: new Date().toISOString() }; - await chrome.storage.local.set({ [storageKey]: session }); - await chrome.tabs.create({ + await chrome.storage.session.set({ [storageKey]: session }); + const editorTab = await chrome.tabs.create({ url: chrome.runtime.getURL(`capture.html?session=${encodeURIComponent(sessionId)}`) }); + await chrome.storage.session.set({ + [storageKey]: { ...session, editorTabId: editorTab.id } + }); + await chrome.tabs.get(editorTab.id); return { ok: true, sessionId }; } catch (error) { + if (typeof storageKey === 'string') { + await chrome.storage.session.remove(storageKey).catch(() => {}); + } return { ok: false, reason: error.message || 'Unable to capture the current tab.' }; } } @@ -134,10 +186,126 @@ return { ok: false, reason: 'Missing region capture session id.' }; } - await chrome.storage.local.remove(`${REGION_CAPTURE_SESSION_PREFIX}${sessionId}`); + await chrome.storage.session.remove(`${REGION_CAPTURE_SESSION_PREFIX}${sessionId}`); return { ok: true }; } + async function clearRegionSessionsForEditorTab(tabId) { + const sessions = await chrome.storage.session.get(null); + const keys = Object.entries(sessions) + .filter(([key, value]) => key.startsWith(REGION_CAPTURE_SESSION_PREFIX) && value?.editorTabId === tabId) + .map(([key]) => key); + + if (keys.length) { + await chrome.storage.session.remove(keys); + } + } + + async function sweepExpiredRegionSessions() { + const sessions = await chrome.storage.session.get(null); + const now = Date.now(); + const expiredKeys = Object.entries(sessions) + .filter(([key, value]) => { + if (!key.startsWith(REGION_CAPTURE_SESSION_PREFIX)) { + return false; + } + const createdAt = Date.parse(value?.createdAt || ''); + return !Number.isFinite(createdAt) || now - createdAt > REGION_SESSION_MAX_AGE_MS; + }) + .map(([key]) => key); + + if (expiredKeys.length) { + await chrome.storage.session.remove(expiredKeys); + } + } + + async function listFeedbackHistory() { + const stored = await chrome.storage.local.get(null); + const histories = await Promise.all(Object.entries(stored).flatMap(([storageKey, value]) => { + if (!storageKey.startsWith(FEEDBACK_STORAGE_PREFIX) || !Array.isArray(value)) { + return []; + } + return [getFeedbackItems(storageKey).then((response) => ({ storageKey, items: response.items || [] }))]; + })); + + return { ok: true, histories }; + } + + async function getFeedbackItems(storageKey) { + if (!isFeedbackStorageKey(storageKey)) { + return { ok: false, reason: 'Invalid feedback storage key.' }; + } + return enqueueFeedbackOperation(storageKey, async () => { + const stored = await chrome.storage.local.get([storageKey]); + const { items, needsMigration } = normalizeStoredFeedbackItems(stored[storageKey]); + if (needsMigration) { + await chrome.storage.local.set({ [storageKey]: items }); + } + return { ok: true, items }; + }); + } + + async function addFeedbackItem(storageKey, item) { + return mutateFeedbackItems(storageKey, (items) => items.concat(item)); + } + + async function deleteFeedbackItem(storageKey, itemId) { + if (!itemId) { + return { ok: false, reason: 'Missing feedback item id.' }; + } + return mutateFeedbackItems(storageKey, (items) => items.filter((item) => item.id !== itemId)); + } + + async function clearFeedbackItems(storageKey) { + return mutateFeedbackItems(storageKey, () => []); + } + + function mutateFeedbackItems(storageKey, mutate) { + if (!isFeedbackStorageKey(storageKey)) { + return Promise.resolve({ ok: false, reason: 'Invalid feedback storage key.' }); + } + + return enqueueFeedbackOperation(storageKey, async () => { + const stored = await chrome.storage.local.get([storageKey]); + const { items: currentItems } = normalizeStoredFeedbackItems(stored[storageKey]); + const nextItems = sanitizeFeedbackItems(mutate(currentItems)); + await chrome.storage.local.set({ [storageKey]: nextItems }); + return { ok: true, items: nextItems }; + }); + } + + function enqueueFeedbackOperation(storageKey, operation) { + const previous = mutationQueues.get(storageKey) || Promise.resolve(); + const current = previous.catch(() => {}).then(operation); + + mutationQueues.set(storageKey, current); + const clearQueue = () => { + if (mutationQueues.get(storageKey) === current) { + mutationQueues.delete(storageKey); + } + }; + current.then(clearQueue, clearQueue); + return current; + } + + function isFeedbackStorageKey(storageKey) { + return typeof storageKey === 'string' && storageKey.startsWith(FEEDBACK_STORAGE_PREFIX); + } + + function normalizeStoredFeedbackItems(rawItems) { + const normalized = sanitizeFeedbackItems(rawItems); + const needsMigration = !Array.isArray(rawItems) || rawItems.length !== normalized.length || rawItems.some((item) => ( + !item || typeof item.id !== 'string' || !item.id || !item.type || !item.captureType + )); + return { items: normalized, needsMigration }; + } + + function respondAsync(promise, sendResponse) { + Promise.resolve(promise) + .then(sendResponse) + .catch((error) => sendResponse({ ok: false, reason: error.message || 'Extension operation failed.' })); + } + async function withActiveTab(callback) { const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); await callback(tabs && tabs[0]); diff --git a/capture.html b/capture.html index 968a4de..4712232 100644 --- a/capture.html +++ b/capture.html @@ -69,6 +69,7 @@ overflow: hidden; background: #0b0d0c; border: 1px solid var(--line); + touch-action: none; } .image-wrap img { @@ -76,6 +77,11 @@ max-width: 100%; } + .image-wrap:focus-visible { + outline: 3px solid rgba(142, 216, 160, 0.5); + outline-offset: 3px; + } + .selection { position: absolute; border: 2px solid var(--accent); @@ -278,7 +284,7 @@
-
+
Captured viewport screenshot
@@ -305,7 +311,7 @@

Region Capture

-
Requested Change
+
0 / 2000
@@ -316,8 +322,8 @@

Region Capture

-

-

Press Escape to cancel. Region crops can include sensitive visible page content and stay local until cleared.

+

+

Mouse, touch, or pen: draw a box. Keyboard: press Enter, then move with arrow keys or resize with Shift + arrow keys. Press Escape to cancel.

diff --git a/capture.js b/capture.js index fa617e8..528d1f9 100644 --- a/capture.js +++ b/capture.js @@ -10,8 +10,7 @@ MAX_NOTE_LENGTH, buildFeedbackId, detectSourceKind, - makeStorageKey, - sanitizeFeedbackItems + makeStorageKey } = globalThis.DevFeedbackShared; const SESSION_PREFIX = 'dev-feedback-region-session-'; @@ -34,11 +33,11 @@ document.getElementById('reset-btn').addEventListener('click', resetSelection); document.getElementById('cancel-btn').addEventListener('click', cancelCapture); - imageWrap.addEventListener('mousedown', startSelection); + imageWrap.addEventListener('pointerdown', startSelection); noteField.addEventListener('input', updateCaptureState); window.addEventListener('keydown', handleKeydown); - window.addEventListener('mousemove', updateSelection); - window.addEventListener('mouseup', finishSelection); + window.addEventListener('pointermove', updateSelection); + window.addEventListener('pointerup', finishSelection); init().catch((error) => { setStatus(error.message || 'Unable to load the capture session.', true); @@ -50,7 +49,7 @@ throw new Error('Missing region capture session id.'); } - const result = await chrome.storage.local.get([`${SESSION_PREFIX}${sessionId}`]); + const result = await chrome.storage.session.get([`${SESSION_PREFIX}${sessionId}`]); session = result[`${SESSION_PREFIX}${sessionId}`]; if (!session || !session.screenshotDataUrl) { @@ -78,6 +77,7 @@ startX: clamp(event.clientX - rect.left, 0, rect.width), startY: clamp(event.clientY - rect.top, 0, rect.height) }; + imageWrap.setPointerCapture?.(event.pointerId); selection = { x: dragState.startX, @@ -166,12 +166,7 @@ const viewportRect = buildViewportRect(selection); const storageKey = makeStorageKey(session.pageUrl || session.rawTabUrl || ''); try { - const existing = await chrome.storage.local.get([storageKey]); - const nextItems = sanitizeFeedbackItems( - existing[storageKey], - session.pageUrl || session.rawTabUrl || '', - session.pageTitle || '' - ).concat({ + const item = { id: buildFeedbackId(), type: CAPTURE_TYPE_REGION, captureType: CAPTURE_TYPE_REGION, @@ -190,9 +185,16 @@ sourceKind: detectSourceKind(session.pageUrl || session.rawTabUrl || ''), note: note.slice(0, MAX_NOTE_LENGTH), timestamp: new Date().toISOString() - }); + }; - await persistFeedbackItems(storageKey, nextItems); + const result = await chrome.runtime.sendMessage({ + action: 'add-feedback-item', + storageKey, + item + }); + if (!result?.ok) { + throw new Error(result?.reason || 'Unable to save region feedback.'); + } await chrome.runtime.sendMessage({ action: 'notify-feedback-updated', tabId: session.tabId }); await chrome.runtime.sendMessage({ action: 'clear-region-session', sessionId: session.sessionId }); @@ -220,7 +222,44 @@ if (event.key === 'Escape') { event.preventDefault(); cancelCapture(); + return; } + + if (event.target !== imageWrap) { + return; + } + + const rect = imageWrap.getBoundingClientRect(); + if ((event.key === 'Enter' || event.key === ' ') && !selection) { + event.preventDefault(); + selection = { + x: Math.round(rect.width * 0.25), + y: Math.round(rect.height * 0.25), + width: Math.max(8, Math.round(rect.width * 0.5)), + height: Math.max(8, Math.round(rect.height * 0.5)) + }; + renderSelection(); + updateCaptureState(); + return; + } + + if (!selection || !['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) { + return; + } + + event.preventDefault(); + const delta = event.altKey ? 1 : 10; + const horizontal = event.key === 'ArrowLeft' ? -delta : event.key === 'ArrowRight' ? delta : 0; + const vertical = event.key === 'ArrowUp' ? -delta : event.key === 'ArrowDown' ? delta : 0; + if (event.shiftKey) { + selection.width = clamp(selection.width + horizontal, 8, rect.width - selection.x); + selection.height = clamp(selection.height + vertical, 8, rect.height - selection.y); + } else { + selection.x = clamp(selection.x + horizontal, 0, rect.width - selection.width); + selection.y = clamp(selection.y + vertical, 0, rect.height - selection.height); + } + renderSelection(); + updateCaptureState(); } function updateCaptureState() { @@ -239,19 +278,6 @@ return Boolean(noteField.value.trim() || (selection && selection.width >= 8 && selection.height >= 8)); } - function persistFeedbackItems(storageKey, nextItems) { - return new Promise((resolve, reject) => { - chrome.storage.local.set({ [storageKey]: nextItems }, () => { - if (chrome.runtime.lastError) { - reject(new Error(chrome.runtime.lastError.message || 'Unable to write extension storage.')); - return; - } - - resolve(); - }); - }); - } - function cropSelectedRegion() { const naturalRect = buildNaturalRect(selection); const canvas = document.createElement('canvas'); @@ -316,5 +342,6 @@ function setStatus(message, isError) { statusLine.textContent = message; statusLine.classList.toggle('error', Boolean(isError)); + statusLine.setAttribute('role', isError ? 'alert' : 'status'); } })(); diff --git a/content.js b/content.js index 616cda1..bb4289c 100644 --- a/content.js +++ b/content.js @@ -48,6 +48,7 @@ let captureModal = null; let markerLayer = null; let decorationFrame = 0; + let modalReturnFocus = null; function init() { if (!document.body) { @@ -102,7 +103,7 @@ captureModal = document.createElement('div'); captureModal.id = UI_IDS.modal; captureModal.innerHTML = ` -