From 67c9e4c36ccd59f9064780a1119f5ef1b2bf26c5 Mon Sep 17 00:00:00 2001 From: StoneHub Date: Mon, 3 Aug 2026 16:03:51 -0400 Subject: [PATCH 1/2] Add reversible content proposal mode --- QUICKSTART.md | 17 +- README.md | 17 +- ai-bundle.js | 6 + background.js | 2 +- content-proposal.js | 170 +++++++++++ content.js | 482 ++++++++++++++++++++++++++++++-- docs/add-content-mode-plan.html | 195 +++++++++++++ history.js | 6 + popup.html | 11 +- popup.js | 49 ++++ scripts/package-extension.cjs | 1 + scripts/verify-package.cjs | 1 + shared.js | 39 ++- styles.css | 160 ++++++++++- test/release.test.cjs | 133 ++++++++- 15 files changed, 1258 insertions(+), 31 deletions(-) create mode 100644 content-proposal.js create mode 100644 docs/add-content-mode-plan.html diff --git a/QUICKSTART.md b/QUICKSTART.md index 5664589..75fff19 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -40,7 +40,18 @@ Optional for local PDFs: 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. Compile an annotated region spec +## 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 1. Open the target page or PDF in the browser. 2. Open the extension popup and switch to `Region`. @@ -50,7 +61,7 @@ Optional for local PDFs: 6. Describe the requested change and optionally add one acceptance check per line. 7. Save the visual change spec. -## 5. Export saved feedback +## 6. 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: @@ -62,7 +73,7 @@ Open the extension popup and select `Open History & Export`. This extension-owne AI Prompt is text-only. Use AI Bundle when the implementation handoff needs its numbered evidence images. -## 6. Give a local agent project-scoped feedback +## 7. 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. diff --git a/README.md b/README.md index 70ec2a8..56c0f54 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,12 @@ # Dev Feedback Capture -Pick and annotate browser elements or regions, then copy AI-ready prompts or export local visual change specs for Codex, Claude Code, Cursor, or another developer. Dev Feedback Capture supports three evidence-rich workflows: +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: > [Chrome Web Store v1.7 is public](https://chromewebstore.google.com/detail/dev-feedback-capture/hhdmfaaplpiokafjieefpgoppckijafc). 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. - `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. 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. @@ -15,6 +16,7 @@ All feedback stays local in extension storage. Open History to review captures, - 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 - 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 @@ -83,12 +85,23 @@ The cropped image, viewport rectangle, and source context are saved into the sam 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. +### Add Content Mode + +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. + ## 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. Older element-only captures are still loaded and normalized automatically. @@ -132,6 +145,7 @@ The extension does not use static host permissions, always-on content scripts, t - `background.js`: runtime injection and region-capture session 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 @@ -160,6 +174,7 @@ See [CHANGELOG.md](CHANGELOG.md) for release notes. - 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. - 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. diff --git a/ai-bundle.js b/ai-bundle.js index 2e79be0..be932f1 100644 --- a/ai-bundle.js +++ b/ai-bundle.js @@ -369,6 +369,12 @@ || target.tag || target.text || formatRect(target.rect); + if (mutation?.action === 'insert') { + const content = mutation.parameters?.content || {}; + const placement = mutation.parameters?.placement || 'after'; + const details = [content.title, content.body, content.support].filter(Boolean).join(' | '); + return `insert ${content.type || 'content'} ${placement} ${identity || 'unknown target'}${details ? `; ${details}` : ''}`; + } const parameters = mutation?.parameters && Object.keys(mutation.parameters).length ? `; parameters=${JSON.stringify(mutation.parameters)}` : ''; diff --git a/background.js b/background.js index 788e4b6..eb45263 100644 --- a/background.js +++ b/background.js @@ -127,7 +127,7 @@ try { await chrome.scripting.executeScript({ target: { tabId }, - files: ['shared.js', 'visual-edit.js', 'content.js'] + files: ['shared.js', 'visual-edit.js', 'content-proposal.js', 'content.js'] }); return { ok: true }; } catch (error) { diff --git a/content-proposal.js b/content-proposal.js new file mode 100644 index 0000000..5cb55ce --- /dev/null +++ b/content-proposal.js @@ -0,0 +1,170 @@ +/** + * 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 e0f0b36..a466655 100644 --- a/content.js +++ b/content.js @@ -43,7 +43,9 @@ OFF: 'off', ELEMENT: 'element', VISUAL_PICK: 'visual-pick', - VISUAL_EDIT: 'visual-edit' + VISUAL_EDIT: 'visual-edit', + CONTENT_PICK: 'content-pick', + CONTENT_EDIT: 'content-edit' }); let feedbackMode = false; @@ -67,6 +69,12 @@ 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) { @@ -94,6 +102,7 @@ 0 V* + C*
@@ -106,6 +115,7 @@ +
@@ -143,6 +153,58 @@ +
+
+
+
Add Content
+
Pick a placement anchor
+
+ +
+

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

+ +
+ Proposed block +
+ + + + +
+ + + + + + + + + + + + + + + +
+ + +
+
+
`; document.body.appendChild(feedbackPanel); @@ -155,8 +217,10 @@ 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() { @@ -192,6 +256,26 @@ 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`; @@ -317,6 +401,12 @@ 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(); @@ -364,7 +454,7 @@ if (interactionMode === nextMode) { return true; } - if (visualBusy && visualSession) { + if ((visualBusy && visualSession) || contentBusy) { showNotification('Wait for the current visual evidence capture to finish.', 'error'); return false; } @@ -380,10 +470,22 @@ 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(); @@ -391,6 +493,7 @@ enableElementHighlighting(); } renderVisualInspector(); + renderContentInspector(); scheduleDecorationRefresh(); if (nextMode !== INTERACTION_MODES.OFF) { window.requestAnimationFrame(() => { @@ -405,7 +508,7 @@ } function stopInteractionMode() { - setInteractionMode(INTERACTION_MODES.OFF, { discardVisual: true }); + setInteractionMode(INTERACTION_MODES.OFF, { discardVisual: true, discardContent: true }); } function startVisualEditMode() { @@ -428,6 +531,28 @@ 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 }; + } + function enableElementHighlighting() { disableElementHighlighting(); document.addEventListener('mouseover', handleMouseOver); @@ -446,7 +571,7 @@ } function handleMouseOver(event) { - if (interactionMode === INTERACTION_MODES.OFF || interactionMode === INTERACTION_MODES.VISUAL_EDIT || isOurElement(event.target)) { + if (interactionMode === INTERACTION_MODES.OFF || interactionMode === INTERACTION_MODES.VISUAL_EDIT || interactionMode === INTERACTION_MODES.CONTENT_EDIT || isOurElement(event.target)) { return; } @@ -454,7 +579,7 @@ } function handleMouseOut(event) { - if (interactionMode === INTERACTION_MODES.OFF || interactionMode === INTERACTION_MODES.VISUAL_EDIT || isOurElement(event.target)) { + if (interactionMode === INTERACTION_MODES.OFF || interactionMode === INTERACTION_MODES.VISUAL_EDIT || interactionMode === INTERACTION_MODES.CONTENT_EDIT || isOurElement(event.target)) { return; } @@ -462,7 +587,7 @@ } function handleElementClick(event) { - if (interactionMode === INTERACTION_MODES.OFF || interactionMode === INTERACTION_MODES.VISUAL_EDIT) { + if (interactionMode === INTERACTION_MODES.OFF || interactionMode === INTERACTION_MODES.VISUAL_EDIT || interactionMode === INTERACTION_MODES.CONTENT_EDIT) { return; } @@ -477,6 +602,8 @@ captureElement(target); } else if (interactionMode === INTERACTION_MODES.VISUAL_PICK) { selectVisualTarget(target); + } else if (interactionMode === INTERACTION_MODES.CONTENT_PICK) { + selectContentAnchor(target); } } @@ -487,7 +614,8 @@ (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(SELECTORS.markerLayer)) || + (typeof element.closest === 'function' && element.closest('[data-dev-feedback-content-preview]')) ) ); } @@ -545,8 +673,308 @@ } } + 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) { - const snapshot = buildElementSnapshot(element); + return toMutationTargetSnapshot(buildElementSnapshot(element)); + } + + function toMutationTargetSnapshot(snapshot) { return { selectors: snapshot.selectors, tag: snapshot.tag, @@ -659,28 +1087,35 @@ } function restoreVisualOnPageExit() { - if (!visualSession) { + if (!visualSession && !contentPreview) { return; } restoreVisualSession(); + restoreContentProposal(); interactionMode = INTERACTION_MODES.OFF; feedbackMode = false; disableElementHighlighting(); if (feedbackPanel) { - feedbackPanel.classList.remove('visible', 'visual-active'); + feedbackPanel.classList.remove('visible', 'visual-active', 'content-active'); } } function restoreVisualAfterSameDocumentNavigation() { - if (!visualSession) { + if (!visualSession && !contentPreview) { return; } + const wasContent = Boolean(contentPreview); restoreVisualSession(); - interactionMode = INTERACTION_MODES.VISUAL_PICK; + restoreContentProposal(); + interactionMode = wasContent ? INTERACTION_MODES.CONTENT_PICK : INTERACTION_MODES.VISUAL_PICK; feedbackMode = false; - feedbackPanel.classList.add('visible', 'visual-active'); + feedbackPanel.classList.add('visible', wasContent ? 'content-active' : 'visual-active'); setPanelCollapsed(true); - renderVisualInspector('Page navigation restored the preview. Pick an element again.'); + if (wasContent) { + renderContentInspector('Page navigation restored the preview. Pick an anchor again.'); + } else { + renderVisualInspector('Page navigation restored the preview. Pick an element again.'); + } scheduleDecorationRefresh(); } @@ -1231,7 +1666,7 @@ } async function startRegionCapture() { - if (visualBusy) { + if (visualBusy || contentBusy) { const reason = 'Wait for the current visual evidence capture to finish.'; showNotification(reason, 'error'); return { ok: false, reason }; @@ -1241,8 +1676,15 @@ 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) => { @@ -1367,7 +1809,10 @@ function populateElementItem(itemElement, item) { const selector = document.createElement('div'); selector.className = 'dev-feedback-item-selector'; - selector.textContent = item.selector; + const insertMutation = item.changeRequest?.requestedMutations?.find((mutation) => mutation.action === 'insert'); + selector.textContent = insertMutation + ? `Add ${insertMutation.parameters.content?.type || 'content'} ${insertMutation.parameters.placement || 'after'} ${item.selector}` + : item.selector; itemElement.appendChild(selector); } @@ -1922,6 +2367,11 @@ 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 new file mode 100644 index 0000000..13639a1 --- /dev/null +++ b/docs/add-content-mode-plan.html @@ -0,0 +1,195 @@ + + + + + + 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/history.js b/history.js index 788e7b7..eeb5db0 100644 --- a/history.js +++ b/history.js @@ -540,6 +540,12 @@ || target.tag || target.text || formatMutationRect(target.rect); + if (mutation?.action === 'insert') { + const content = mutation.parameters?.content || {}; + const placement = mutation.parameters?.placement || 'after'; + const details = [content.title, content.body, content.support].filter(Boolean).join(' | '); + return `insert ${content.type || 'content'} ${placement} ${identity || 'unknown target'}${details ? `; ${details}` : ''}`; + } const parameters = mutation?.parameters && Object.keys(mutation.parameters).length ? `; parameters=${JSON.stringify(mutation.parameters)}` : ''; diff --git a/popup.html b/popup.html index 6f292f0..594ac09 100644 --- a/popup.html +++ b/popup.html @@ -125,7 +125,7 @@ .mode-switch { display: grid; - grid-template-columns: repeat(auto-fit, minmax(92px, 1fr)); + grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; padding: 4px; border: 1px solid var(--line); @@ -390,6 +390,13 @@

Dev Feedback Capture

Drag and resize one selected element. +