mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* feat(web): free-pin fallback in comment mode for unannotated artifacts When the artifact has no data-od-id annotations, clicking in Comment mode now posts a synthetic position-based target so the host opens a popover at the click location. Daemon upsert validation requires a non-empty selector/label, so the pin uses [data-od-pin=ID] and label 'pin'. Coordinates are document-space (viewport + scrollY) so pins stay anchored after scroll/reload. Clicks on interactive elements (a/button/input/textarea/select/label/contenteditable) keep their native behavior and are not pinned. * feat(web): tighten comment popover layout for free-pin and element targets The popover header used to dump the raw elementId verbatim — fine for data-od-id targets like 'hero-cta' but jarring for free-pins where elementId is a synthetic 'pin-...' string. Branch on the prefix and show 'Pin · at X, Y' for free-pins; keep the label + selection kind for real element / pod targets. Replace the text 'Close' button with an icon-only close affordance to match the popover-as-card visual. Action row is now two right-aligned buttons (Comment + Send to Claude) for element targets and (Add note + Send to Claude) for pod targets, eliminating the three-button row that wrapped onto two lines at narrow widths. The 'Remove' affordance for existing comments stays left-aligned. * feat(web): drop comments tab from chat sidebar The chat sidebar's 'Comments' tab listed saved/attached preview comments but duplicates the per-element popover already shown in the artifact viewer. Hide the tab and its content while the right-side comment thread panel takes over the same surface in-context. The CommentsPanel / CommentSection components stay defined as dead code for the moment so callers and translation keys remain valid; a later pass can delete them. * feat(web): right-side comment thread panel in board mode Render a 320px CommentSidePanel anchored to the right of the artifact preview whenever board (comment) mode is on. The panel lists every saved preview comment for the current file with an avatar initial, the element label (or 'Pin' for free-pin synthetic ids), an Xd/Xh/Xm-ago timestamp, the note body, a Reply link, and a checkbox. Reply focuses the comment's element via liveSnapshotForComment so the popover opens at the right anchor. Selecting one or more comments via the checkboxes surfaces a 'N selected · Clear · Send to Claude' action bar above the list; Send to Claude reuses the existing onSendBoardCommentAttachments pipeline via commentsToAttachments. The panel takes the place of the chat sidebar's removed Comments tab so the thread lives next to the artifact instead of behind a tab switch. * feat(web): styles for right-side comment thread panel Floating 320px panel anchored to the right edge of the artifact preview with a scrollable comment list and a coral selection bar that appears when one or more comments are checked. Selected items get a coral tint; the reply / check / send-to-claude controls match the popover's coral primary tone. * feat(web): toast confirmation on comment save, close popover After savePersistentComment succeeds, close the popover via clearBoardComposer and surface a transient 'Comment saved' (or 'Pin saved' for free-pin targets) toast for 2.2s. Replaces the previous behavior where the popover stayed open with an empty draft after save, which left users uncertain whether the save landed and forced an extra click to dismiss. * feat(web): position the comment-save toast at the top of the preview * feat(web): allow editing saved comment notes via the side panel Rename the per-item 'Reply' affordance to 'Edit' (no thread model exists yet, so reply was misleading) and pre-fill the popover with the existing note when clicked. The save path goes through onSavePreviewComment which the daemon implements as an upsert keyed on (project, conversation, filePath, elementId), so the edit overwrites the existing row's note without spawning a duplicate. Also fall back to a snapshot synthesized from the saved comment's own fields when the corresponding live target is no longer in the iframe DOM (e.g. free-pin parents that were re-rendered), so the edit path still works after artifact reloads. * feat(web): hide already-sent comments from the side panel After Send to Claude, the daemon flips the comment status from 'open' to 'applying' (and then 'needs_review' / 'resolved' / 'failed' depending on the run). Filter the side panel to status === 'open' so sent comments visibly leave the list — the user gets clear feedback that the send landed and the panel stays focused on actionable, un-sent items. * feat(web): drop single-tab bar and conversation count badge After the Comments tab was removed the chat header still rendered a one-tab 'tablist' just for the Chat tab, which read as visual noise without a sibling to switch between. Drop the tabs wrapper entirely; the chat content stays mounted and the header now hosts only the conversation-history affordance. Also drop the numeric badge that overlaid the conversation history button: counting open conversations next to a generic history icon was easy to mistake for an unread / notification count. The dropdown itself remains the canonical place to see and switch between past conversations. * feat(web): right-align chat header actions after tab bar removal With the tabs wrapper gone, chat-header-actions sat flush left because nothing was pushing it across the header. Add margin-left: auto so the history / new-conversation / collapse buttons land at the right edge, matching the design files / index.html tab row's own right-aligned controls. * feat(web): rename board-mode toggle to Comment with comment icon The artifact preview toolbar's board-mode entry was labeled 'Tweaks' with the tweaks icon, which collided with the palette Tweaks button next to it and hid the comment capability behind a generic label. Rename to 'Comment' with the comment icon and switch to the viewer-action class so the button matches the surrounding toolbar items (Edit/Draw) and the coral active state lands on the right surface. * fix(web): pass designTemplates to ProjectView in api-empty-response test The test props for ProjectView were missing the designTemplates prop that was added to Props in #955 (generic skills split). CI's strict typecheck (tsc -b --noEmit) caught it; local runs that hit project references differently did not. Pass an empty SkillSummary array — matches the empty skills fixture for the same reason.
221 lines
11 KiB
TypeScript
221 lines
11 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { JSDOM } from 'jsdom';
|
|
import { buildSrcdoc } from '../../src/runtime/srcdoc';
|
|
|
|
const deckHtml = `<!doctype html>
|
|
<html>
|
|
<head><title>Deck</title></head>
|
|
<body>
|
|
<section class="slide active">One</section>
|
|
<section class="slide">Two</section>
|
|
<section class="slide">Three</section>
|
|
</body>
|
|
</html>`;
|
|
|
|
describe('buildSrcdoc', () => {
|
|
it('injects an initial slide index for deck previews', () => {
|
|
const doc = buildSrcdoc(deckHtml, { deck: true, initialSlideIndex: 2 });
|
|
|
|
expect(doc).toContain('var initialSlideIndex = 2;');
|
|
expect(doc).toContain('setTimeout(restoreInitialSlide, 200)');
|
|
expect(doc).toContain('setTimeout(restoreInitialSlide, 100)');
|
|
});
|
|
|
|
it('clamps invalid initial slide indices before injecting deck bridge script', () => {
|
|
const doc = buildSrcdoc(deckHtml, { deck: true, initialSlideIndex: -4 });
|
|
|
|
expect(doc).toContain('var initialSlideIndex = 0;');
|
|
});
|
|
|
|
it('only uses directly mutable slide conventions for setActive support', () => {
|
|
const srcdoc = buildSrcdoc(
|
|
'<section class="slide">One</section><section class="slide">Two</section>',
|
|
{ deck: true }
|
|
);
|
|
|
|
const canSetActive = srcdoc.match(/function canSetActive\(list\)\{([\s\S]*?)\n \}/)?.[1] ?? '';
|
|
|
|
expect(canSetActive).toContain('findActiveByClass(list) >= 0');
|
|
expect(canSetActive).toContain("list[i].style.display === 'none'");
|
|
expect(canSetActive).toContain("list[i].style.visibility === 'hidden'");
|
|
expect(canSetActive).toContain("list[i].hasAttribute('hidden')");
|
|
expect(canSetActive).not.toContain('findActiveByVisibility');
|
|
});
|
|
|
|
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-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'");
|
|
expect(srcdoc).toContain("type: 'od:comment-targets'");
|
|
expect(srcdoc).toContain("postStroke('od:pod-stroke')");
|
|
expect(srcdoc).toContain("postStroke('od:pod-select')");
|
|
expect(srcdoc).toContain('data-od-comment-mode-kind');
|
|
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-selection-bridge-style');
|
|
});
|
|
|
|
it('emits free-pin fallback coordinates in viewport space', () => {
|
|
const srcdoc = buildSrcdoc('<main>Hero</main>', { commentBridge: true });
|
|
const freePinBlock = srcdoc.slice(srcdoc.indexOf('var pinX = Math.round(ev.clientX);'));
|
|
|
|
expect(freePinBlock).toContain('var pinX = Math.round(ev.clientX);');
|
|
expect(freePinBlock).toContain('var pinY = Math.round(ev.clientY);');
|
|
expect(freePinBlock).toContain('position: { x: pinX - 12, y: pinY - 12, width: 24, height: 24 }');
|
|
expect(freePinBlock).not.toContain('scrollX');
|
|
expect(freePinBlock).not.toContain('scrollY');
|
|
expect(freePinBlock).not.toContain('pageXOffset');
|
|
expect(freePinBlock).not.toContain('pageYOffset');
|
|
});
|
|
|
|
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', () => {
|
|
const dom = new JSDOM('');
|
|
globalThis.DOMParser = dom.window.DOMParser;
|
|
const srcdoc = buildSrcdoc(
|
|
'<main><h1>Source title</h1><script>document.body.prepend(document.createElement("h1"));</script></main>',
|
|
{ editBridge: true },
|
|
);
|
|
Reflect.deleteProperty(globalThis, 'DOMParser');
|
|
|
|
expect(srcdoc).toContain('data-od-source-path="path-0"');
|
|
expect(srcdoc).toContain('data-od-source-path="path-0-0"');
|
|
expect(srcdoc).not.toContain('<script data-od-source-path=');
|
|
expect(srcdoc.indexOf('data-od-source-path="path-0"')).toBeLessThan(srcdoc.indexOf('document.body.prepend'));
|
|
});
|
|
|
|
it('injects only the manual edit bridge when edit mode is enabled without picker bridges', () => {
|
|
const dom = new JSDOM('');
|
|
globalThis.DOMParser = dom.window.DOMParser;
|
|
const srcdoc = buildSrcdoc('<main data-od-id="hero">Hero</main>', {
|
|
editBridge: true,
|
|
});
|
|
Reflect.deleteProperty(globalThis, 'DOMParser');
|
|
|
|
expect(srcdoc).toContain('data-od-source-path=');
|
|
expect(srcdoc).toContain('data-od-edit-bridge');
|
|
expect(srcdoc).not.toContain('data-od-selection-bridge');
|
|
expect(srcdoc).not.toContain("type: 'od:comment-target'");
|
|
expect(srcdoc).not.toContain("type: 'od:inspect-overrides'");
|
|
});
|
|
});
|