feat(web): add Inspect mode for live per-element style tuning

This commit is contained in:
Tom Huang 2026-05-07 16:40:30 +08:00 committed by GitHub
parent bb2015766a
commit 38eb78a382
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 2080 additions and 106 deletions

File diff suppressed because it is too large Load diff

View file

@ -27,6 +27,8 @@ export interface UrlLoadDecision {
isDeck: boolean;
/** Comment mode is active — needs the comment bridge. */
commentMode: boolean;
/** Inspect mode is active — needs the selection bridge for live tuning. */
inspectMode?: boolean;
/** User explicitly opted into the inline path via ?forceInline=1. */
forceInline: boolean;
}
@ -41,6 +43,9 @@ export function shouldUrlLoadHtmlPreview(d: UrlLoadDecision): boolean {
if (d.mode !== 'preview') return false;
if (d.isDeck) return false;
if (d.commentMode) return false;
// Inspect needs the selection bridge injected via buildSrcdoc; a raw
// URL-loaded iframe has no listener to apply per-element overrides.
if (d.inspectMode) return false;
if (d.forceInline) return false;
return true;
}

View file

@ -170,6 +170,8 @@ export const ar: Dict = {
'promptTemplates.emptyImage': 'لم يتم تثبيت قوالب صور بعد.',
'promptTemplates.emptyVideo': 'لم يتم تثبيت قوالب فيديو بعد.',
'promptTemplates.emptyNoMatch': 'لا توجد قوالب تطابق بحثك.',
'promptTemplates.allSources': 'جميع المصادر',
'promptTemplates.sourceFilterAria': 'تصفية حسب المصدر',
'promptTemplates.attributionFooter': 'مقتبس من مكتبات الأوامر العامة. كل بطاقة تشير إلى المؤلف الأصلي.',
'promptTemplates.openPreviewTitle': 'فتح الأمر والمعاينة',
'promptTemplates.sourcePrefix': 'المصدر:',

View file

@ -170,6 +170,8 @@ export const fr: Dict = {
'promptTemplates.emptyImage': 'Aucun modèle de prompt d\'image installé pour l\'instant.',
'promptTemplates.emptyVideo': 'Aucun modèle de prompt vidéo installé pour l\'instant.',
'promptTemplates.emptyNoMatch': 'Aucun modèle ne correspond à votre recherche.',
'promptTemplates.allSources': 'Toutes les sources',
'promptTemplates.sourceFilterAria': 'Filtrer par source',
'promptTemplates.attributionFooter': 'Adapté de bibliothèques de prompts publiques. Chaque carte renvoie vers l\'auteur original.',
'promptTemplates.openPreviewTitle': 'Ouvrir le prompt et l\'aperçu',
'promptTemplates.sourcePrefix': 'Source :',

View file

@ -5862,6 +5862,134 @@ button.connector-action.is-loading {
background: var(--red-bg);
border-color: var(--red-border);
}
/* Inspect panel sibling of the comment popover. Anchored to the
right side of the preview surface. Width is fixed so layout doesn't
reflow as the user scrubs slider values; controls reserve space for
their numeric readouts. */
.inspect-panel {
position: absolute;
top: 14px;
right: 14px;
z-index: 5;
width: 296px;
max-height: calc(100% - 28px);
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 14px;
padding: 12px 14px 14px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-panel);
box-shadow: var(--shadow-lg);
font-size: 12px;
}
.inspect-panel-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.inspect-panel-title {
display: grid;
gap: 2px;
min-width: 0;
}
.inspect-panel-title strong {
font-size: 13px;
}
.inspect-panel-title code {
color: var(--text-muted);
font-size: 11px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.inspect-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.inspect-section-label {
color: var(--text-muted);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.inspect-row {
display: grid;
grid-template-columns: 64px 1fr auto;
align-items: center;
gap: 8px;
}
.inspect-row > label {
color: var(--text-muted);
font-size: 11px;
}
.inspect-row input[type='color'] {
width: 28px;
height: 22px;
padding: 0;
border: 1px solid var(--border);
border-radius: 4px;
background: transparent;
}
.inspect-row input[type='text'],
.inspect-row select {
min-width: 0;
padding: 3px 6px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
font-size: 11px;
font-family: inherit;
}
.inspect-row input[type='range'] {
width: 100%;
}
.inspect-row-value {
font-variant-numeric: tabular-nums;
color: var(--text-muted);
font-size: 11px;
min-width: 42px;
text-align: right;
}
.inspect-panel-footer {
display: flex;
justify-content: space-between;
gap: 8px;
}
.inspect-panel-error {
margin: 0;
padding: 6px 8px;
border: 1px solid var(--red-border);
border-radius: 4px;
background: var(--red-bg);
color: var(--red);
font-size: 11px;
}
.inspect-empty-hint {
position: absolute;
top: 14px;
right: 14px;
z-index: 5;
padding: 8px 12px;
border: 1px dashed var(--border);
border-radius: var(--radius);
background: var(--bg-panel);
color: var(--text-muted);
font-size: 12px;
pointer-events: none;
}
.inspect-empty-hint code {
padding: 1px 5px;
border-radius: 3px;
background: var(--bg);
font-size: 11px;
}
.comments-panel {
display: flex;
flex-direction: column;

View file

@ -26,6 +26,7 @@ export type SrcdocOptions = {
baseHref?: string;
initialSlideIndex?: number;
commentBridge?: boolean;
inspectBridge?: boolean;
editBridge?: boolean;
};
@ -49,8 +50,21 @@ export function buildSrcdoc(
const withBase = options.baseHref ? injectBaseHref(withSourcePaths, options.baseHref) : withSourcePaths;
const withShim = injectSandboxShim(withBase);
const withDeck = options.deck ? injectDeckBridge(withShim, options.initialSlideIndex) : withShim;
const withComment = options.commentBridge ? injectCommentBridge(withDeck) : withDeck;
return options.editBridge ? injectManualEditBridge(withComment) : withComment;
// Comment + Inspect share an element-selection bridge: both pick a
// [data-od-id] / [data-screen-label] node and route the host's reply
// to either the comment popover (annotate) or the inspect panel
// (live-style overrides). Inject once when either mode is on. Pass the
// requested modes through so the bridge boots with picking already
// active — without that initial seed there is a window after each
// srcdoc rebuild where the host's `od:*-mode` postMessage races the
// bridge's own listener install and the iframe ignores clicks.
const withSelection = options.commentBridge || options.inspectBridge
? injectSelectionBridge(withDeck, {
initialCommentMode: !!options.commentBridge,
initialInspectMode: !!options.inspectBridge,
})
: withDeck;
return options.editBridge ? injectManualEditBridge(withSelection) : withSelection;
}
function annotateManualEditSourcePaths(doc: string): string {
@ -161,118 +175,250 @@ function injectSandboxShim(doc: string): string {
return shim + doc;
}
function injectCommentBridge(doc: string): string {
const script = `<script data-od-comment-bridge>(function(){
var enabled = true;
// Selection bridge: shared substrate for Comment mode and Inspect mode.
// Both modes pick a [data-od-id] / [data-screen-label] element on click;
// the difference is what the host does with the selection — annotate
// (Comment) or live-tune basic styles (Inspect).
//
// Inspect adds four messages on top of the comment protocol:
// in: { type: 'od:inspect-set', elementId, selector, prop, value }
// Apply (or unset, when value === '') a per-element CSS override.
// in: { type: 'od:inspect-reset', elementId? } Clear overrides for one
// element, or all if elementId is omitted.
// in: { type: 'od:inspect-extract' } Reply with the cumulative
// override map so the host can persist to source.
// in: { type: 'od:inspect-replay', overrides } Replace the in-memory
// override map with the host's authoritative set so the iframe
// preview matches host state after every srcdoc rebuild. Without
// this the bridge re-hydrates only the persisted <style> block on
// load, so any unsaved edit the host still holds disappears from
// the preview while saveInspectToSource() can later commit CSS the
// user is no longer seeing. Re-validates every entry under the
// same allow-list / value sanitizer applied to od:inspect-set.
// out: { type: 'od:inspect-overrides', overrides } The current snapshot,
// sent in reply to extract and after every set/reset/replay. The
// host re-derives the persisted CSS body from the structured map
// under its own allow-list — the bridge's own stylesheet text is
// NOT included in this message because artifact JS can forge a
// same-source od:inspect-overrides containing a hostile `css`.
//
// Overrides are written into a single <style data-od-inspect-overrides>
// block in <head>, with `!important` on every property so the bridge
// can defeat author inline styles (common in agent-generated HTML).
//
// Security: this bridge runs inside a sandboxed iframe but still shares the
// host page context for the override <style> element. The message listener
// does NOT validate ev.origin — the web app runs on configurable ports and
// preview domains, so the host origin is not stable. The bridge therefore
// trusts any parent that can postMessage to it and relies on iframe
// sandboxing + the prop allow-list / value sanitization below to contain
// damage. Any parent able to postMessage here can already mount the iframe.
function injectSelectionBridge(
doc: string,
options: { initialCommentMode?: boolean; initialInspectMode?: boolean } = {},
): string {
const initialComment = options.initialCommentMode ? 'true' : 'false';
const initialInspect = options.initialInspectMode ? 'true' : 'false';
const script = `<script data-od-selection-bridge>(function(){
var commentEnabled = ${initialComment};
var inspectEnabled = ${initialInspect};
// Comment mode has two sub-tools (kept on the host side as boardTool):
// 'picker' — click-to-select an element for annotation.
// 'pod' — pointer-drag a freeform stroke that the host turns into a
// pod selection covering whatever the stroke encloses.
// Inspect mode always uses 'picker'-style click selection regardless of
// this value.
var mode = 'picker';
var hoveredId = null;
var selectableCache = null;
var drawing = false;
var stroke = [];
var postTargetsTimer = null;
var MAX_TARGETS = 400;
// overrides[elementId] = { selector: '[data-od-id="x"]', props: { color: '#fff', ... } }
var overrides = Object.create(null);
var styleEl = null;
// Allow-list of CSS properties the host may override. A malicious parent
// could otherwise smuggle arbitrary CSS (or, with </style>, raw HTML)
// through od:inspect-set. Keep this in sync with the InspectPanel UI.
var ALLOWED_PROPS = {
'color': true,
'background-color': true,
'font-size': true,
'font-weight': true,
'font-family': true,
'line-height': true,
'text-align': true,
'padding': true,
'padding-top': true,
'padding-right': true,
'padding-bottom': true,
'padding-left': true,
'border-radius': true
};
// Reject any value that could break out of a 'prop: value' declaration:
// semicolons (extra declarations), braces (close the rule), angle
// brackets (close the <style> tag), and newlines (defense in depth).
var UNSAFE_VALUE = /[;{}<>\\n\\r]/;
function active(){ return commentEnabled || inspectEnabled; }
function esc(value){ try { return window.CSS && CSS.escape ? CSS.escape(value) : String(value).replace(/"/g, '\\\\"'); } catch (_) { return String(value); } }
function isSelectableElement(el){
if (!el || !el.tagName) return false;
var tag = el.tagName.toLowerCase();
if (tag === 'html' || tag === 'body' || tag === 'head' || tag === 'script' || tag === 'style' || tag === 'meta' || tag === 'link' || tag === 'title') return false;
var rect = el.getBoundingClientRect();
if (!rect || rect.width < 2 || rect.height < 2) return false;
var style = window.getComputedStyle ? window.getComputedStyle(el) : null;
if (style && (style.display === 'none' || style.visibility === 'hidden' || style.pointerEvents === 'none')) return false;
return true;
}
function meaningfulText(el){
return (el.textContent || '').replace(/\\s+/g, ' ').trim().slice(0, 160);
}
function shortLabel(el){
var tag = el.tagName ? el.tagName.toLowerCase() : 'element';
var id = typeof el.id === 'string' && el.id.trim() ? '#' + el.id.trim() : '';
var cls = typeof el.className === 'string' && el.className.trim() ? '.' + el.className.trim().split(/\\s+/).slice(0,2).join('.') : '';
return tag + id + cls;
}
function cssPath(el){
if (el.hasAttribute && el.hasAttribute('data-od-id')) {
return '[data-od-id="' + esc(el.getAttribute('data-od-id')) + '"]';
// Recompute the selector from elementId rather than trusting the one in
// the inbound message — a forged selector like
// '} </style><script>...' would otherwise be concatenated into the
// override <style> sheet verbatim. The hint string is only inspected to
// decide which attribute kind (data-od-id vs data-screen-label) was the
// user's pick at click time, so we tune the same node the host
// serializer keys off; the hint itself is never written into CSS.
function safeSelectorFor(elementId, hint){
var id = String(elementId);
var kind = null;
if (typeof hint === 'string') {
if (hint.indexOf('[data-od-id=') === 0) kind = 'data-od-id';
else if (hint.indexOf('[data-screen-label=') === 0) kind = 'data-screen-label';
}
if (el.id) return '#' + esc(el.id);
var parts = [];
var node = el;
while (node && node.nodeType === 1 && node !== document.body && parts.length < 6) {
var part = node.tagName.toLowerCase();
var cls = typeof node.className === 'string' && node.className.trim()
? node.className
.trim()
.split(/\\s+/)
.slice(0, 2)
.map(function(token){ return esc(token); })
.join('.')
: '';
if (cls) part += '.' + cls;
var index = 1;
var sib = node;
while ((sib = sib.previousElementSibling)) {
if (sib.tagName === node.tagName) index++;
if (kind === 'data-screen-label' && document.querySelector('[data-screen-label="' + esc(id) + '"]')) {
return '[data-screen-label="' + esc(id) + '"]';
}
if (kind === 'data-od-id' && document.querySelector('[data-od-id="' + esc(id) + '"]')) {
return '[data-od-id="' + esc(id) + '"]';
}
if (document.querySelector('[data-od-id="' + esc(id) + '"]')) {
return '[data-od-id="' + esc(id) + '"]';
}
if (document.querySelector('[data-screen-label="' + esc(id) + '"]')) {
return '[data-screen-label="' + esc(id) + '"]';
}
return null;
}
function ensureStyleEl(){
if (styleEl && styleEl.isConnected) return styleEl;
styleEl = document.querySelector('style[data-od-inspect-overrides]');
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.setAttribute('data-od-inspect-overrides', '');
(document.head || document.documentElement).appendChild(styleEl);
}
return styleEl;
}
// Hydrate the in-memory override map from any persisted
// <style data-od-inspect-overrides> block already in the document.
// Without this, the first od:inspect-set rebuilds the sheet from an
// empty map and silently drops every previously saved rule for other
// elements — a subsequent Save-to-source would then erase them from
// the artifact too.
function hydrateOverridesFromDom(){
var existing = document.querySelector('style[data-od-inspect-overrides]');
if (!existing) return;
var text = existing.textContent || '';
var ruleRe = /(\\[data-(?:od-id|screen-label)="[^"]*"\\])\\s*\\{\\s*([^}]*)\\}/g;
var match;
while ((match = ruleRe.exec(text)) !== null) {
var selector = match[1];
var declBody = match[2];
var idMatch = selector.match(/="([^"]*)"/);
if (!idMatch) continue;
var elementId = idMatch[1];
var props = Object.create(null);
var decls = declBody.split(';');
for (var d = 0; d < decls.length; d++) {
var raw = decls[d];
if (!raw) continue;
var colon = raw.indexOf(':');
if (colon <= 0) continue;
var name = raw.slice(0, colon).trim().toLowerCase();
if (!Object.prototype.hasOwnProperty.call(ALLOWED_PROPS, name)) continue;
var value = raw.slice(colon + 1).replace(/!important/i, '').trim();
if (!value || UNSAFE_VALUE.test(value)) continue;
props[name] = value;
}
part += ':nth-of-type(' + index + ')';
parts.unshift(part);
node = node.parentElement;
if (node && node.id) {
parts.unshift('#' + esc(node.id));
break;
if (Object.keys(props).length) {
overrides[elementId] = { selector: selector, props: props };
}
}
return parts.join(' > ') || shortLabel(el);
styleEl = existing;
}
function stableElementId(el){
if (el.hasAttribute && el.hasAttribute('data-od-id')) {
return el.getAttribute('data-od-id');
}
if (el.id) return el.id;
return cssPath(el);
function rebuildStyleSheet(){
var el = ensureStyleEl();
var lines = [];
Object.keys(overrides).forEach(function(id){
var entry = overrides[id];
if (!entry) return;
var props = entry.props || {};
var keys = Object.keys(props);
if (!keys.length) return;
var body = keys.map(function(k){ return k + ': ' + props[k] + ' !important'; }).join('; ');
lines.push(entry.selector + ' { ' + body + ' }');
});
el.textContent = lines.join('\\n');
}
function postOverrides(){
var clean = {};
Object.keys(overrides).forEach(function(id){
var entry = overrides[id];
if (entry && entry.props && Object.keys(entry.props).length) {
clean[id] = { selector: entry.selector, props: Object.assign({}, entry.props) };
}
});
// Intentionally do NOT include a css string here. Artifact code
// running inside this iframe shares window.parent and could forge
// od:inspect-overrides with a hostile css (e.g. </style><script>...).
// The host re-derives CSS from the structured overrides map under
// its own allow-list, so any stray css field on the wire would only
// be a false-trust trap.
try { window.parent.postMessage({ type: 'od:inspect-overrides', overrides: clean }, '*'); } catch (_) {}
}
function styleSnapshot(el){
try {
var cs = window.getComputedStyle(el);
return {
color: cs.color,
backgroundColor: cs.backgroundColor,
fontSize: cs.fontSize,
fontWeight: cs.fontWeight,
lineHeight: cs.lineHeight,
paddingTop: cs.paddingTop,
paddingRight: cs.paddingRight,
paddingBottom: cs.paddingBottom,
paddingLeft: cs.paddingLeft,
borderRadius: cs.borderTopLeftRadius,
textAlign: cs.textAlign,
fontFamily: cs.fontFamily
};
} catch (_) { return null; }
}
function targetFrom(el){
if (!isSelectableElement(el)) return null;
var id = stableElementId(el);
var id = el.getAttribute('data-od-id') || el.getAttribute('data-screen-label');
if (!id) return null;
var rect = el.getBoundingClientRect();
var tag = el.tagName ? el.tagName.toLowerCase() : 'element';
var cls = typeof el.className === 'string' && el.className.trim() ? '.' + el.className.trim().split(/\\s+/).slice(0,2).join('.') : '';
var html = '';
try { html = (el.outerHTML || '').replace(/\\s+/g, ' ').match(/^<[^>]+>/)?.[0] || ''; } catch (_) {}
return {
type: 'od:comment-target',
elementId: id,
selector: cssPath(el),
label: shortLabel(el),
text: meaningfulText(el),
selector: el.hasAttribute('data-od-id') ? '[data-od-id="' + esc(id) + '"]' : '[data-screen-label="' + esc(id) + '"]',
label: tag + cls,
text: (el.textContent || '').replace(/\\s+/g, ' ').trim().slice(0, 160),
position: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) },
htmlHint: html.slice(0, 180)
htmlHint: html.slice(0, 180),
style: styleSnapshot(el)
};
}
function relativePoint(ev){
return { x: Math.round(ev.clientX), y: Math.round(ev.clientY) };
}
function postStroke(type){
window.parent.postMessage({ type: type, points: stroke.slice() }, '*');
}
function allTargets(){
if (selectableCache) return selectableCache;
var nodes = document.body ? document.body.querySelectorAll('*') : [];
var nodes = document.querySelectorAll('[data-od-id], [data-screen-label]');
var items = [];
for (var i = 0; i < nodes.length; i++) {
var item = targetFrom(nodes[i]);
if (item) items.push(item);
if (items.length >= MAX_TARGETS) break;
}
selectableCache = items;
return selectableCache;
return items;
}
var postTargetsPending = false;
function postTargets(){
if (!enabled) return;
selectableCache = null;
if (!active()) return;
window.parent.postMessage({ type: 'od:comment-targets', targets: allTargets() }, '*');
}
function schedulePostTargets(){
if (!enabled || postTargetsPending) return;
if (!active() || postTargetsPending) return;
postTargetsPending = true;
if (postTargetsTimer) window.clearTimeout(postTargetsTimer);
postTargetsTimer = window.setTimeout(function(){
@ -283,30 +429,126 @@ function injectCommentBridge(doc: string): string {
});
}, 120);
}
function relativePoint(ev){
return { x: Math.round(ev.clientX), y: Math.round(ev.clientY) };
}
function postStroke(type){
window.parent.postMessage({ type: type, points: stroke.slice() }, '*');
}
function closestTarget(event){
var el = event.target && event.target.nodeType === 3 ? event.target.parentElement : event.target;
var el = event.target;
while (el && el !== document.documentElement) {
if (isSelectableElement(el)) return el;
if (el.getAttribute && (el.hasAttribute('data-od-id') || el.hasAttribute('data-screen-label'))) return el;
el = el.parentElement;
}
return null;
}
function selectorFor(el){
var id = el.getAttribute('data-od-id') || el.getAttribute('data-screen-label');
if (!id) return null;
return el.hasAttribute('data-od-id') ? '[data-od-id="' + esc(id) + '"]' : '[data-screen-label="' + esc(id) + '"]';
}
function applyOverride(elementId, selector, prop, value){
if (!elementId || !prop) return;
if (!Object.prototype.hasOwnProperty.call(ALLOWED_PROPS, prop)) return;
var safeSelector = safeSelectorFor(elementId, selector);
if (!safeSelector) return;
var v = (value == null) ? '' : String(value).trim();
if (v && UNSAFE_VALUE.test(v)) return;
var entry = overrides[elementId];
if (!entry) {
entry = { selector: safeSelector, props: Object.create(null) };
overrides[elementId] = entry;
} else {
entry.selector = safeSelector;
}
if (!v) delete entry.props[prop];
else entry.props[prop] = v;
if (Object.keys(entry.props).length === 0) delete overrides[elementId];
rebuildStyleSheet();
postOverrides();
}
function resetOverrides(elementId){
if (elementId) delete overrides[elementId];
else overrides = Object.create(null);
rebuildStyleSheet();
postOverrides();
}
window.addEventListener('message', function(ev){
if (!ev.data || ev.data.type !== 'od:comment-mode') return;
enabled = !!ev.data.enabled;
mode = ev.data.mode === 'pod' ? 'pod' : 'picker';
document.documentElement.toggleAttribute('data-od-comment-mode', enabled);
document.documentElement.setAttribute('data-od-comment-mode-kind', mode);
if (enabled) setTimeout(postTargets, 0);
else hoveredId = null;
if (!enabled || mode !== 'pod') {
drawing = false;
stroke = [];
window.parent.postMessage({ type: 'od:pod-clear' }, '*');
var data = ev && ev.data;
if (!data || !data.type) return;
if (data.type === 'od:comment-mode') {
commentEnabled = !!data.enabled;
mode = data.mode === 'pod' ? 'pod' : 'picker';
document.documentElement.toggleAttribute('data-od-comment-mode', commentEnabled);
document.documentElement.setAttribute('data-od-comment-mode-kind', mode);
if (active()) setTimeout(postTargets, 0);
else hoveredId = null;
if (!commentEnabled || mode !== 'pod') {
drawing = false;
stroke = [];
try { window.parent.postMessage({ type: 'od:pod-clear' }, '*'); } catch (_) {}
}
return;
}
if (data.type === 'od:inspect-mode') {
inspectEnabled = !!data.enabled;
document.documentElement.toggleAttribute('data-od-inspect-mode', inspectEnabled);
if (active()) setTimeout(postTargets, 0);
else hoveredId = null;
return;
}
if (data.type === 'od:inspect-set') {
applyOverride(data.elementId, data.selector, data.prop, data.value);
return;
}
if (data.type === 'od:inspect-reset') {
resetOverrides(data.elementId);
return;
}
if (data.type === 'od:inspect-extract') {
postOverrides();
return;
}
if (data.type === 'od:inspect-replay') {
// Replace the in-memory map with the host's authoritative set so
// unsaved edits survive a srcdoc rebuild (toggling inspect off/on,
// switching to comment, any other reload reloads the iframe from
// previewSource without the unsaved style block). Re-validate every
// entry: a parent able to postMessage to this bridge is otherwise
// trusted, but applying its payload through the same allow-list /
// value sanitizer keeps the override sheet under the bridge's own
// contract instead of whatever the parent sent.
var raw = (data && typeof data.overrides === 'object' && data.overrides) ? data.overrides : {};
overrides = Object.create(null);
var ids = Object.keys(raw);
for (var i = 0; i < ids.length; i++) {
var id = ids[i];
var entry = raw[id];
if (!entry || typeof entry.props !== 'object' || !entry.props) continue;
var safeSelector = safeSelectorFor(id, entry.selector);
if (!safeSelector) continue;
var clean = Object.create(null);
var pkeys = Object.keys(entry.props);
for (var p = 0; p < pkeys.length; p++) {
var name = String(pkeys[p]).toLowerCase();
if (!Object.prototype.hasOwnProperty.call(ALLOWED_PROPS, name)) continue;
var rawValue = entry.props[pkeys[p]];
if (rawValue == null) continue;
var v = String(rawValue).trim();
if (!v || UNSAFE_VALUE.test(v)) continue;
clean[name] = v;
}
if (Object.keys(clean).length) overrides[id] = { selector: safeSelector, props: clean };
}
rebuildStyleSheet();
postOverrides();
return;
}
});
function pickerActive(){ return inspectEnabled || (commentEnabled && mode === 'picker'); }
document.addEventListener('mouseover', function(ev){
if (!enabled || mode !== 'picker') return;
if (!pickerActive()) return;
var el = closestTarget(ev);
if (!el) return;
var payload = targetFrom(el);
@ -315,7 +557,7 @@ function injectCommentBridge(doc: string): string {
window.parent.postMessage(Object.assign({}, payload, { type: 'od:comment-hover' }), '*');
}, true);
document.addEventListener('mouseout', function(ev){
if (!enabled || mode !== 'picker') return;
if (!pickerActive()) return;
var el = closestTarget(ev);
if (!el) return;
var next = ev.relatedTarget;
@ -327,7 +569,7 @@ function injectCommentBridge(doc: string): string {
window.parent.postMessage({ type: 'od:comment-leave' }, '*');
}, true);
document.addEventListener('click', function(ev){
if (!enabled || mode !== 'picker') return;
if (!pickerActive()) return;
var el = closestTarget(ev);
if (!el) return;
ev.preventDefault();
@ -335,8 +577,9 @@ function injectCommentBridge(doc: string): string {
var payload = targetFrom(el);
if (payload) window.parent.postMessage(payload, '*');
}, true);
// Pod drawing — only active in comment mode with the 'pod' tool.
document.addEventListener('pointerdown', function(ev){
if (!enabled || mode !== 'pod' || ev.button !== 0) return;
if (!commentEnabled || mode !== 'pod' || ev.button !== 0) return;
drawing = true;
stroke = [relativePoint(ev)];
ev.preventDefault();
@ -368,11 +611,24 @@ function injectCommentBridge(doc: string): string {
document.addEventListener('scroll', schedulePostTargets, true);
var mo = new MutationObserver(schedulePostTargets);
mo.observe(document.documentElement, { subtree: true, childList: true, attributes: true, characterData: true });
// Reflect the host-requested initial modes on the documentElement so
// the cursor/hover styles match what the bridge picks up on click.
if (commentEnabled) document.documentElement.toggleAttribute('data-od-comment-mode', true);
if (inspectEnabled) document.documentElement.toggleAttribute('data-od-inspect-mode', true);
document.documentElement.setAttribute('data-od-comment-mode-kind', mode);
hydrateOverridesFromDom();
// Acknowledge the hydrated overrides to the host as a preview signal so
// diagnostic listeners (and tests) can observe that the bridge is in sync
// with the persisted style sheet. The host no longer treats this message
// as save input — it parses the artifact source itself — but emitting it
// keeps the iframe → host channel symmetric across set/reset/extract.
if (Object.keys(overrides).length) setTimeout(postOverrides, 0);
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', postTargets);
else setTimeout(postTargets, 0);
})();</script>`;
const style = `<style data-od-comment-bridge-style>
const style = `<style data-od-selection-bridge-style>
html[data-od-comment-mode] body * { cursor: crosshair !important; }
html[data-od-inspect-mode] body * { cursor: crosshair !important; }
html[data-od-comment-mode][data-od-comment-mode-kind="pod"] body * { cursor: cell !important; }
</style>`;
const withStyle = /<\/head>/i.test(doc)

View file

@ -22,7 +22,12 @@ import {
FileViewer,
LiveArtifactRefreshHistoryPanel,
SvgViewer,
applyInspectOverridesToSource,
parseInspectOverridesFromSource,
serializeInspectOverrides,
updateInspectOverride,
} from '../../src/components/FileViewer';
import type { InspectOverrideMap } from '../../src/components/FileViewer';
import type { LiveArtifact, ProjectFile } from '../../src/types';
function baseFile(overrides: Partial<ProjectFile>): ProjectFile {
@ -261,6 +266,510 @@ describe('FileViewer SVG artifacts', () => {
});
});
describe('applyInspectOverridesToSource', () => {
const base = `<!doctype html><html><head><title>X</title></head><body><main data-od-id="hero">Hi</main></body></html>`;
const css = `[data-od-id="hero"] { color: #ff0000 !important }`;
it('inserts the overrides block before </head>', () => {
const next = applyInspectOverridesToSource(base, css);
expect(next).toContain('<style data-od-inspect-overrides>');
expect(next).toContain('color: #ff0000');
expect(next.indexOf('<style data-od-inspect-overrides>')).toBeLessThan(next.indexOf('</head>'));
});
it('replaces an existing overrides block instead of duplicating', () => {
const once = applyInspectOverridesToSource(base, css);
const twice = applyInspectOverridesToSource(once, `[data-od-id="hero"] { color: #00ff00 !important }`);
const matches = twice.match(/<style data-od-inspect-overrides>/g) ?? [];
expect(matches).toHaveLength(1);
expect(twice).toContain('color: #00ff00');
expect(twice).not.toContain('color: #ff0000');
});
it('strips the overrides block when called with empty css', () => {
const once = applyInspectOverridesToSource(base, css);
const stripped = applyInspectOverridesToSource(once, '');
expect(stripped).not.toContain('data-od-inspect-overrides');
});
it('handles fragments without an explicit <head>', () => {
const next = applyInspectOverridesToSource('<main data-od-id="x">x</main>', css);
expect(next).toContain('<style data-od-inspect-overrides>');
expect(next.indexOf('<style data-od-inspect-overrides>')).toBeLessThan(next.indexOf('<main'));
});
// Regression for nexu-io/open-design#362: if a source file has more than
// one inspect override block (manual edit, or an earlier buggy save), the
// splicer must drop them all before inserting the new block. A non-global
// regex would only strip the first, so save-then-reload could resurrect an
// override the user just cleared.
it('removes every existing overrides block, not just the first', () => {
const dup = `<!doctype html><html><head>` +
`<style data-od-inspect-overrides>[data-od-id="hero"] { color: #ff0000 !important }</style>` +
`<style data-od-inspect-overrides>[data-od-id="hero"] { color: #00ff00 !important }</style>` +
`<title>X</title></head><body><main data-od-id="hero">Hi</main></body></html>`;
const replaced = applyInspectOverridesToSource(dup, `[data-od-id="hero"] { color: #0000ff !important }`);
const matches = replaced.match(/<style data-od-inspect-overrides>/g) ?? [];
expect(matches).toHaveLength(1);
expect(replaced).toContain('color: #0000ff');
expect(replaced).not.toContain('color: #ff0000');
expect(replaced).not.toContain('color: #00ff00');
const cleared = applyInspectOverridesToSource(dup, '');
expect(cleared).not.toContain('data-od-inspect-overrides');
});
// Regression for nexu-io/open-design#362: the splicer must be HTML-aware
// when locating its own override block and the head insertion point.
// Generated artifacts commonly carry inline scripts/styles that mention
// `</head>` or `<style data-od-inspect-overrides>` as text, e.g. a
// template literal that builds HTML at runtime or a CSS rule that
// documents the override block. A regex-only splicer would happily
// splice into the middle of the script body or strip the literal string,
// corrupting user code on Save to source.
it('ignores </head> literals inside inline <script> and <style>', () => {
const sourceWithLiteral =
`<!doctype html><html><head>` +
// Script body contains a quoted "</head>" string that must NOT be
// treated as the real head close.
`<script>const tpl = "<head>\\n</head>";</script>` +
`<style>/* sentinel: </head> appears in this CSS comment */</style>` +
`<title>X</title></head><body><main data-od-id="hero">Hi</main></body></html>`;
const next = applyInspectOverridesToSource(sourceWithLiteral, css);
// The override block must land exactly once, before the real </head>,
// and after the inline <script> and <style> that contain `</head>`
// text. Without HTML-aware scanning the regex would splice before the
// first textual `</head>`, which sits inside the script body.
const blockIdx = next.indexOf('<style data-od-inspect-overrides>');
const realHeadEndIdx = next.indexOf('</head>', next.indexOf('<title>'));
const scriptOpenIdx = next.indexOf('<script>');
const scriptCloseIdx = next.indexOf('</script>');
expect(blockIdx).toBeGreaterThan(-1);
expect(realHeadEndIdx).toBeGreaterThan(-1);
expect(scriptOpenIdx).toBeGreaterThan(-1);
expect(scriptCloseIdx).toBeGreaterThan(-1);
// Override block sits BEFORE the real </head>, AFTER the script body.
expect(blockIdx).toBeLessThan(realHeadEndIdx);
expect(blockIdx).toBeGreaterThan(scriptCloseIdx);
// The script's `</head>` literal still survives in the output —
// the splicer must not have hijacked it as the head insertion point.
expect(next).toContain('const tpl = "<head>\\n</head>";');
// The CSS comment's `</head>` token also survives untouched.
expect(next).toContain('/* sentinel: </head> appears in this CSS comment */');
// Only one override block in total.
const blockMatches = next.match(/<style data-od-inspect-overrides>/g) ?? [];
expect(blockMatches).toHaveLength(1);
});
it('ignores `<style data-od-inspect-overrides>` literals inside <script>', () => {
// A sentinel string literal in an inline script that mentions the
// override block by name. A regex-only splicer would strip the
// literal as if it were a real block, mangling the script.
const sourceWithLiteral =
`<!doctype html><html><head>` +
`<script>const banner = "<style data-od-inspect-overrides>color: red</style>";</script>` +
`<title>X</title></head><body><main data-od-id="hero">Hi</main></body></html>`;
const next = applyInspectOverridesToSource(sourceWithLiteral, css);
// The literal must survive verbatim inside the script body.
expect(next).toContain('const banner = "<style data-od-inspect-overrides>color: red</style>";');
// The output still gains exactly one real override block.
const blockMatches = next.match(/<style data-od-inspect-overrides>\n\[data-od-id="hero"\]/g) ?? [];
expect(blockMatches).toHaveLength(1);
// Stripping with empty css must NOT touch the script literal.
const stripped = applyInspectOverridesToSource(sourceWithLiteral, '');
expect(stripped).toContain('const banner = "<style data-od-inspect-overrides>color: red</style>";');
// The script-internal literal is the only mention of the marker after
// stripping — the splicer must not have inserted or kept any real
// override block.
const allMatches = stripped.match(/data-od-inspect-overrides/g) ?? [];
expect(allMatches).toHaveLength(1);
});
// Regression for nexu-io/open-design#362: the splicer must look at real
// attribute names, not just substring-match the marker text against the
// whole opening tag. A `\bdata-od-inspect-overrides\b` regex over the
// full tag matches both a longer attribute name (`-note` suffix) and the
// marker spelled inside another attribute's value, so a plain `<style>`
// documenting the override block in a `title` tooltip or a sibling note
// attribute would be mis-stripped on save and would have its inner CSS
// mis-parsed as override rules on hydration.
it('does not strip <style> blocks whose attribute name only PREFIXES the marker', () => {
const css2 = `[data-od-id="hero"] { color: #00ffaa !important }`;
const userBlock = `body { background: red !important }`;
const sourceWithLongerName =
`<!doctype html><html><head>` +
// attribute is named data-od-inspect-overrides-note, NOT the marker.
// The note shouldn't be treated as an Inspect-owned style block.
`<style data-od-inspect-overrides-note="docs">${userBlock}</style>` +
`<title>X</title></head><body><main data-od-id="hero">Hi</main></body></html>`;
const next = applyInspectOverridesToSource(sourceWithLongerName, css2);
// The user's style with the longer attribute name must survive in the
// output verbatim (with both the attribute and the body intact).
expect(next).toContain('<style data-od-inspect-overrides-note="docs">');
expect(next).toContain(userBlock);
// Exactly one real override block lands before </head>.
const blockMatches = next.match(/<style data-od-inspect-overrides>/g) ?? [];
expect(blockMatches).toHaveLength(1);
// Stripping with empty CSS still leaves the user's longer-name block
// alone — there was no real override block to remove.
const stripped = applyInspectOverridesToSource(sourceWithLongerName, '');
expect(stripped).toContain('<style data-od-inspect-overrides-note="docs">');
expect(stripped).toContain(userBlock);
expect(stripped).not.toContain('<style data-od-inspect-overrides>');
});
it('does not strip <style> blocks that only mention the marker inside an attribute value', () => {
const css2 = `[data-od-id="hero"] { color: #00ffaa !important }`;
const userBlock = `body { background: red !important }`;
const sourceWithMarkerInValue =
`<!doctype html><html><head>` +
// The literal text data-od-inspect-overrides appears as an attribute
// VALUE on a normal <style title="..."> — there is no real override
// marker here, so the splicer must keep the block.
`<style title="data-od-inspect-overrides">${userBlock}</style>` +
`<title>X</title></head><body><main data-od-id="hero">Hi</main></body></html>`;
const next = applyInspectOverridesToSource(sourceWithMarkerInValue, css2);
expect(next).toContain('<style title="data-od-inspect-overrides">');
expect(next).toContain(userBlock);
const blockMatches = next.match(/<style data-od-inspect-overrides>/g) ?? [];
expect(blockMatches).toHaveLength(1);
const stripped = applyInspectOverridesToSource(sourceWithMarkerInValue, '');
expect(stripped).toContain('<style title="data-od-inspect-overrides">');
expect(stripped).toContain(userBlock);
expect(stripped).not.toContain('<style data-od-inspect-overrides>');
});
it('still strips a real <style data-od-inspect-overrides> block with assigned value', () => {
// The marker is allowed both as a boolean attribute and with an
// assigned value (`<style data-od-inspect-overrides="">`). The splicer
// must treat both as the override block, not just the boolean shape.
const sourceWithValuedMarker =
`<!doctype html><html><head>` +
`<style data-od-inspect-overrides="">` +
`[data-od-id="hero"] { color: #ff0000 !important }` +
`</style>` +
`<title>X</title></head><body></body></html>`;
const stripped = applyInspectOverridesToSource(sourceWithValuedMarker, '');
expect(stripped).not.toContain('data-od-inspect-overrides');
expect(stripped).not.toContain('color: #ff0000');
});
it('ignores </head> inside <textarea> and <title> raw-text elements', () => {
// <textarea> and <title> are escapable raw-text elements; their
// contents are text, not markup, so a literal `</head>` inside them
// must not be treated as a tag boundary.
const sourceWithTextarea =
`<!doctype html><html><head><title>Has </head> in title</title></head>` +
`<body><textarea>literal </head> goes here</textarea>` +
`<main data-od-id="hero">Hi</main></body></html>`;
const next = applyInspectOverridesToSource(sourceWithTextarea, css);
// Override block lands before the REAL </head>, which is after the
// </title>'s close. The title-internal `</head>` must not be the
// chosen insertion point.
const blockIdx = next.indexOf('<style data-od-inspect-overrides>');
const titleCloseIdx = next.indexOf('</title>');
const realHeadCloseIdx = next.indexOf('</head>', titleCloseIdx);
expect(blockIdx).toBeGreaterThan(titleCloseIdx);
expect(blockIdx).toBeLessThan(realHeadCloseIdx);
// Both literals survive untouched.
expect(next).toContain('Has </head> in title');
expect(next).toContain('literal </head> goes here');
});
});
describe('serializeInspectOverrides', () => {
it('emits validated declarations for legitimate overrides', () => {
const out = serializeInspectOverrides({
hero: { selector: '[data-od-id="hero"]', props: { color: '#ff0000', 'font-size': '18px' } },
});
expect(out).toContain('[data-od-id="hero"]');
expect(out).toContain('color: #ff0000 !important');
expect(out).toContain('font-size: 18px !important');
});
it('honours data-screen-label entries the bridge tagged that way', () => {
const out = serializeInspectOverrides({
hero: { selector: '[data-screen-label="hero"]', props: { color: '#0f0' } },
});
expect(out).toContain('[data-screen-label="hero"]');
expect(out).not.toContain('[data-od-id="hero"]');
});
// Regression for nexu-io/open-design#362: standard deck slides ship as
// `<section data-screen-label="01 Cover">`. The bridge keys overrides by
// the raw label and posts a CSS.escape'd selector, so the host must
// accept whitespace/leading-digit ids and detect the selector kind by
// prefix instead of full equality. Otherwise the override is dropped
// outright (or silently rewritten to `[data-od-id="..."]`) and reload
// erases the user's edit.
it('preserves data-screen-label values with whitespace and leading digits', () => {
const out = serializeInspectOverrides({
'01 Cover': {
selector: '[data-screen-label="\\30 1\\20 Cover"]',
props: { color: '#ff0000', 'font-size': '20px' },
},
});
expect(out).toContain('[data-screen-label="01 Cover"]');
expect(out).not.toContain('[data-od-id="01 Cover"]');
expect(out).toContain('color: #ff0000 !important');
expect(out).toContain('font-size: 20px !important');
});
it('rejects non-allow-listed properties', () => {
const out = serializeInspectOverrides({
hero: { selector: '[data-od-id="hero"]', props: { position: 'absolute', color: '#fff' } },
});
expect(out).not.toContain('position');
expect(out).toContain('color: #fff !important');
});
it('drops values that try to break out of a `prop: value` declaration', () => {
const out = serializeInspectOverrides({
hero: {
selector: '[data-od-id="hero"]',
// semicolon, brace, angle bracket, and newline are all rejected.
props: {
color: 'red; background: url(x)',
'font-size': '16px } [body] { color: red',
'font-family': 'Arial</style><script>alert(1)</script>',
'line-height': '1\n.evil',
},
},
});
expect(out).toBe('');
});
// The vulnerability we're regression-testing: artifact code rendered with
// scripts enabled can call window.parent.postMessage({ type:
// 'od:inspect-overrides', overrides, css: '</style><script>...</script>' })
// — ev.source still matches iframe.contentWindow, so the host listener
// accepts it. The fix is that the host re-derives CSS from the structured
// `overrides` field under its own allow-list and ignores the inbound `css`
// entirely. This test covers that the serializer never lets a forged
// payload reach the persisted style block.
it('refuses to surface a forged </style><script> payload', () => {
const forged = {
// Hostile selector string: re-derived from elementId, never trusted.
hero: {
selector: '} </style><script>alert(1)</script><style>{',
props: { color: '#fff' },
},
// Hostile elementId: rejected outright by the safe-id check.
'"></style><script>alert(2)</script>': {
selector: '[data-od-id="x"]',
props: { color: '#fff' },
},
// Hostile value: rejected by UNSAFE_VALUE.
villain: {
selector: '[data-od-id="villain"]',
props: { color: '</style><script>alert(3)</script>' },
},
};
const out = serializeInspectOverrides(forged);
expect(out).not.toContain('</style>');
expect(out).not.toContain('<script>');
expect(out).not.toContain('alert(');
// The legitimate-looking entry still serializes — but with a re-derived
// selector, not the attacker-supplied one.
expect(out).toContain('[data-od-id="hero"] { color: #fff !important }');
expect(out).not.toContain('villain');
// And the spliced source must not contain executable markup either,
// even when the forged body is concatenated into a <style> block.
const spliced = applyInspectOverridesToSource(
'<!doctype html><html><head></head><body></body></html>',
out,
);
expect(spliced).not.toContain('</style><script>');
expect(spliced).not.toContain('alert(');
});
it('returns empty string for non-object payloads', () => {
expect(serializeInspectOverrides(null)).toBe('');
expect(serializeInspectOverrides(undefined)).toBe('');
expect(serializeInspectOverrides('</style><script>alert(1)</script>')).toBe('');
expect(serializeInspectOverrides(42)).toBe('');
});
});
// Regression for nexu-io/open-design#362: the host owns the inspect override
// map authoritatively. Hydration parses the artifact source on load so an
// initial Save-to-source preserves prior rules even when the user edits a
// different element, and forging the iframe's od:inspect-overrides reply
// cannot inject overrides — the host never ingests it.
describe('parseInspectOverridesFromSource', () => {
it('returns an empty map when the source has no override block', () => {
expect(parseInspectOverridesFromSource('')).toEqual({});
expect(parseInspectOverridesFromSource('<!doctype html><html><body>x</body></html>')).toEqual({});
});
it('parses an existing override block into the host map', () => {
const source =
`<!doctype html><html><head>` +
`<style data-od-inspect-overrides>` +
`[data-od-id="hero"] { color: #ff0000 !important; font-size: 18px !important }` +
`\n[data-screen-label="01 Cover"] { background-color: #000 !important }` +
`</style></head><body></body></html>`;
const map = parseInspectOverridesFromSource(source);
expect(map.hero?.props).toEqual({ color: '#ff0000', 'font-size': '18px' });
expect(map.hero?.selector).toBe('[data-od-id="hero"]');
expect(map['01 Cover']?.props).toEqual({ 'background-color': '#000' });
expect(map['01 Cover']?.selector).toBe('[data-screen-label="01 Cover"]');
});
it('aggregates rules across multiple persisted blocks', () => {
const source =
`<style data-od-inspect-overrides>[data-od-id="a"] { color: #111 !important }</style>` +
`<style data-od-inspect-overrides>[data-od-id="b"] { color: #222 !important }</style>`;
const map = parseInspectOverridesFromSource(source);
expect(Object.keys(map).sort()).toEqual(['a', 'b']);
});
it('drops disallowed properties and rules whose only declarations are unsafe', () => {
const source =
`<style data-od-inspect-overrides>` +
`[data-od-id="hero"] { position: absolute !important; color: #fff !important }` +
`[data-od-id="bad"] { background: red } ` +
`</style>`;
const map = parseInspectOverridesFromSource(source);
expect(map.hero?.props).toEqual({ color: '#fff' });
expect(map.bad).toBeUndefined();
});
it('refuses elementIds whose characters could break out of the attr value', () => {
const hostile =
`<style data-od-inspect-overrides>` +
`[data-od-id="\"><script>alert(1)</script>"] { color: #fff !important }` +
`</style>`;
expect(parseInspectOverridesFromSource(hostile)).toEqual({});
});
it('ignores override-shaped text inside raw-text elements and HTML comments', () => {
// A template literal in a <script>, a CSS comment in a sibling <style>, the
// body of a <textarea> / <title>, and an HTML comment all contain text that
// would match the override block regex. None of them are real persisted
// overrides, so the host map must stay empty — otherwise useEffect would
// seed phantom rules and a later Save-to-source would write CSS the user
// never created.
const phantomBlock =
`<style data-od-inspect-overrides>` +
`[data-od-id="hero"] { color: #ff0000 !important }` +
`</style>`;
const source =
`<!doctype html><html><head>` +
`<script>const tmpl = \`${phantomBlock}\`;</script>` +
`<style>/* docs: ${phantomBlock} */</style>` +
`<title>${phantomBlock}</title>` +
`<!-- ${phantomBlock} -->` +
`</head><body><textarea>${phantomBlock}</textarea></body></html>`;
expect(parseInspectOverridesFromSource(source)).toEqual({});
});
// Regression for nexu-io/open-design#362: hydration must require an
// actual `data-od-inspect-overrides` attribute name, not a boundary-only
// substring match against the whole opening tag. Otherwise a sibling
// attribute name with `-note` suffix or a tooltip whose value contains
// the marker text would seed phantom overrides into the host map and
// a later Save-to-source would persist CSS the artifact never had.
it('does not seed phantom overrides from a longer attribute name', () => {
const source =
`<!doctype html><html><head>` +
`<style data-od-inspect-overrides-note="docs">` +
`[data-od-id="hero"] { color: #ff0000 !important }` +
`</style></head><body></body></html>`;
expect(parseInspectOverridesFromSource(source)).toEqual({});
});
it('does not seed phantom overrides when the marker text only appears in an attribute value', () => {
const source =
`<!doctype html><html><head>` +
`<style title="data-od-inspect-overrides">` +
`[data-od-id="hero"] { color: #ff0000 !important }` +
`</style></head><body></body></html>`;
expect(parseInspectOverridesFromSource(source)).toEqual({});
});
it('still parses a real override block when raw-text literals also mention one', () => {
const phantomBlock =
`<style data-od-inspect-overrides>` +
`[data-od-id="phantom"] { color: #ff0000 !important }` +
`</style>`;
const source =
`<!doctype html><html><head>` +
`<script>const tmpl = \`${phantomBlock}\`;</script>` +
`<style data-od-inspect-overrides>` +
`[data-od-id="hero"] { color: #00ff00 !important }` +
`</style>` +
`</head><body></body></html>`;
const map = parseInspectOverridesFromSource(source);
expect(Object.keys(map)).toEqual(['hero']);
expect(map.hero?.props).toEqual({ color: '#00ff00' });
});
});
describe('updateInspectOverride', () => {
const base: InspectOverrideMap = {
hero: { selector: '[data-od-id="hero"]', props: { color: '#ff0000' } },
};
it('adds a new property to an existing entry', () => {
const next = updateInspectOverride(base, 'hero', '[data-od-id="hero"]', 'font-size', '18px');
expect(next).not.toBe(base);
expect(next.hero?.props).toEqual({ color: '#ff0000', 'font-size': '18px' });
});
it('creates a new entry for a previously untouched element', () => {
const next = updateInspectOverride(base, 'cta', '[data-od-id="cta"]', 'color', '#00ff00');
expect(next.cta?.props).toEqual({ color: '#00ff00' });
expect(next.hero?.props).toEqual({ color: '#ff0000' });
});
it('clears a single property when given an empty value', () => {
const seeded = updateInspectOverride(base, 'hero', '[data-od-id="hero"]', 'font-size', '18px');
const cleared = updateInspectOverride(seeded, 'hero', '[data-od-id="hero"]', 'font-size', '');
expect(cleared.hero?.props).toEqual({ color: '#ff0000' });
});
it('drops the entry once the last property is cleared', () => {
const cleared = updateInspectOverride(base, 'hero', '[data-od-id="hero"]', 'color', '');
expect(cleared.hero).toBeUndefined();
});
it('returns the same map reference when the change is a no-op', () => {
const same = updateInspectOverride(base, 'hero', '[data-od-id="hero"]', 'color', '#ff0000');
expect(same).toBe(base);
const noClear = updateInspectOverride(base, 'hero', '[data-od-id="hero"]', 'font-size', '');
expect(noClear).toBe(base);
});
it('rejects properties off the host allow-list', () => {
const ignored = updateInspectOverride(base, 'hero', '[data-od-id="hero"]', 'position', 'absolute');
expect(ignored).toBe(base);
});
it('rejects values that could break out of `prop: value`', () => {
const ignored = updateInspectOverride(
base,
'hero',
'[data-od-id="hero"]',
'color',
'red; background: url(x)',
);
expect(ignored).toBe(base);
});
it('rejects elementIds whose characters could break out of the attr value', () => {
const ignored = updateInspectOverride(
base,
'"><script>alert(1)</script>',
'[data-od-id="x"]',
'color',
'#fff',
);
expect(ignored).toBe(base);
});
});
function baseLiveArtifact(overrides: Partial<LiveArtifact> = {}): LiveArtifact {
const artifact: LiveArtifact = {
schemaVersion: 1,
@ -344,5 +853,4 @@ describe('LiveArtifactRefreshHistoryPanel', () => {
expect(markup).toContain('2 sources updated');
expect(markup).toContain('3.8s');
});
});

View file

@ -17,6 +17,10 @@ describe('shouldUrlLoadHtmlPreview', () => {
expect(shouldUrlLoadHtmlPreview({ ...base, commentMode: true })).toBe(false);
});
it('falls back to srcDoc when inspect mode is active (selection bridge required)', () => {
expect(shouldUrlLoadHtmlPreview({ ...base, inspectMode: true })).toBe(false);
});
it('falls back to srcDoc when the user opts in via forceInline', () => {
expect(shouldUrlLoadHtmlPreview({ ...base, forceInline: true })).toBe(false);
});

View file

@ -42,14 +42,17 @@ describe('buildSrcdoc', () => {
expect(canSetActive).not.toContain('findActiveByVisibility');
});
it('enables the comment bridge immediately when injected', () => {
it('injects the selection bridge for comment mode', () => {
const srcdoc = buildSrcdoc('<main data-od-id="hero">Hero</main>', {
commentBridge: true,
});
expect(srcdoc).toContain('data-od-comment-bridge');
expect(srcdoc).toContain('var enabled = true;');
expect(srcdoc).toContain("var mode = 'picker';");
expect(srcdoc).toContain('data-od-selection-bridge');
// The bridge boots with the requested mode already on so a click
// immediately after srcdoc rebuild is not lost to the listener-install
// race against the host's `od:*-mode` postMessage.
expect(srcdoc).toContain('var commentEnabled = true;');
expect(srcdoc).toContain('var inspectEnabled = false;');
expect(srcdoc).toContain("type: 'od:comment-target'");
expect(srcdoc).toContain("type: 'od:comment-hover'");
expect(srcdoc).toContain("type: 'od:comment-leave'");
@ -60,7 +63,116 @@ describe('buildSrcdoc', () => {
expect(srcdoc).toContain("body * { cursor: crosshair !important; }");
expect(srcdoc).toContain('MutationObserver(schedulePostTargets)');
expect(srcdoc).toContain("document.addEventListener('scroll', schedulePostTargets, true);");
expect(srcdoc).toContain('data-od-comment-bridge-style');
expect(srcdoc).toContain('data-od-selection-bridge-style');
});
it('injects the selection bridge for inspect mode and exposes override hooks', () => {
const srcdoc = buildSrcdoc('<main data-od-id="hero">Hero</main>', {
inspectBridge: true,
});
expect(srcdoc).toContain('data-od-selection-bridge');
expect(srcdoc).toContain('var commentEnabled = false;');
expect(srcdoc).toContain('var inspectEnabled = true;');
expect(srcdoc).toContain("type: 'od:inspect-overrides'");
expect(srcdoc).toContain("data.type === 'od:inspect-mode'");
expect(srcdoc).toContain("data.type === 'od:inspect-set'");
expect(srcdoc).toContain("data.type === 'od:inspect-reset'");
expect(srcdoc).toContain("data.type === 'od:inspect-extract'");
expect(srcdoc).toContain("data-od-inspect-overrides");
expect(srcdoc).toContain('html[data-od-inspect-mode]');
});
it('hydrates inspect overrides from a persisted style block on bridge boot', () => {
// Without hydration, the first od:inspect-set rebuilds the override
// sheet from an empty in-memory map and silently drops every previously
// saved rule for other elements — Save-to-source would then erase them
// from the artifact too.
const srcdoc = buildSrcdoc('<main data-od-id="hero">Hero</main>', {
inspectBridge: true,
});
expect(srcdoc).toContain('function hydrateOverridesFromDom()');
expect(srcdoc).toContain('hydrateOverridesFromDom();');
expect(srcdoc).toContain("document.querySelector('style[data-od-inspect-overrides]')");
// After hydration, the bridge must seed the host's overrides state so a
// Save-to-source before the user has touched any control does not splice
// an empty CSS body that erases the persisted style block.
expect(srcdoc).toContain('if (Object.keys(overrides).length) setTimeout(postOverrides, 0);');
});
it('reflects the requested initial bridge modes on the documentElement attributes', () => {
const commentDoc = buildSrcdoc('<main data-od-id="hero">Hero</main>', {
commentBridge: true,
});
expect(commentDoc).toContain("document.documentElement.toggleAttribute('data-od-comment-mode', true)");
const inspectDoc = buildSrcdoc('<main data-od-id="hero">Hero</main>', {
inspectBridge: true,
});
expect(inspectDoc).toContain("document.documentElement.toggleAttribute('data-od-inspect-mode', true)");
});
it('omits the selection bridge entirely when neither comment nor inspect mode is on', () => {
const srcdoc = buildSrcdoc('<main data-od-id="hero">Hero</main>', {});
expect(srcdoc).not.toContain('data-od-selection-bridge');
});
// Regression for nexu-io/open-design#362: the bridge must accept an
// od:inspect-replay message that replaces its in-memory override map
// with the host's authoritative set. Without this, toggling Inspect
// off/on or switching to Comment mode reloads the iframe from
// previewSource without the host's unsaved style block, leaving
// preview and persisted state out of sync — saveInspectToSource()
// could then commit CSS the user is no longer seeing.
it('accepts od:inspect-replay to rehydrate from the host map after a srcdoc rebuild', () => {
const srcdoc = buildSrcdoc('<main data-od-id="hero">Hero</main>', {
inspectBridge: true,
});
expect(srcdoc).toContain("data.type === 'od:inspect-replay'");
// Re-validates the inbound payload under the same allow-list and
// value sanitizer used for od:inspect-set. A parent able to post to
// this bridge is otherwise trusted, but applying its payload through
// the bridge's own contract keeps the override sheet under known
// rules instead of whatever the parent sent.
expect(srcdoc).toContain('Object.prototype.hasOwnProperty.call(ALLOWED_PROPS, name)');
// The replay handler installs the host map atomically — clears the
// previous in-memory map first, then re-applies validated entries
// and rebuilds the sheet in a single pass so the user does not see
// a flash of unstyled preview between the two postMessages a
// per-prop replay would require.
expect(srcdoc).toContain('overrides = Object.create(null);');
});
it('hardens inspect overrides with a prop allow-list, value sanitizer, and trusted selector', () => {
const srcdoc = buildSrcdoc('<main data-od-id="hero">Hero</main>', {
inspectBridge: true,
});
// Allow-list rejects anything off the InspectPanel surface — without
// this a malicious parent could smuggle CSS via od:inspect-set.
expect(srcdoc).toContain('var ALLOWED_PROPS');
expect(srcdoc).toContain("'color': true");
expect(srcdoc).toContain("'background-color': true");
expect(srcdoc).toContain("'border-radius': true");
expect(srcdoc).toContain("Object.prototype.hasOwnProperty.call(ALLOWED_PROPS, prop)");
// Value sanitizer drops any character that could close the declaration,
// the rule, or the <style> element.
expect(srcdoc).toContain('var UNSAFE_VALUE = /[;{}<>\\n\\r]/;');
expect(srcdoc).toContain('UNSAFE_VALUE.test(v)');
// Selector is recomputed from elementId, not echoed back from the
// inbound message — defends against a forged selector breaking out
// of the override <style> block. The inbound selector is still
// inspected to pick the attribute kind (data-od-id vs
// data-screen-label) the user clicked, so an artifact that carries
// both attributes on different nodes with the same id tunes the
// node the host serializer keys off, not whichever attribute
// happens to come first in safeSelectorFor's fallback order.
expect(srcdoc).toContain('function safeSelectorFor(elementId, hint)');
expect(srcdoc).toContain('var safeSelector = safeSelectorFor(elementId, selector)');
expect(srcdoc).toContain("hint.indexOf('[data-od-id=') === 0");
expect(srcdoc).toContain("hint.indexOf('[data-screen-label=') === 0");
});
it('marks source-authored edit targets before runtime scripts can add nodes', () => {