diff --git a/web/src/lib/components/editor/Editor.svelte b/web/src/lib/components/editor/Editor.svelte index 5b1ecd81..7b566b8e 100644 --- a/web/src/lib/components/editor/Editor.svelte +++ b/web/src/lib/components/editor/Editor.svelte @@ -1385,7 +1385,79 @@ border-color: var(--accent-blue); } - + /* Hidden-content authoring warning (TASK-1327 / PLAN-1322) */ + .editor-content :global(.html-block-warning) { + display: block; + width: 100%; + text-align: left; + padding: var(--space-2) var(--space-3); + background: var(--bg-warning, rgba(255, 180, 50, 0.12)); + border: none; + border-bottom: 1px solid var(--border); + color: var(--accent-orange, #d68a3a); + font-size: 0.85em; + font-family: var(--font-mono); + cursor: pointer; + transition: background 0.12s; + } + .editor-content :global(.html-block-warning:hover) { + background: var(--bg-warning-hover, rgba(255, 180, 50, 0.22)); + } + .editor-content :global(.html-block-inspector) { + padding: var(--space-3); + border-top: 1px solid var(--border); + background: var(--bg-secondary); + font-size: 0.9em; + } + .editor-content :global(.html-block-inspector-heading) { + font-weight: 600; + color: var(--text-primary); + margin-bottom: var(--space-2); + } + .editor-content :global(.html-block-inspector-list) { + list-style: none; + padding: 0; + margin: 0 0 var(--space-2) 0; + } + .editor-content :global(.html-block-inspector-item) { + padding: var(--space-2) 0; + border-bottom: 1px solid var(--border); + } + .editor-content :global(.html-block-inspector-item:last-child) { + border-bottom: none; + } + .editor-content :global(.html-block-inspector-rule) { + color: var(--text-secondary); + } + .editor-content :global(.html-block-inspector-rule code) { + color: var(--accent-blue); + font-family: var(--font-mono); + font-size: 0.95em; + } + .editor-content :global(.html-block-inspector-snippet) { + margin-top: 4px; + padding: 4px var(--space-2); + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 0.85em; + background: var(--bg-tertiary); + border-radius: var(--radius-sm); + overflow-wrap: anywhere; + } + .editor-content :global(.html-block-dismiss) { + padding: 4px 12px; + font-size: 0.85em; + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-secondary); + cursor: pointer; + transition: color 0.12s, border-color 0.12s; + } + .editor-content :global(.html-block-dismiss:hover) { + color: var(--text-primary); + border-color: var(--accent-blue); + } /* Mobile keyboard toolbar */ .mobile-toolbar { diff --git a/web/src/lib/components/editor/extensions/htmlBlock.ts b/web/src/lib/components/editor/extensions/htmlBlock.ts index d8c5c64b..c7e86c3e 100644 --- a/web/src/lib/components/editor/extensions/htmlBlock.ts +++ b/web/src/lib/components/editor/extensions/htmlBlock.ts @@ -19,6 +19,12 @@ import { InputRule, Node, type Editor } from '@tiptap/core'; import type MarkdownIt from 'markdown-it'; import type { Node as ProseMirrorNode } from '@tiptap/pm/model'; import { sanitizeHtmlBlock } from '$lib/utils/markdown'; +import { + detectHiddenContent, + isHiddenContentAcknowledged, + setHiddenContentAcknowledged, + type HiddenSegment, +} from '$lib/utils/hiddenContentDetector'; /** A single htmlBlock node's identity for snapshot comparison. */ export interface HtmlBlockSnapshotEntry { @@ -219,10 +225,91 @@ export const HtmlBlock = Node.create({ actions.append(doneBtn); source.append(textarea, actions); - wrapper.append(preview, source); + + // Hidden-content authoring warning (TASK-1327). The pill is + // always created but only revealed when detectHiddenContent + // finds segments AND the user hasn't dismissed for this block. + const warning = document.createElement('button'); + warning.type = 'button'; + warning.className = 'html-block-warning'; + warning.hidden = true; + warning.title = 'Click to inspect hidden segments in this block'; + + const inspector = document.createElement('div'); + inspector.className = 'html-block-inspector'; + inspector.hidden = true; + + wrapper.append(warning, preview, source, inspector); let lastHtml = (node.attrs.html as string | undefined) ?? ''; let mode: 'preview' | 'source' = 'preview'; + let lastSegments: HiddenSegment[] = []; + + const renderInspector = () => { + if (lastSegments.length === 0) { + inspector.replaceChildren(); + return; + } + const heading = document.createElement('div'); + heading.className = 'html-block-inspector-heading'; + heading.textContent = `${lastSegments.length} hidden segment${lastSegments.length === 1 ? '' : 's'}`; + + const list = document.createElement('ul'); + list.className = 'html-block-inspector-list'; + for (const seg of lastSegments) { + const item = document.createElement('li'); + item.className = 'html-block-inspector-item'; + const label = document.createElement('div'); + label.className = 'html-block-inspector-rule'; + const tagSpan = document.createElement('code'); + tagSpan.textContent = seg.tag; + label.append(tagSpan, document.createTextNode(` — ${seg.rule}`)); + item.appendChild(label); + if (seg.snippet) { + const snip = document.createElement('div'); + snip.className = 'html-block-inspector-snippet'; + snip.textContent = `"${seg.snippet}"`; + item.appendChild(snip); + } + list.appendChild(item); + } + + const dismiss = document.createElement('button'); + dismiss.type = 'button'; + dismiss.className = 'html-block-dismiss'; + dismiss.textContent = 'Dismiss for this block'; + dismiss.title = 'Mark these segments as reviewed; warning will not re-appear'; + // mousedown.preventDefault keeps focus where it was so the + // click handler runs in-context. + dismiss.addEventListener('mousedown', (e) => e.preventDefault()); + dismiss.addEventListener('click', (e) => { + e.preventDefault(); + if (!editor.isEditable) return; + const pos = typeof getPos === 'function' ? getPos() : null; + if (typeof pos !== 'number') return; + const next = setHiddenContentAcknowledged(lastHtml); + if (next === lastHtml) return; + editor.view.dispatch( + editor.view.state.tr.setNodeMarkup(pos, undefined, { html: next }), + ); + }); + + inspector.replaceChildren(heading, list, dismiss); + }; + + const updateWarning = () => { + lastSegments = detectHiddenContent(lastHtml); + const acked = isHiddenContentAcknowledged(lastHtml); + if (lastSegments.length === 0 || acked) { + warning.hidden = true; + inspector.hidden = true; + wrapper.classList.remove('html-block--has-hidden'); + return; + } + warning.textContent = `⚠ ${lastSegments.length} hidden segment${lastSegments.length === 1 ? '' : 's'} — click to inspect`; + warning.hidden = false; + wrapper.classList.add('html-block--has-hidden'); + }; const renderPreview = () => { if (!lastHtml.trim()) { @@ -234,9 +321,17 @@ export const HtmlBlock = Node.create({ } else { preview.innerHTML = sanitizeHtmlBlock(lastHtml); } + updateWarning(); + renderInspector(); }; renderPreview(); + warning.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + inspector.hidden = !inspector.hidden; + }); + function flipToSource() { if (mode === 'source') return; mode = 'source'; diff --git a/web/src/lib/utils/hiddenContentDetector.ts b/web/src/lib/utils/hiddenContentDetector.ts new file mode 100644 index 00000000..4733cfd6 --- /dev/null +++ b/web/src/lib/utils/hiddenContentDetector.ts @@ -0,0 +1,247 @@ +/** + * Hidden-content detector for HTML blocks (TASK-1327 / PLAN-1322). + * + * Walks an HTML string looking for elements + comments that are visible + * to LLMs / agents reading the document but invisible (or near-invisible) + * to a human author reviewing the rendered preview. The detector is an + * **authoring-honesty** feature — NOT a security control. Render-time + * sanitization (sanitizeHtmlBlock) protects browsers; this protects + * authors from accidentally pasting content that looks one way and + * reads another. + * + * Heuristics flag content that is one of: + * - CSS-hidden via inline style (display:none, visibility:hidden, + * opacity:0, font-size:0 or very small, color === background-color) + * - Off-screen positioned (position:absolute with very negative + * left/right/top/bottom, transform translate to off-screen) + * - Zero-dimension (width:0 AND height:0, clip:rect(0,0,0,0)) + * - HTML comments + * - Suspiciously long aria-label / alt / title (>200 chars or + * containing newlines — heuristic for hidden text in attribute + * values) + * + * Class-based hiding (e.g. `.sr-only`) is NOT flagged: resolving it + * requires the page's stylesheet context, which we don't have here, + * and the false-positive rate would be too high. The detector stays + * conservative. + */ + +/** + * The Pad-internal sentinel used by `setHiddenContentAcknowledged` to + * mark that a human has reviewed and accepted the hidden content. + * `detectHiddenContent` skips this exact comment so dismissal sticks + * after a doc reload. + */ +export const PAD_ACK_HIDDEN_MARKER = ''; + +const PAD_ACK_HIDDEN_TEXT = ' pad:ack-hidden '; // textContent of the marker (with surrounding spaces) +const PAD_ACK_HIDDEN_TEXT_TRIMMED = 'pad:ack-hidden'; + +export interface HiddenSegment { + /** Lowercase tag name of the offending element, or `#comment`. */ + tag: string; + /** Short human-readable rule label, e.g. `display:none` or `font-size:2px (too small)`. */ + rule: string; + /** Up to 80 chars of trimmed text content, for context in the inspector. */ + snippet: string; +} + +/** + * Returns true when the user has previously dismissed the hidden-content + * warning for this block. The marker lives at the top of the html string + * so it's the first thing read on parse and survives markdown round-trips + * (it's just an HTML comment, valid in any context where raw HTML is + * permitted). + */ +export function isHiddenContentAcknowledged(html: string): boolean { + return html.trimStart().startsWith(PAD_ACK_HIDDEN_MARKER); +} + +/** + * Prepend the ack marker if not already present. Idempotent. Used by the + * NodeView's "Dismiss for this block" affordance. + */ +export function setHiddenContentAcknowledged(html: string): string { + if (isHiddenContentAcknowledged(html)) return html; + const sep = html.startsWith('\n') || html === '' ? '' : '\n'; + return `${PAD_ACK_HIDDEN_MARKER}${sep}${html}`; +} + +/** + * Detect hidden content in an HTML block's raw source. Returns one + * `HiddenSegment` per offending element / attribute / comment. Returns + * an empty array when running outside a browser (no DOMParser) — the + * detector is purely client-side. + */ +export function detectHiddenContent(html: string): HiddenSegment[] { + if (typeof window === 'undefined' || typeof DOMParser === 'undefined') return []; + if (!html) return []; + + const segments: HiddenSegment[] = []; + const doc = new DOMParser().parseFromString(html, 'text/html'); + + // Walk all elements regardless of whether the parser placed them + // inside body (the typical case) or moved them to head (e.g.