Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,9 @@ toolbar.destroy();

The package has not been released to npm yet. Build from this repository or install a reviewed GitHub commit. HTML integration needs only the hosted script.

Add `data-feedback-id="hero-cta"` to important elements to keep anchors stable across deployments. The selector, bounded element text, viewport, URL, and deployment reference accompany the comment. The loader excludes form values, editable content including inherited editing contexts, and elements marked `data-feedback-private`; the service strips URL queries and fragments.
Add `data-feedback-id="hero-cta"` to important elements to keep anchors stable across deployments. The selector, bounded element text, viewport, URL, and deployment reference accompany the comment. The loader excludes form values, editable content including inherited editing contexts, and elements marked `data-feedback-private`; the service strips URL queries and fragments. Use `data-feedback-redact` on regions whose elements should remain selectable while their text and accessible labels are withheld. Descendants inherit that redaction, and selecting an ancestor excludes the region's text. Keep `data-feedback-private` alongside `data-feedback-redact` when supporting older embeds: new embeds allow redacted selection, while older embeds continue to block the region entirely. Nested elements marked only `data-feedback-private` remain blocked.

Toggle pins to show up to 256 loaded, open threads on the current page. Click a pin to open its conversation. Pins skip private, missing, ambiguous, or hidden elements. **Add reaction** opens a searchable Unicode emoji picker with skin tones; reactions also support flags and joined emoji sequences.
Toggle pins to show up to 256 loaded, open threads on the current page. Feedback on shared elements also appears on other routes of the same website when its selector resolves to a unique, visible, public element. Click a pin to open its conversation. Pins skip private, missing, ambiguous, or hidden elements. **Add reaction** opens a searchable Unicode emoji picker with skin tones; reactions also support flags and joined emoji sequences.

## Agent-assisted setup

Expand Down
7 changes: 6 additions & 1 deletion e2e/feedback.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ test('isolated toolbar shares the service session and completes the feedback-to-
await page.evaluate(() => {
const editing = document.createElement('section');
editing.innerHTML =
'<div id="editable-empty" contenteditable>Secret draft</div><div id="editable-plain" contenteditable="plaintext-only">Secret plaintext</div><div contenteditable><span id="editable-inherited">Inherited secret</span></div><div id="editable-parent">Public text <div contenteditable>Private child draft</div></div>';
'<div id="editable-empty" contenteditable>Secret draft</div><div id="editable-plain" contenteditable="plaintext-only">Secret plaintext</div><div contenteditable><span id="editable-inherited">Inherited secret</span></div><div id="editable-parent">Public text <div contenteditable>Private child draft</div></div><div id="redacted-parent">Public controls <section data-feedback-redact>Unpublished owner metadata</section></div>';
document.body.prepend(editing);
});
for (const id of ['editable-empty', 'editable-plain', 'editable-inherited']) {
Expand All @@ -295,6 +295,11 @@ test('isolated toolbar shares the service session and completes the feedback-to-
await expect(frame.getByText('Private child draft', { exact: true })).toHaveCount(0);
await frame.getByRole('button', { name: 'Back to feedback', exact: true }).click();
await frame.getByRole('button', { name: 'New feedback' }).click();
await page.locator('#redacted-parent').click({ position: { x: 5, y: 5 } });
await expect(frame.getByText('Public controls', { exact: true })).toBeVisible();
await expect(frame.getByText('Unpublished owner metadata', { exact: true })).toBeHidden();
await frame.getByRole('button', { name: 'Back to feedback', exact: true }).click();
await frame.getByRole('button', { name: 'New feedback' }).click();
await page.locator('[data-feedback-id=hero-button]').click();
await frame
.getByLabel('What would you like to change?')
Expand Down
182 changes: 181 additions & 1 deletion e2e/pins.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,180 @@ async function pinMessages(page: Page) {
});
}

test('shared element feedback follows its anchor onto a nested route', async ({ page }) => {
await instrument(page);
const project = await createProject(page);
await createThread(
page,
project.id,
'[data-feedback-id="hero-heading"]',
'Shared heading feedback',
);
await createThread(page, project.id, '#root-only', 'Root-only feedback');
const frame = await openToolbar(page, project.id);
await page.evaluate(() => {
history.pushState({}, '', '/owner');
});
await expect.poll(async () => (await pinMessages(page)).at(-1)?.url).toBe(`${website}/owner`);
await expect(
frame.getByRole('switch', { name: 'Show feedback pins', exact: true }),
).toBeEnabled();
await frame.getByRole('button', { name: 'Feedback', exact: false }).click();
await expect(frame.getByText('Shared heading feedback', { exact: true })).toBeVisible();
await expect(frame.getByText('Root-only feedback', { exact: true })).toBeHidden();
await frame.getByRole('switch', { name: 'Show feedback pins', exact: true }).click();
await expect.poll(async () => (await pins(page)).length).toBe(1);
await page.screenshot({ path: '.data/review/shared-feedback-after.png' });
await clickPin(page, 1);
await expect(frame.getByText('Shared heading feedback', { exact: true })).toBeVisible();
await expect(frame.getByRole('button', { name: 'Back to feedback', exact: true })).toBeVisible();
await frame.getByRole('button', { name: 'Back to feedback', exact: true }).click();
await page.locator('[data-feedback-id="hero-heading"]').evaluate((element) => {
element.setAttribute('hidden', '');
});
await expect(frame.getByText('Shared heading feedback', { exact: true })).toBeHidden();
await expect.poll(async () => (await pins(page)).length).toBe(0);
await page.locator('[data-feedback-id="hero-heading"]').evaluate((element) => {
element.removeAttribute('hidden');
element.setAttribute('data-feedback-private', '');
});
await expect(frame.getByText('Shared heading feedback', { exact: true })).toBeHidden();
await page.locator('[data-feedback-id="hero-heading"]').evaluate((element) => {
element.removeAttribute('data-feedback-private');
});
await expect(frame.getByText('Shared heading feedback', { exact: true })).toBeVisible();
await expect.poll(async () => (await pins(page)).length).toBe(1);
});

test('shared header SVG feedback loads directly on nested routes without including another origin', async ({
page,
}) => {
await instrument(page);
const project = await createProject(page);
const otherOrigin = 'https://other.example';
expect(
(
await page.request.patch(`/api/projects/${project.id}`, {
data: { origins: [website, otherOrigin] },
})
).status(),
).toBe(200);
await createThread(page, project.id, 'body > header > svg', 'Shared header icon feedback');
await createThread(
page,
project.id,
'body > header > svg',
'Other website feedback',
`${otherOrigin}/`,
);
await page.addInitScript((origin) => {
// The demo server serves only /. Start the embed on a nested pathname
// before any page scripts run, using the real loopback document response.
if (location.origin !== origin) return;
history.replaceState({}, '', `/owner${location.search}`);
document.addEventListener('DOMContentLoaded', () => {
const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
icon.setAttribute('viewBox', '0 0 16 16');
icon.style.cssText = 'width:22px;height:22px';
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', 'M2 2h12v12H2z');
icon.append(path);
document.querySelector('body > header')?.append(icon);
});
}, website);
const frame = await openToolbar(page, project.id);
await expect(page).toHaveURL(`${website}/owner?project=${project.id}`);
await frame.getByRole('button', { name: 'Feedback', exact: false }).click();
await expect(frame.getByText('Shared header icon feedback', { exact: true })).toBeVisible();
await expect(frame.getByText('Other website feedback', { exact: true })).toBeHidden();
await frame.getByRole('switch', { name: 'Show feedback pins', exact: true }).click();
await expect.poll(async () => (await pins(page)).length).toBe(1);
await frame.getByRole('button', { name: 'Minimize toolbar', exact: true }).click();
await expect(page.locator('[data-feedback-host]')).toHaveCSS('height', '56px');
await clickPin(page, 1);
await expect(frame.getByText('Shared header icon feedback', { exact: true })).toBeVisible();
await expect(frame.getByRole('button', { name: 'Back to feedback', exact: true })).toBeVisible();
});

test('selection keeps an Astro table header cell granular through hover and compose', async ({
page,
}) => {
await instrument(page);
const project = await createProject(page);
const frame = await openToolbar(page, project.id);
await page.evaluate(() => {
const main = document.querySelector('main');
if (!main) throw new Error('Main missing');
main.innerHTML =
'<astro-island style="display:contents"><div><table style="border-collapse:collapse;width:100%;table-layout:fixed"><thead><tr><th>Title</th><th>Published</th><th>Status</th></tr></thead></table></div></astro-island>';
main.setAttribute('style', 'padding:24px;padding-top:0;margin-top:0');
main.setAttribute('data-feedback-redact', '');
main.setAttribute('data-feedback-private', '');
for (const cell of main.querySelectorAll('th'))
cell.setAttribute('style', 'height:40px;padding:0;text-align:left');
});
const cell = page.locator('th').nth(1);
await frame.getByRole('button', { name: 'Select an element', exact: true }).click();
await cell.hover();
const highlightBounds = () =>
page.evaluate(() => {
const root: unknown = Reflect.get(window, 'pinTestRoot');
if (!(root instanceof ShadowRoot)) throw new Error('Closed test root missing');
const highlight = Array.from(root.children).find(
(element) => element instanceof HTMLElement && element.style.pointerEvents === 'none',
);
const rect = highlight?.getBoundingClientRect();
return rect ? { x: rect.x, y: rect.y, width: rect.width, height: rect.height } : null;
});
const bounds = await cell.boundingBox();
if (!bounds) throw new Error('Table header cell missing');
await expect.poll(highlightBounds).toMatchObject(bounds);
await page.screenshot({ path: '.data/review/table-selection.png' });
await cell.evaluate((element) => {
element.setAttribute('aria-label', 'Unpublished itinerary metadata');
});
await cell.click();
await expect(
frame.getByText('astro-island > div > table > thead > tr > th:nth-of-type(2)', { exact: true }),
).toBeVisible();
await expect(frame.getByText('[Private element]', { exact: true })).toBeVisible();
await expect(frame.getByText('Published', { exact: true })).toBeHidden();
await expect.poll(highlightBounds).toMatchObject(bounds);
await frame.getByLabel('What would you like to change?').fill('Adjust this column spacing');
const posted = page.waitForResponse(
(response) => response.url().endsWith('/threads') && response.request().method() === 'POST',
);
await frame.getByRole('button', { name: 'Post feedback', exact: true }).click();
const result = z.object({ thread: threadSchema }).parse(await (await posted).json());
expect(result.thread.context.anchor).toMatchObject({
tag: 'th',
text: '[Private element]',
label: '',
});
await frame.getByRole('switch', { name: 'Show feedback pins', exact: true }).click();
await expect.poll(async () => (await pins(page)).length).toBe(1);
await frame.getByRole('button', { name: 'Back to feedback', exact: true }).click();
await frame.getByRole('button', { name: 'New feedback', exact: true }).click();
await page.locator('body > header').hover();
expect(await highlightBounds()).not.toEqual(bounds);
await cell.evaluate((element) => {
element.setAttribute('data-feedback-private', '');
});
await cell.hover();
await expect
.poll(async () =>
page.evaluate(() => {
const root: unknown = Reflect.get(window, 'pinTestRoot');
if (!(root instanceof ShadowRoot)) throw new Error('Closed test root missing');
const highlight = Array.from(root.children).find(
(element) => element instanceof HTMLElement && element.style.pointerEvents === 'none',
);
return highlight ? getComputedStyle(highlight).display : null;
}),
)
.toBe('none');
});

test('toggle renders isolated accessible pins and click or keyboard opens the matching thread in place', async ({
page,
}) => {
Expand Down Expand Up @@ -425,7 +599,13 @@ test('pins follow scroll, reflow and resize, reject missing, duplicate and priva
await createThread(page, project.id, '', 'Unanchored feedback');
await createThread(page, project.id, 'button', 'Ambiguous feedback');
await createThread(page, project.id, '.feedback-form input', 'Private feedback');
await createThread(page, project.id, 'main', 'Different page feedback', `${website}/other`);
await createThread(
page,
project.id,
'#other-page-main',
'Different page feedback',
`${website}/other`,
);
const frame = await openToolbar(page, project.id);
await frame.getByRole('switch', { name: 'Show feedback pins', exact: true }).click();
await expect.poll(async () => (await pins(page)).length).toBe(1);
Expand Down
3 changes: 3 additions & 0 deletions e2e/reactions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,9 @@ test('favourites combine choices from separate tabs without losing updates', asy
page.getByRole('gridcell', { name: 'Butterfly', exact: true }).click(),
other.getByRole('gridcell', { name: 'Rocket', exact: true }).click(),
]);
// Both choices must finish persisting before either picker snapshots favourites.
for (const tab of [page, other])
await expect(tab.getByRole('button', { name: 'Add reaction', exact: true })).toBeEnabled();
for (const tab of [page, other]) {
const add = tab.getByRole('button', { name: 'Add reaction', exact: true });
await expect(add).toBeEnabled();
Expand Down
57 changes: 57 additions & 0 deletions src/browser/host-anchors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { publicAnchor } from './host-pins.js';
import type { FeedbackAnchors } from './pin-protocol.js';

// Match shared elements even when pins are switched off. Only selectors and IDs
// cross the frame boundary; the host never receives conversation content.
export function createHostAnchors(
host: Element,
pageUrl: () => string,
send: (data: unknown) => void,
) {
let candidates: FeedbackAnchors | null = null;
let previous = '';
let pending: ReturnType<typeof setTimeout> | undefined;
const match = () => {
pending = undefined;
if (candidates?.url !== pageUrl()) return;
const matches = new Map<string, boolean>();
const threadIds = candidates.items
.filter((item) => {
if (!matches.has(item.selector))
matches.set(item.selector, publicAnchor(item.selector) !== null);
return matches.get(item.selector);
})
.map((item) => item.threadId);
const signature = JSON.stringify([candidates.url, threadIds]);
if (signature === previous) return;
previous = signature;
send({ type: 'feedback:anchors-found', url: candidates.url, threadIds });
};
const schedule = () => {
if (candidates?.items.length && pending === undefined) pending = setTimeout(match, 50);
};
const observer = new MutationObserver((records) => {
if (records.some((record) => record.target !== host && !host.contains(record.target)))
schedule();
});
window.addEventListener('resize', schedule);
observer.observe(document.documentElement, {
subtree: true,
childList: true,
attributes: true,
characterData: true,
});
return {
update(value: FeedbackAnchors | null) {
clearTimeout(pending);
candidates = value;
previous = '';
match();
},
destroy() {
observer.disconnect();
clearTimeout(pending);
window.removeEventListener('resize', schedule);
},
};
}
2 changes: 1 addition & 1 deletion src/browser/host-pins.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { FeedbackPin } from './pin-protocol.js';

const privateSelector =
'[data-feedback-private],input,textarea,select,[contenteditable],[data-feedback-host]';
'[data-feedback-private]:not([data-feedback-redact]),input,textarea,select,[contenteditable],[data-feedback-host]';

const paintOnlyProperties = new Set([
'opacity',
Expand Down
Loading
Loading