mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-21 18:13:26 +00:00
feat(editor): hidden-content detector + non-blocking authoring warning (TASK-1327) (#477)
* feat(editor): hidden-content detector + non-blocking authoring warning (TASK-1327)
Adds an authoring-honesty feature for HTML blocks: when the user pastes
or types raw HTML containing content that's invisible (or
near-invisible) to humans but readable by LLMs / agents, surface a
non-blocking warning pill above the block. Click the pill to expand an
inspector listing each hidden segment with the rule that flagged it
plus a snippet for context. "Dismiss for this block" button persists
acknowledgement via a sentinel HTML comment marker so the warning
doesn't re-fire after a doc reload.
NOT a security control. Render-time sanitization (sanitizeHtmlBlock,
TASK-1323) protects browsers from XSS. This protects authors from
unconsciously shipping content that looks one way and reads another
— common with copy-pasted HTML blobs that contain steganography
channels (display:none divs, white-on-white text, font-size:0,
hidden HTML comments, off-screen positioning, suspiciously long
aria-label / alt / title values).
## Detector
`web/src/lib/utils/hiddenContentDetector.ts` (NEW). Pure function
`detectHiddenContent(html: string): HiddenSegment[]`. Uses DOMParser
to walk the HTML tree:
- Inline-style heuristics:
- display:none, visibility:hidden, opacity:0/0%/0.0
- font-size with px value < 6
- color matches background-color (exact normalised match)
- position absolute/fixed with left/right/top/bottom <= -9000px
- transform translate to off-screen (heuristic regex on -9XXX or
-1XXXX values inside translateX/Y/translate)
- width:0 AND height:0
- clip:rect(0,0,0,0) — intentionally flagged; the user can dismiss
if it's deliberate sr-only positioning, but it's also a known
steganography channel
- HTML comments — every <!-- ... --> flags, EXCEPT the Pad-internal
ack marker (PAD_ACK_HIDDEN_MARKER) which the detector skips
- aria-label / alt / title attributes longer than 200 chars OR
containing newlines
Class-based hiding (e.g. .sr-only) is NOT flagged: resolving it
requires the page's stylesheet context, which we don't have, and the
false-positive rate is too high.
## NodeView UX
Warning pill ⚠ "N hidden segment(s) — click to inspect" appears above
the preview pane when segments > 0 AND the user hasn't dismissed for
this block. Click toggles the inspector panel below the source pane,
which lists each segment as `<code>tag</code> — rule` with the snippet
in a quoted monospace box. Dismiss button prepends
`<!-- pad:ack-hidden -->` to attrs.html via setNodeMarkup; the marker
survives markdown round-trip (it's a valid HTML comment) and the
detector skips it on subsequent runs.
The wrapper gets `.html-block--has-hidden` while the warning is
showing, in case any caller wants to react.
`updateWarning()` runs on every renderPreview call, so the warning
follows attrs.html changes (e.g. the user edits the block in source
mode and removes the hidden content — warning disappears immediately).
## Out of scope (future work)
- Class-based hiding detection (requires stylesheet resolution)
- Auto-stripping hidden content (this is an authoring honesty
feature, not a sanitizer; the user decides)
- Detection in non-HTML-block content (markdown comments, raw HTML
in markdown surface)
Parent: PLAN-1322.
* fix(detector): walk comments at doc root + normalize style values (Codex round 1)
* fix(detector): use CSSStyleDeclaration for browser-correct parsing (Codex round 2)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 = '<!-- pad:ack-hidden -->';
|
||||
|
||||
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. <style>
|
||||
// / <link> at the top of the snippet). We only check elements that
|
||||
// rendered content can hide behind, but element-level checks are
|
||||
// cheap so just walk the whole document.
|
||||
doc.querySelectorAll('*').forEach((el) => {
|
||||
// Skip <html>, <head>, <body> wrappers DOMParser inserts; they
|
||||
// never carry the inline-style hide patterns we look for.
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (tag === 'html' || tag === 'head' || tag === 'body') return;
|
||||
segments.push(...checkElement(el as HTMLElement));
|
||||
});
|
||||
|
||||
// Walk comments anywhere in the document. DOMParser can place
|
||||
// comments at document level (siblings of <html>) when the input
|
||||
// has a leading `<!-- ... -->`; walking only doc.body would miss
|
||||
// those. createTreeWalker on `doc` is the inclusive root.
|
||||
const walker = doc.createTreeWalker(doc, NodeFilter.SHOW_COMMENT);
|
||||
let node = walker.nextNode();
|
||||
while (node) {
|
||||
const text = node.textContent ?? '';
|
||||
const trimmed = text.trim();
|
||||
if (text !== PAD_ACK_HIDDEN_TEXT && trimmed !== PAD_ACK_HIDDEN_TEXT_TRIMMED) {
|
||||
segments.push({
|
||||
tag: '#comment',
|
||||
rule: 'HTML comment',
|
||||
snippet: snippetFor(text),
|
||||
});
|
||||
}
|
||||
node = walker.nextNode();
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
function checkElement(el: HTMLElement): HiddenSegment[] {
|
||||
const segments: HiddenSegment[] = [];
|
||||
const tag = el.tagName.toLowerCase();
|
||||
// `el.style` is the browser-parsed CSSStyleDeclaration for the
|
||||
// inline `style` attribute. The browser handles every CSS edge
|
||||
// case for us:
|
||||
//
|
||||
// - `!important` priority (display:none!important;display:block
|
||||
// leaves `el.style.display === 'none'`)
|
||||
// - CSS comments (display:/**/none parses correctly)
|
||||
// - case-insensitive keywords (DISPLAY: NONE → 'none')
|
||||
// - whitespace and unit normalisation
|
||||
//
|
||||
// A hand-rolled `style` attribute parser misses these consistently;
|
||||
// using the CSSOM here is materially more robust.
|
||||
const style = el.style;
|
||||
const text = el.textContent ?? '';
|
||||
|
||||
if (style.display === 'none') {
|
||||
segments.push({ tag, rule: 'display:none', snippet: snippetFor(text) });
|
||||
}
|
||||
if (style.visibility === 'hidden') {
|
||||
segments.push({ tag, rule: 'visibility:hidden', snippet: snippetFor(text) });
|
||||
}
|
||||
if (isZeroOpacity(style.opacity)) {
|
||||
segments.push({ tag, rule: `opacity:${style.opacity}`, snippet: snippetFor(text) });
|
||||
}
|
||||
const fontSize = parseLength(style.fontSize);
|
||||
if (fontSize !== null && fontSize < 6) {
|
||||
segments.push({
|
||||
tag,
|
||||
rule: `font-size:${style.fontSize} (too small)`,
|
||||
snippet: snippetFor(text),
|
||||
});
|
||||
}
|
||||
if (
|
||||
style.color &&
|
||||
style.backgroundColor &&
|
||||
normalizeColor(style.color) === normalizeColor(style.backgroundColor)
|
||||
) {
|
||||
segments.push({
|
||||
tag,
|
||||
rule: 'color matches background-color',
|
||||
snippet: snippetFor(text),
|
||||
});
|
||||
}
|
||||
if (style.position === 'absolute' || style.position === 'fixed') {
|
||||
for (const prop of ['left', 'right', 'top', 'bottom'] as const) {
|
||||
const value = style.getPropertyValue(prop);
|
||||
const offset = parseLength(value);
|
||||
if (offset !== null && offset <= -9000) {
|
||||
segments.push({
|
||||
tag,
|
||||
rule: `${prop}:${value} (off-screen)`,
|
||||
snippet: snippetFor(text),
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (style.transform && /translate[xy]?\s*\(\s*-?\d+/i.test(style.transform)) {
|
||||
const off = /-\s*9\d{3,}|-\s*\d{5,}/.test(style.transform);
|
||||
if (off) {
|
||||
segments.push({
|
||||
tag,
|
||||
rule: 'transform off-screen',
|
||||
snippet: snippetFor(text),
|
||||
});
|
||||
}
|
||||
}
|
||||
const w = parseLength(style.width);
|
||||
const h = parseLength(style.height);
|
||||
if (w === 0 && h === 0) {
|
||||
segments.push({ tag, rule: 'width:0;height:0', snippet: snippetFor(text) });
|
||||
}
|
||||
if (style.clip && /rect\(\s*0(?:px)?\s*,\s*0(?:px)?\s*,\s*0(?:px)?\s*,\s*0(?:px)?\s*\)/i.test(style.clip)) {
|
||||
segments.push({ tag, rule: 'clip:rect(0,0,0,0)', snippet: snippetFor(text) });
|
||||
}
|
||||
|
||||
for (const attr of ['aria-label', 'alt', 'title'] as const) {
|
||||
const val = el.getAttribute(attr);
|
||||
if (!val) continue;
|
||||
const tooLong = val.length > 200;
|
||||
const hasNewline = /\n/.test(val);
|
||||
if (tooLong || hasNewline) {
|
||||
const reasons: string[] = [];
|
||||
if (tooLong) reasons.push(`${val.length} chars`);
|
||||
if (hasNewline) reasons.push('contains newlines');
|
||||
segments.push({
|
||||
tag,
|
||||
rule: `${attr} suspicious (${reasons.join(', ')})`,
|
||||
snippet: snippetFor(val),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a CSS length declaration like `12px`, `0`, `-9999px`, `1.5em`
|
||||
* into a number when the unit is bare (`0`) or px. Returns `null` for
|
||||
* percentage / em / rem / unrecognised forms — those don't have a
|
||||
* single numeric "is this hidden" answer.
|
||||
*/
|
||||
function parseLength(value: string | undefined): number | null {
|
||||
if (!value) return null;
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === '0') return 0;
|
||||
const match = trimmed.match(/^(-?[\d.]+)px$/i);
|
||||
if (!match) return null;
|
||||
const n = parseFloat(match[1]);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function isZeroOpacity(value: string | undefined): boolean {
|
||||
if (!value) return false;
|
||||
const v = value.trim();
|
||||
return v === '0' || v === '0.0' || v === '0%' || v === '.0';
|
||||
}
|
||||
|
||||
function normalizeColor(c: string): string {
|
||||
return c.toLowerCase().replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
function snippetFor(text: string): string {
|
||||
const collapsed = text.trim().replace(/\s+/g, ' ');
|
||||
if (collapsed.length <= 80) return collapsed;
|
||||
return `${collapsed.slice(0, 77)}…`;
|
||||
}
|
||||
Reference in New Issue
Block a user