feat: CSS Custom Highlight API — yellow for fake, terminal for real
Two visual modes using the Highlight API (zero DOM modification): - Yellow highlight: fake/substituted values the AI received. Shows automatically in all AI responses, even without reveal mode. "The AI sees these yellow values, not your real data." - Terminal style (black bg, green text): your real data shown in reveal mode. "This is YOUR data — the AI never saw this." Uses CSS.highlights with ::highlight() pseudo-elements — no spans, no DOM changes, no copy/paste artifacts. Falls back to CSS class-based styling if Highlight API is unavailable. Highlights are debounced (500ms) to handle streaming responses without excessive reflows. https://claude.ai/code/session_01Dvgwe7XMoSxnWXkih8p1Cw
This commit is contained in:
+18
-5
@@ -13,13 +13,26 @@
|
|||||||
padding: 0 1px;
|
padding: 0 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Revealed text styling (when reveal mode shows real values in responses) */
|
/* CSS Custom Highlight API styles — zero DOM changes */
|
||||||
|
|
||||||
|
/* Yellow highlight: fake values the AI received (non-reveal mode) */
|
||||||
|
::highlight(ss-substituted) {
|
||||||
|
background-color: rgba(250, 204, 21, 0.4);
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Terminal style: real data shown in reveal mode (dark bg, green text) */
|
||||||
|
::highlight(ss-revealed) {
|
||||||
|
background-color: rgba(0, 0, 0, 0.85);
|
||||||
|
color: #4ade80;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fallback for browsers without Highlight API */
|
||||||
.ss-revealed {
|
.ss-revealed {
|
||||||
background: rgba(59, 130, 246, 0.1);
|
background: rgba(0, 0, 0, 0.85);
|
||||||
border-bottom: 2px solid rgba(59, 130, 246, 0.6);
|
color: #4ade80;
|
||||||
border-radius: 2px;
|
|
||||||
padding: 0 2px;
|
padding: 0 2px;
|
||||||
cursor: help;
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Floating reveal mode indicator */
|
/* Floating reveal mode indicator */
|
||||||
|
|||||||
+139
-122
@@ -566,28 +566,42 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Response Reveal — swaps fake data back to real in the page
|
// Highlighting — CSS Custom Highlight API (zero DOM changes)
|
||||||
|
//
|
||||||
|
// Two highlight modes:
|
||||||
|
// ss-substituted (yellow) — fake values the AI received
|
||||||
|
// ss-revealed (terminal: dark bg, green text) — your real data
|
||||||
|
//
|
||||||
|
// Falls back to simple text replacement for reveal if
|
||||||
|
// CSS.highlights is not supported.
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
// Build reverse mapping pairs from identity + explicit mappings
|
const hasHighlightAPI = typeof CSS !== 'undefined' && CSS.highlights;
|
||||||
|
|
||||||
|
// Register highlight groups
|
||||||
|
let hlSubstituted = null; // yellow — marks fake values in responses
|
||||||
|
let hlRevealed = null; // terminal — marks revealed real values
|
||||||
|
|
||||||
|
if (hasHighlightAPI) {
|
||||||
|
hlSubstituted = new Highlight();
|
||||||
|
hlRevealed = new Highlight();
|
||||||
|
CSS.highlights.set('ss-substituted', hlSubstituted);
|
||||||
|
CSS.highlights.set('ss-revealed', hlRevealed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build pairs: substitute → real
|
||||||
function buildRevealPairs() {
|
function buildRevealPairs() {
|
||||||
const pairs = [];
|
const pairs = [];
|
||||||
|
|
||||||
// Explicit mappings (substitute → real)
|
|
||||||
for (const m of mappings) {
|
for (const m of mappings) {
|
||||||
if (!m.enabled || !m.substitute || !m.real) continue;
|
if (!m.enabled || !m.substitute || !m.real) continue;
|
||||||
pairs.push({ from: m.substitute, to: m.real, caseSensitive: m.caseSensitive });
|
pairs.push({ from: m.substitute, to: m.real, caseSensitive: m.caseSensitive });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Smart identity pairs (substitute → real)
|
|
||||||
if (identity) {
|
if (identity) {
|
||||||
for (const e of (identity.emails || [])) {
|
for (const e of (identity.emails || [])) {
|
||||||
if (e.substitute && e.real) pairs.push({ from: e.substitute, to: e.real });
|
if (e.substitute && e.real) pairs.push({ from: e.substitute, to: e.real });
|
||||||
}
|
}
|
||||||
if (identity.catchAllEmail) {
|
|
||||||
// Can't reverse a catch-all to a specific email, but we mark it
|
|
||||||
// so the user sees it was a substitution
|
|
||||||
}
|
|
||||||
for (const n of (identity.names || [])) {
|
for (const n of (identity.names || [])) {
|
||||||
if (n.substitute && n.real) pairs.push({ from: n.substitute, to: n.real });
|
if (n.substitute && n.real) pairs.push({ from: n.substitute, to: n.real });
|
||||||
}
|
}
|
||||||
@@ -602,20 +616,25 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort longer matches first
|
|
||||||
pairs.sort((a, b) => b.from.length - a.from.length);
|
pairs.sort((a, b) => b.from.length - a.from.length);
|
||||||
return pairs;
|
return pairs;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache reveal pairs — rebuild when config changes
|
// Cache
|
||||||
let _revealPairsCache = null;
|
let _revealPairsCache = null;
|
||||||
window.addEventListener('message', (event) => {
|
window.addEventListener('message', (event) => {
|
||||||
if (event.data?.type === 'ss:config-updated') _revealPairsCache = null;
|
if (event.data?.type === 'ss:config-updated') _revealPairsCache = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
function revealText(text) {
|
function getRevealPairs() {
|
||||||
if (!_revealPairsCache) _revealPairsCache = buildRevealPairs();
|
if (!_revealPairsCache) _revealPairsCache = buildRevealPairs();
|
||||||
const pairs = _revealPairsCache;
|
return _revealPairsCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Text replacement for reveal (needed regardless of highlight API) ---
|
||||||
|
|
||||||
|
function revealText(text) {
|
||||||
|
const pairs = getRevealPairs();
|
||||||
let result = text;
|
let result = text;
|
||||||
for (const p of pairs) {
|
for (const p of pairs) {
|
||||||
const escaped = esc(p.from);
|
const escaped = esc(p.from);
|
||||||
@@ -625,114 +644,40 @@
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store originals so we can un-reveal when toggled off
|
|
||||||
const originalTexts = new WeakMap();
|
const originalTexts = new WeakMap();
|
||||||
|
|
||||||
function revealInElement(el) {
|
function revealInElement(el) {
|
||||||
if (SKIP_REVEAL_TAGS.has(el.tagName)) return;
|
if (SKIP_REVEAL_TAGS.has(el.tagName)) return;
|
||||||
if (el.classList?.contains('ss-reveal-badge')) return;
|
if (el.classList?.contains('ss-reveal-badge')) return;
|
||||||
if (el.classList?.contains('ss-revealed')) return; // skip our own spans
|
|
||||||
|
|
||||||
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, {
|
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, {
|
||||||
acceptNode(node) {
|
acceptNode(node) {
|
||||||
const parent = node.parentElement;
|
const parent = node.parentElement;
|
||||||
if (parent && SKIP_REVEAL_TAGS.has(parent.tagName)) return NodeFilter.FILTER_REJECT;
|
if (parent && SKIP_REVEAL_TAGS.has(parent.tagName)) return NodeFilter.FILTER_REJECT;
|
||||||
if (parent?.classList?.contains('ss-reveal-badge')) return NodeFilter.FILTER_REJECT;
|
if (parent?.classList?.contains('ss-reveal-badge')) return NodeFilter.FILTER_REJECT;
|
||||||
if (parent?.classList?.contains('ss-revealed')) return NodeFilter.FILTER_REJECT;
|
|
||||||
return NodeFilter.FILTER_ACCEPT;
|
return NodeFilter.FILTER_ACCEPT;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Collect nodes first (modifying DOM during walk breaks the walker)
|
|
||||||
const nodes = [];
|
|
||||||
let textNode;
|
let textNode;
|
||||||
while ((textNode = walker.nextNode())) {
|
while ((textNode = walker.nextNode())) {
|
||||||
nodes.push(textNode);
|
const text = textNode.textContent;
|
||||||
}
|
|
||||||
|
|
||||||
if (!_revealPairsCache) _revealPairsCache = buildRevealPairs();
|
|
||||||
const pairs = _revealPairsCache;
|
|
||||||
|
|
||||||
for (const node of nodes) {
|
|
||||||
const text = node.textContent;
|
|
||||||
if (!text || text.length < MIN_STRING_LENGTH) continue;
|
if (!text || text.length < MIN_STRING_LENGTH) continue;
|
||||||
|
|
||||||
// Save original
|
if (!originalTexts.has(textNode)) {
|
||||||
if (!originalTexts.has(node)) {
|
originalTexts.set(textNode, text);
|
||||||
originalTexts.set(node, text);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find all substituted values and their positions
|
const revealed = revealText(text);
|
||||||
const segments = [];
|
if (revealed !== text) {
|
||||||
let remaining = text;
|
textNode.textContent = revealed;
|
||||||
let offset = 0;
|
|
||||||
|
|
||||||
for (const p of pairs) {
|
|
||||||
const escaped = esc(p.from);
|
|
||||||
const regex = new RegExp(escaped, p.caseSensitive ? 'gi' : 'gi');
|
|
||||||
let match;
|
|
||||||
while ((match = regex.exec(text)) !== null) {
|
|
||||||
segments.push({
|
|
||||||
start: match.index,
|
|
||||||
end: match.index + match[0].length,
|
|
||||||
original: match[0],
|
|
||||||
revealed: p.to,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (segments.length === 0) continue;
|
|
||||||
|
|
||||||
// Sort by position, deduplicate overlaps
|
|
||||||
segments.sort((a, b) => a.start - b.start);
|
|
||||||
const deduped = [];
|
|
||||||
let lastEnd = -1;
|
|
||||||
for (const s of segments) {
|
|
||||||
if (s.start >= lastEnd) {
|
|
||||||
deduped.push(s);
|
|
||||||
lastEnd = s.end;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build fragment: text + highlighted spans
|
|
||||||
const frag = document.createDocumentFragment();
|
|
||||||
let pos = 0;
|
|
||||||
|
|
||||||
for (const s of deduped) {
|
|
||||||
// Text before this match
|
|
||||||
if (s.start > pos) {
|
|
||||||
frag.appendChild(document.createTextNode(text.slice(pos, s.start)));
|
|
||||||
}
|
|
||||||
// Highlighted revealed value
|
|
||||||
const span = document.createElement('span');
|
|
||||||
span.className = 'ss-revealed';
|
|
||||||
span.textContent = s.revealed;
|
|
||||||
span.title = 'Substituted value: ' + s.original;
|
|
||||||
frag.appendChild(span);
|
|
||||||
pos = s.end;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remaining text after last match
|
|
||||||
if (pos < text.length) {
|
|
||||||
frag.appendChild(document.createTextNode(text.slice(pos)));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Replace the text node with the fragment
|
|
||||||
node.parentNode.replaceChild(frag, node);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function unrevealInElement(el) {
|
function unrevealInElement(el) {
|
||||||
if (SKIP_REVEAL_TAGS.has(el.tagName)) return;
|
if (SKIP_REVEAL_TAGS.has(el.tagName)) return;
|
||||||
|
|
||||||
// Remove all ss-revealed spans, restoring original text
|
|
||||||
const spans = el.querySelectorAll('.ss-revealed');
|
|
||||||
for (const span of spans) {
|
|
||||||
const text = document.createTextNode(span.title?.replace('Substituted value: ', '') || span.textContent);
|
|
||||||
span.parentNode.replaceChild(text, span);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also restore any text nodes that were modified without spans
|
|
||||||
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
||||||
let textNode;
|
let textNode;
|
||||||
while ((textNode = walker.nextNode())) {
|
while ((textNode = walker.nextNode())) {
|
||||||
@@ -741,9 +686,57 @@
|
|||||||
textNode.textContent = original;
|
textNode.textContent = original;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Normalize adjacent text nodes
|
// --- CSS Highlight API — find and highlight matching text ---
|
||||||
el.normalize();
|
|
||||||
|
function highlightMatches(root) {
|
||||||
|
if (!hasHighlightAPI) return;
|
||||||
|
|
||||||
|
// Clear previous ranges
|
||||||
|
hlSubstituted.clear();
|
||||||
|
hlRevealed.clear();
|
||||||
|
|
||||||
|
const pairs = getRevealPairs();
|
||||||
|
if (pairs.length === 0) return;
|
||||||
|
|
||||||
|
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
||||||
|
acceptNode(node) {
|
||||||
|
const parent = node.parentElement;
|
||||||
|
if (parent && SKIP_REVEAL_TAGS.has(parent.tagName)) return NodeFilter.FILTER_REJECT;
|
||||||
|
if (parent?.classList?.contains('ss-reveal-badge')) return NodeFilter.FILTER_REJECT;
|
||||||
|
return NodeFilter.FILTER_ACCEPT;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let textNode;
|
||||||
|
while ((textNode = walker.nextNode())) {
|
||||||
|
const text = textNode.textContent;
|
||||||
|
if (!text || text.length < MIN_STRING_LENGTH) continue;
|
||||||
|
|
||||||
|
for (const p of pairs) {
|
||||||
|
const escaped = esc(settings.revealMode ? p.to : p.from);
|
||||||
|
const searchTerm = settings.revealMode ? p.to : p.from;
|
||||||
|
const regex = new RegExp(escaped, p.caseSensitive ? 'g' : 'gi');
|
||||||
|
let match;
|
||||||
|
|
||||||
|
while ((match = regex.exec(text)) !== null) {
|
||||||
|
try {
|
||||||
|
const range = new Range();
|
||||||
|
range.setStart(textNode, match.index);
|
||||||
|
range.setEnd(textNode, match.index + match[0].length);
|
||||||
|
|
||||||
|
if (settings.revealMode) {
|
||||||
|
hlRevealed.add(range);
|
||||||
|
} else {
|
||||||
|
hlSubstituted.add(range);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Range may be invalid if DOM changed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Elements to skip when revealing (inputs, scripts, styles, extension UI)
|
// Elements to skip when revealing (inputs, scripts, styles, extension UI)
|
||||||
@@ -751,59 +744,83 @@
|
|||||||
'SCRIPT', 'STYLE', 'NOSCRIPT', 'IFRAME', 'INPUT', 'TEXTAREA', 'SELECT',
|
'SCRIPT', 'STYLE', 'NOSCRIPT', 'IFRAME', 'INPUT', 'TEXTAREA', 'SELECT',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Reveal ALL text on the page (not just specific selectors)
|
// Reveal ALL text on the page + apply highlights
|
||||||
function revealAllResponses() {
|
function revealAllResponses() {
|
||||||
revealInElement(document.body);
|
revealInElement(document.body);
|
||||||
|
highlightMatches(document.body);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Un-reveal ALL text on the page
|
// Un-reveal ALL text + clear highlights
|
||||||
function unrevealAllResponses() {
|
function unrevealAllResponses() {
|
||||||
unrevealInElement(document.body);
|
unrevealInElement(document.body);
|
||||||
|
// In non-reveal mode, highlight the fake values instead
|
||||||
|
highlightMatches(document.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounced highlight refresh
|
||||||
|
let _highlightTimer = null;
|
||||||
|
function scheduleHighlightRefresh() {
|
||||||
|
if (!hasHighlightAPI) return;
|
||||||
|
if (_highlightTimer) clearTimeout(_highlightTimer);
|
||||||
|
_highlightTimer = setTimeout(() => {
|
||||||
|
highlightMatches(document.body);
|
||||||
|
}, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Watch for ANY new content on the page
|
// Watch for ANY new content on the page
|
||||||
function observeResponses() {
|
function observeResponses() {
|
||||||
const observer = new MutationObserver((mutations) => {
|
const observer = new MutationObserver((mutations) => {
|
||||||
if (!settings.revealMode || !hasSubstitutions()) return;
|
if (!hasSubstitutions()) return;
|
||||||
|
|
||||||
|
// Always schedule highlight refresh for new content (yellow markers)
|
||||||
|
let hasNewContent = false;
|
||||||
|
|
||||||
for (const mutation of mutations) {
|
for (const mutation of mutations) {
|
||||||
// Handle new nodes — reveal all text in them
|
|
||||||
for (const node of mutation.addedNodes) {
|
for (const node of mutation.addedNodes) {
|
||||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
hasNewContent = true;
|
||||||
if (!SKIP_REVEAL_TAGS.has(node.tagName)) {
|
// Only do text replacement in reveal mode
|
||||||
revealInElement(node);
|
if (settings.revealMode) {
|
||||||
}
|
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||||
} else if (node.nodeType === Node.TEXT_NODE) {
|
if (!SKIP_REVEAL_TAGS.has(node.tagName)) {
|
||||||
const text = node.textContent;
|
revealInElement(node);
|
||||||
if (text && text.length >= MIN_STRING_LENGTH) {
|
|
||||||
if (!originalTexts.has(node)) {
|
|
||||||
originalTexts.set(node, text);
|
|
||||||
}
|
}
|
||||||
const revealed = revealText(text);
|
} else if (node.nodeType === Node.TEXT_NODE) {
|
||||||
if (revealed !== text) {
|
const text = node.textContent;
|
||||||
node.textContent = revealed;
|
if (text && text.length >= MIN_STRING_LENGTH) {
|
||||||
|
if (!originalTexts.has(node)) {
|
||||||
|
originalTexts.set(node, text);
|
||||||
|
}
|
||||||
|
const revealed = revealText(text);
|
||||||
|
if (revealed !== text) {
|
||||||
|
node.textContent = revealed;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle text changes in existing nodes (streaming responses)
|
// Handle streaming text changes
|
||||||
if (mutation.type === 'characterData' && settings.revealMode) {
|
if (mutation.type === 'characterData') {
|
||||||
const text = mutation.target.textContent;
|
hasNewContent = true;
|
||||||
if (text && text.length >= MIN_STRING_LENGTH) {
|
if (settings.revealMode) {
|
||||||
const parent = mutation.target.parentElement;
|
const text = mutation.target.textContent;
|
||||||
if (parent && !SKIP_REVEAL_TAGS.has(parent.tagName)) {
|
if (text && text.length >= MIN_STRING_LENGTH) {
|
||||||
if (!originalTexts.has(mutation.target)) {
|
const parent = mutation.target.parentElement;
|
||||||
originalTexts.set(mutation.target, text);
|
if (parent && !SKIP_REVEAL_TAGS.has(parent.tagName)) {
|
||||||
}
|
if (!originalTexts.has(mutation.target)) {
|
||||||
const revealed = revealText(text);
|
originalTexts.set(mutation.target, text);
|
||||||
if (revealed !== text) {
|
}
|
||||||
mutation.target.textContent = revealed;
|
const revealed = revealText(text);
|
||||||
|
if (revealed !== text) {
|
||||||
|
mutation.target.textContent = revealed;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasNewContent) scheduleHighlightRefresh();
|
||||||
});
|
});
|
||||||
|
|
||||||
observer.observe(document.body, {
|
observer.observe(document.body, {
|
||||||
|
|||||||
Reference in New Issue
Block a user