design-share 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,9 +9,9 @@
9
9
  <key>CFBundleIdentifier</key>
10
10
  <string>com.design-share.menubar</string>
11
11
  <key>CFBundleVersion</key>
12
- <string>0.1.0</string>
12
+ <string>0.1.1</string>
13
13
  <key>CFBundleShortVersionString</key>
14
- <string>0.1.0</string>
14
+ <string>0.1.1</string>
15
15
  <key>CFBundleExecutable</key>
16
16
  <string>DesignShare</string>
17
17
  <key>CFBundlePackageType</key>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "design-share",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Live branch previews and pinned comments for design teams. Zero config, no server, git is the backend.",
5
5
  "keywords": ["design", "preview", "branches", "collaboration", "cli"],
6
6
  "license": "MIT",
package/public/app.js CHANGED
@@ -15,10 +15,13 @@ const state = {
15
15
  selected: null, // { user, branch }
16
16
  viewport: 'desktop',
17
17
  commentMode: false,
18
- pendingPin: null, // { xPct, yPct }
18
+ pendingPin: null, // { xPct, yPct, selector?, elementLabel?, relX?, relY?, route? }
19
19
  focusedComment: null,
20
20
  previewStatus: null,
21
21
  frameUrl: null,
22
+ bridge: false, // true when the injected inspector answered from inside the iframe
23
+ route: '/',
24
+ railFilter: '',
22
25
  };
23
26
 
24
27
  const VIEWPORTS = [
@@ -79,62 +82,81 @@ function renderRail() {
79
82
 
80
83
  const sections = el('rail-sections');
81
84
  const frag = document.createDocumentFragment();
85
+ const filter = (state.railFilter || '').trim().toLowerCase();
86
+
87
+ // One section per person. Shared branches render bright; branches that
88
+ // exist on origin without being shared render dim under the same author.
89
+ const people = new Map(); // slug -> { slug, name, items: [{user, branch, time, shared}] }
90
+ const person = (slug, name) => {
91
+ if (!people.has(slug)) people.set(slug, { slug, name, items: [] });
92
+ return people.get(slug);
93
+ };
82
94
 
83
95
  const shares = b.shares.slice().sort((x, y) => (y.updatedAt || 0) - (x.updatedAt || 0));
84
- const byUser = new Map();
85
96
  for (const s of shares) {
86
- if (!byUser.has(s.user)) byUser.set(s.user, { name: s.name, items: [] });
87
- byUser.get(s.user).items.push(s);
97
+ person(s.user, s.name).items.push({
98
+ user: s.user, branch: s.branch, shared: true,
99
+ time: b.heads[s.branch] ? b.heads[s.branch].time : s.updatedAt / 1000,
100
+ });
101
+ }
102
+ for (const o of b.otherBranches) {
103
+ const slug = slugify(o.author);
104
+ person(slug, people.has(slug) ? people.get(slug).name : (o.author || 'someone'))
105
+ .items.push({ user: slug, branch: o.branch, shared: false, time: o.time });
88
106
  }
89
107
 
90
- // Me first
91
- const order = [...byUser.keys()].sort((x, y) => (x === b.me.slug ? -1 : y === b.me.slug ? 1 : 0));
92
-
93
- const ownShared = byUser.has(b.me.slug) && byUser.get(b.me.slug).items.some((s) => s.branch === b.ownBranch);
108
+ const me = people.get(b.me.slug);
109
+ const ownShared = !!(me && me.items.some((i) => i.branch === b.ownBranch && i.shared));
94
110
  if (b.ownBranch && !ownShared) {
95
- const label = document.createElement('div');
96
- label.className = 'rail-section-label';
97
- label.textContent = 'you';
98
- frag.appendChild(label);
99
- frag.appendChild(branchRow({
100
- user: b.me.slug, branch: b.ownBranch, own: true,
111
+ const mine = person(b.me.slug, b.me.name);
112
+ mine.items = mine.items.filter((i) => i.branch !== b.ownBranch);
113
+ mine.items.unshift({
114
+ user: b.me.slug, branch: b.ownBranch, shared: true, own: true,
101
115
  time: b.heads[b.ownBranch] ? b.heads[b.ownBranch].time : null,
102
- }));
103
- const cta = document.createElement('button');
104
- cta.className = 'share-cta';
105
- cta.innerHTML = `${icons.plus} share ${escapeHtml(b.ownBranch)} with the board`;
106
- cta.onclick = async () => {
107
- await api('/api/share', { branch: b.ownBranch });
108
- toast('Shared with the team board');
109
- refresh();
110
- };
111
- frag.appendChild(cta);
116
+ });
112
117
  }
113
118
 
114
- for (const user of order) {
115
- const group = byUser.get(user);
119
+ const latest = (p) => Math.max(...p.items.map((i) => i.time || 0), 0);
120
+ const order = [...people.values()].sort((x, y) => {
121
+ if (x.slug === b.me.slug) return -1;
122
+ if (y.slug === b.me.slug) return 1;
123
+ const xs = x.items.some((i) => i.shared), ys = y.items.some((i) => i.shared);
124
+ if (xs !== ys) return xs ? -1 : 1;
125
+ return latest(y) - latest(x);
126
+ });
127
+
128
+ let shown = 0;
129
+ for (const p of order) {
130
+ const items = p.items.filter((i) =>
131
+ !filter || i.branch.toLowerCase().includes(filter) || p.name.toLowerCase().includes(filter));
132
+ if (!items.length) continue;
133
+ shown += items.length;
134
+
116
135
  const label = document.createElement('div');
117
136
  label.className = 'rail-section-label';
118
- label.textContent = user === b.me.slug ? `${group.name} (you)` : group.name;
137
+ label.textContent = p.slug === b.me.slug ? `${p.name} (you)` : p.name;
119
138
  frag.appendChild(label);
120
- for (const s of group.items) {
121
- frag.appendChild(branchRow({
122
- user: s.user, branch: s.branch,
123
- time: b.heads[s.branch] ? b.heads[s.branch].time : s.updatedAt / 1000,
124
- }));
139
+ for (const i of items) {
140
+ frag.appendChild(branchRow({ user: i.user, branch: i.branch, time: i.time, dim: !i.shared }));
141
+ }
142
+ if (p.slug === b.me.slug && b.ownBranch && !ownShared && (!filter || b.ownBranch.toLowerCase().includes(filter))) {
143
+ const cta = document.createElement('button');
144
+ cta.className = 'share-cta';
145
+ cta.innerHTML = `${icons.plus} share ${escapeHtml(b.ownBranch)} with the board`;
146
+ cta.onclick = async () => {
147
+ await api('/api/share', { branch: b.ownBranch });
148
+ toast('Shared with the team board');
149
+ refresh();
150
+ };
151
+ frag.appendChild(cta);
125
152
  }
126
153
  }
127
154
 
128
- if (b.otherBranches.length) {
129
- const label = document.createElement('div');
130
- label.className = 'rail-section-label';
131
- label.textContent = 'other branches';
132
- frag.appendChild(label);
133
- for (const o of b.otherBranches.slice(0, 12)) {
134
- frag.appendChild(branchRow({
135
- user: slugify(o.author), branch: o.branch, time: o.time, dim: true,
136
- }));
137
- }
155
+ if (!shown) {
156
+ const empty = document.createElement('div');
157
+ empty.className = 'empty-note';
158
+ empty.textContent = filter ? 'Nothing matches this filter.' : 'No branches yet.';
159
+ frag.appendChild(empty);
138
160
  }
139
161
 
140
162
  sections.replaceChildren(frag);
@@ -183,6 +205,7 @@ async function select(user, branch) {
183
205
  state.pendingPin = null;
184
206
  state.focusedComment = null;
185
207
  state.frameUrl = null;
208
+ state.bridge = false;
186
209
  location.hash = `#/u/${encodeURIComponent(user)}/${encodeURIComponent(branch)}`;
187
210
  el('frame').hidden = true;
188
211
  el('frame').src = 'about:blank';
@@ -228,7 +251,9 @@ function renderStage() {
228
251
  }
229
252
 
230
253
  const share = b.shares.find((s) => s.user === sel.user && s.branch === sel.branch);
231
- const authorName = share ? share.name : (sel.user === b.me.slug ? b.me.name : sel.user);
254
+ const authorName = share ? share.name
255
+ : sel.user === b.me.slug ? b.me.name
256
+ : (b.heads[sel.branch] && b.heads[sel.branch].author) || sel.user;
232
257
  const head = b.heads[sel.branch];
233
258
  el('stage-title').textContent = sel.branch;
234
259
  el('stage-meta').textContent = `${authorName}${head ? ' · updated ' + timeAgo(head.time) : ''}${sel.user === b.me.slug && sel.branch === b.ownBranch ? ' · your working copy' : ''}`;
@@ -263,19 +288,78 @@ function renderStage() {
263
288
  }
264
289
 
265
290
  const cm = el('btn-comment-mode');
266
- cm.innerHTML = `${icons.message} <span>${state.commentMode ? 'Click to place pin' : 'Comment'}</span>`;
291
+ cm.innerHTML = `${icons.message} <span>${state.commentMode ? 'Click an element to pin' : 'Comment'}</span>`;
267
292
  cm.classList.toggle('active-mode', state.commentMode);
268
- el('pin-layer').classList.toggle('capture', state.commentMode);
293
+ // The in-page inspector handles hover + capture when present; the overlay
294
+ // only captures clicks as a fallback for previews the proxy could not reach.
295
+ el('pin-layer').classList.toggle('capture', state.commentMode && !state.bridge);
296
+ sendToFrame({ ds: 'mode', comment: state.commentMode });
269
297
 
270
298
  renderPins();
271
299
  }
272
300
 
301
+ function sendToFrame(msg) {
302
+ const frame = el('frame');
303
+ if (frame.contentWindow) {
304
+ try { frame.contentWindow.postMessage(msg, '*'); } catch { /* not ready */ }
305
+ }
306
+ }
307
+
308
+ function pushCommentsToFrame() {
309
+ const sel = state.selected;
310
+ if (!sel || !state.bridge) return;
311
+ const items = branchComments(sel.branch).map((c, i) => ({
312
+ id: c.id,
313
+ n: i + 1,
314
+ selector: c.selector || null,
315
+ relX: c.relX,
316
+ relY: c.relY,
317
+ xPct: c.xPct,
318
+ yPct: c.yPct,
319
+ resolved: !!c.resolvedAt,
320
+ }));
321
+ sendToFrame({ ds: 'comments', items, focusedId: state.focusedComment });
322
+ }
323
+
324
+ window.addEventListener('message', (e) => {
325
+ const frame = el('frame');
326
+ if (e.source !== frame.contentWindow) return;
327
+ const d = e.data || {};
328
+ if (d.ds === 'ready') {
329
+ state.bridge = true;
330
+ state.route = d.route || '/';
331
+ sendToFrame({ ds: 'mode', comment: state.commentMode });
332
+ pushCommentsToFrame();
333
+ renderStage();
334
+ } else if (d.ds === 'pin') {
335
+ state.pendingPin = {
336
+ xPct: d.xPct, yPct: d.yPct,
337
+ selector: d.selector, elementLabel: d.elementLabel,
338
+ relX: d.relX, relY: d.relY, route: d.route,
339
+ };
340
+ const composer = el('composer');
341
+ composer.hidden = false;
342
+ const wrap = el('frame-wrap');
343
+ composer.style.left = Math.min(d.clientX, wrap.clientWidth - 260) + 'px';
344
+ composer.style.top = Math.min(d.clientY + 14, wrap.clientHeight - 120) + 'px';
345
+ el('composer-text').value = '';
346
+ el('composer-text').focus();
347
+ } else if (d.ds === 'pin-click') {
348
+ state.focusedComment = d.id;
349
+ renderComments();
350
+ pushCommentsToFrame();
351
+ const card = document.querySelector(`[data-comment="${d.id}"]`);
352
+ if (card) card.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
353
+ }
354
+ });
355
+
273
356
  /* ---------- pins ---------- */
274
357
 
275
358
  function renderPins() {
276
359
  const layer = el('pin-layer');
277
360
  layer.querySelectorAll('.pin').forEach((p) => p.remove());
278
361
  const sel = state.selected;
362
+ if (state.bridge) { pushCommentsToFrame(); return; }
279
363
  if (!sel || el('frame').hidden) return;
280
364
  const comments = branchComments(sel.branch);
281
365
  comments.forEach((c, i) => {
@@ -317,12 +401,17 @@ async function postComment() {
317
401
  const sel = state.selected;
318
402
  if (!text || !sel || !state.pendingPin) return;
319
403
  const rect = el('pin-layer').getBoundingClientRect();
404
+ const pin = state.pendingPin;
320
405
  await api('/api/comments', {
321
406
  targetUser: sel.user,
322
407
  branch: sel.branch,
323
- route: state.frameUrl ? new URL(state.frameUrl).pathname : '/',
324
- xPct: state.pendingPin.xPct,
325
- yPct: state.pendingPin.yPct,
408
+ route: pin.route || state.route || '/',
409
+ xPct: pin.xPct,
410
+ yPct: pin.yPct,
411
+ selector: pin.selector || null,
412
+ elementLabel: pin.elementLabel || null,
413
+ relX: pin.relX,
414
+ relY: pin.relY,
326
415
  viewportLabel: state.viewport,
327
416
  viewportW: Math.round(rect.width),
328
417
  viewportH: Math.round(rect.height),
@@ -400,6 +489,7 @@ function commentCard(c, num) {
400
489
  <span class="comment-num">${num}</span>
401
490
  </div>
402
491
  <div class="comment-text">${escapeHtml(c.text)}</div>
492
+ ${c.elementLabel ? `<div class="comment-anchor">${escapeHtml(c.elementLabel)}</div>` : ''}
403
493
  <div class="comment-meta">
404
494
  <span>${timeAgo(c.createdAt / 1000)}</span>
405
495
  ${c.viewportLabel ? `<span>· ${escapeHtml(c.viewportLabel)}</span>` : ''}
@@ -443,6 +533,7 @@ function commentCard(c, num) {
443
533
  if (e.target === reply || e.target.tagName === 'BUTTON') return;
444
534
  state.focusedComment = c.id;
445
535
  renderPins();
536
+ pushCommentsToFrame();
446
537
  document.querySelectorAll('.comment-card').forEach((x) => x.classList.remove('focused'));
447
538
  card.classList.add('focused');
448
539
  };
@@ -514,6 +605,16 @@ function parseHash() {
514
605
  }
515
606
 
516
607
  (async function boot() {
608
+ // Each iframe navigation starts a fresh document; assume no inspector until
609
+ // the injected script announces itself again.
610
+ el('frame').addEventListener('load', () => {
611
+ state.bridge = false;
612
+ setTimeout(() => renderStage(), 300);
613
+ });
614
+ el('branch-filter').addEventListener('input', (e) => {
615
+ state.railFilter = e.target.value;
616
+ renderRail();
617
+ });
517
618
  renderViewportSeg();
518
619
  await refresh();
519
620
  const target = parseHash();
package/public/index.html CHANGED
@@ -13,6 +13,10 @@
13
13
  <span class="logo-mark"></span>
14
14
  <span id="repo-name" class="repo-name">…</span>
15
15
  </div>
16
+ <div class="rail-filter">
17
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
18
+ <input id="branch-filter" placeholder="Filter people or branches" spellcheck="false" autocomplete="off">
19
+ </div>
16
20
  <div id="rail-sections"></div>
17
21
  <div class="rail-foot">
18
22
  <div id="sync-status" class="sync-status"></div>
@@ -0,0 +1,233 @@
1
+ /* design-share in-page inspector.
2
+ Injected into previews by the daemon proxy. Draws the hover highlight and
3
+ tooltip during comment mode, anchors pins to real elements, and talks to
4
+ the dashboard through postMessage. Inert when not framed by the dashboard. */
5
+ (function () {
6
+ 'use strict';
7
+ if (window.top === window || window.__designShareInspector) return;
8
+ window.__designShareInspector = true;
9
+
10
+ var Z = 2147483000;
11
+ var commentMode = false;
12
+ var comments = [];
13
+ var focusedId = null;
14
+
15
+ /* ---------- chrome (highlight box, tooltip, pin layer) ---------- */
16
+
17
+ var css = [
18
+ '.__ds-box{position:fixed;pointer-events:none;z-index:' + Z + ';border:1.5px solid #2f7cf6;',
19
+ 'border-radius:3px;background:rgba(47,124,246,0.06);display:none;box-sizing:border-box;}',
20
+ '.__ds-tip{position:fixed;pointer-events:none;z-index:' + (Z + 2) + ';background:#16161a;color:#fff;',
21
+ 'font:12px/1.45 -apple-system,BlinkMacSystemFont,sans-serif;padding:6px 10px;border-radius:7px;',
22
+ 'max-width:340px;box-shadow:0 4px 16px rgba(0,0,0,0.3);display:none;white-space:nowrap;',
23
+ 'overflow:hidden;text-overflow:ellipsis;}',
24
+ '.__ds-tip b{font-weight:600;color:#9ec2ff;}',
25
+ '.__ds-pin{position:fixed;z-index:' + (Z + 1) + ';width:20px;height:20px;border-radius:50% 50% 50% 0;',
26
+ 'transform:translate(-3px,-17px) rotate(-45deg);background:#2f7cf6;color:#fff;border:2px solid #fff;',
27
+ 'box-shadow:0 1px 4px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;',
28
+ 'font:600 10px -apple-system,sans-serif;cursor:pointer;}',
29
+ '.__ds-pin>span{transform:rotate(45deg);}',
30
+ '.__ds-pin.__ds-resolved{background:#2da562;opacity:0.55;}',
31
+ '.__ds-pin.__ds-focused{outline:2px solid #2f7cf6;outline-offset:2px;}',
32
+ '.__ds-comment-cursor,.__ds-comment-cursor *{cursor:crosshair !important;}',
33
+ ].join('');
34
+ var style = document.createElement('style');
35
+ style.textContent = css;
36
+ document.documentElement.appendChild(style);
37
+
38
+ var box = document.createElement('div');
39
+ box.className = '__ds-box';
40
+ var tip = document.createElement('div');
41
+ tip.className = '__ds-tip';
42
+ document.documentElement.appendChild(box);
43
+ document.documentElement.appendChild(tip);
44
+ var pinLayer = document.createElement('div');
45
+ document.documentElement.appendChild(pinLayer);
46
+
47
+ /* ---------- element naming, selectors ---------- */
48
+
49
+ var FRIENDLY = {
50
+ p: 'paragraph', a: 'link', img: 'image', button: 'button', input: 'input',
51
+ textarea: 'input', select: 'input', ul: 'list', ol: 'list', li: 'list item',
52
+ nav: 'navigation', svg: 'icon', video: 'video', form: 'form', table: 'table',
53
+ label: 'label', header: 'header', footer: 'footer', section: 'section',
54
+ };
55
+ function friendlyName(el) {
56
+ var t = el.tagName.toLowerCase();
57
+ if (/^h[1-6]$/.test(t)) return 'heading';
58
+ return FRIENDLY[t] || t;
59
+ }
60
+
61
+ function labelFor(el) {
62
+ var name = friendlyName(el);
63
+ var text = (el.innerText || el.getAttribute('alt') || el.getAttribute('aria-label') || '')
64
+ .replace(/\s+/g, ' ').trim();
65
+ if (text.length > 42) text = text.slice(0, 42) + '…';
66
+ return { name: name, text: text };
67
+ }
68
+
69
+ function cssPath(el) {
70
+ if (el.id) return '#' + CSS.escape(el.id);
71
+ var parts = [];
72
+ var node = el;
73
+ while (node && node.nodeType === 1 && node !== document.body && parts.length < 6) {
74
+ var tag = node.tagName.toLowerCase();
75
+ if (node.id) { parts.unshift('#' + CSS.escape(node.id)); break; }
76
+ var index = 1, sib = node;
77
+ while ((sib = sib.previousElementSibling)) {
78
+ if (sib.tagName === node.tagName) index++;
79
+ }
80
+ parts.unshift(tag + ':nth-of-type(' + index + ')');
81
+ node = node.parentElement;
82
+ }
83
+ return parts.join(' > ');
84
+ }
85
+
86
+ function isOurs(el) {
87
+ return el && el.className && typeof el.className === 'string' && el.className.indexOf('__ds-') !== -1;
88
+ }
89
+
90
+ /* ---------- hover highlight ---------- */
91
+
92
+ var hoverEl = null;
93
+
94
+ function showHighlight(el, x, y) {
95
+ var r = el.getBoundingClientRect();
96
+ box.style.display = 'block';
97
+ box.style.left = r.left + 'px';
98
+ box.style.top = r.top + 'px';
99
+ box.style.width = r.width + 'px';
100
+ box.style.height = r.height + 'px';
101
+
102
+ var info = labelFor(el);
103
+ tip.innerHTML = '<b>' + info.name + '</b>' + (info.text ? ': “' + escapeHtml(info.text) + '”' : '');
104
+ tip.style.display = 'block';
105
+ var tw = tip.offsetWidth, th = tip.offsetHeight;
106
+ var tx = Math.min(Math.max(8, x + 12), window.innerWidth - tw - 8);
107
+ var ty = y + 16 + th > window.innerHeight - 8 ? y - th - 10 : y + 16;
108
+ tip.style.left = tx + 'px';
109
+ tip.style.top = ty + 'px';
110
+ }
111
+
112
+ function hideHighlight() {
113
+ box.style.display = 'none';
114
+ tip.style.display = 'none';
115
+ hoverEl = null;
116
+ }
117
+
118
+ function escapeHtml(s) {
119
+ return s.replace(/[&<>"]/g, function (c) {
120
+ return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c];
121
+ });
122
+ }
123
+
124
+ document.addEventListener('mousemove', function (e) {
125
+ if (!commentMode) return;
126
+ var el = e.target;
127
+ if (isOurs(el) || el === document.body || el === document.documentElement) { hideHighlight(); return; }
128
+ hoverEl = el;
129
+ showHighlight(el, e.clientX, e.clientY);
130
+ }, true);
131
+
132
+ document.addEventListener('mouseleave', hideHighlight, true);
133
+
134
+ /* ---------- click to pin ---------- */
135
+
136
+ ['click', 'mousedown', 'mouseup'].forEach(function (type) {
137
+ document.addEventListener(type, function (e) {
138
+ if (!commentMode) return;
139
+ if (isOurs(e.target)) return; // pins stay clickable
140
+ e.preventDefault();
141
+ e.stopPropagation();
142
+ if (type !== 'click') return;
143
+ var el = hoverEl || e.target;
144
+ var r = el.getBoundingClientRect();
145
+ var info = labelFor(el);
146
+ parent.postMessage({
147
+ ds: 'pin',
148
+ selector: cssPath(el),
149
+ elementLabel: info.name + (info.text ? ': “' + info.text + '”' : ''),
150
+ relX: r.width ? (e.clientX - r.left) / r.width : 0.5,
151
+ relY: r.height ? (e.clientY - r.top) / r.height : 0.5,
152
+ xPct: (e.clientX / window.innerWidth) * 100,
153
+ yPct: (e.clientY / window.innerHeight) * 100,
154
+ clientX: e.clientX,
155
+ clientY: e.clientY,
156
+ route: location.pathname,
157
+ }, '*');
158
+ hideHighlight();
159
+ }, true);
160
+ });
161
+
162
+ /* ---------- pins anchored to elements ---------- */
163
+
164
+ function renderPins() {
165
+ pinLayer.innerHTML = '';
166
+ comments.forEach(function (c) {
167
+ if (c.resolved && c.id !== focusedId) return;
168
+ var x = null, y = null;
169
+ var el = null;
170
+ if (c.selector) {
171
+ try { el = document.querySelector(c.selector); } catch (err) { el = null; }
172
+ }
173
+ if (el) {
174
+ var r = el.getBoundingClientRect();
175
+ if (r.width || r.height) {
176
+ x = r.left + (c.relX == null ? 0.5 : c.relX) * r.width;
177
+ y = r.top + (c.relY == null ? 0.5 : c.relY) * r.height;
178
+ }
179
+ }
180
+ if (x == null && c.xPct != null) {
181
+ x = (c.xPct / 100) * window.innerWidth;
182
+ y = (c.yPct / 100) * window.innerHeight;
183
+ }
184
+ if (x == null) return;
185
+ var pin = document.createElement('div');
186
+ pin.className = '__ds-pin' + (c.resolved ? ' __ds-resolved' : '') + (c.id === focusedId ? ' __ds-focused' : '');
187
+ pin.style.left = x + 'px';
188
+ pin.style.top = y + 'px';
189
+ pin.innerHTML = '<span>' + c.n + '</span>';
190
+ pin.addEventListener('click', function (e) {
191
+ e.stopPropagation();
192
+ parent.postMessage({ ds: 'pin-click', id: c.id }, '*');
193
+ });
194
+ pinLayer.appendChild(pin);
195
+ });
196
+ }
197
+
198
+ var repaintQueued = false;
199
+ function queueRepaint() {
200
+ if (repaintQueued) return;
201
+ repaintQueued = true;
202
+ requestAnimationFrame(function () { repaintQueued = false; renderPins(); });
203
+ }
204
+ window.addEventListener('scroll', queueRepaint, true);
205
+ window.addEventListener('resize', queueRepaint);
206
+ setInterval(queueRepaint, 900);
207
+
208
+ /* ---------- bridge ---------- */
209
+
210
+ window.addEventListener('message', function (e) {
211
+ var d = e.data || {};
212
+ if (d.ds === 'mode') {
213
+ commentMode = !!d.comment;
214
+ document.documentElement.classList.toggle('__ds-comment-cursor', commentMode);
215
+ if (!commentMode) hideHighlight();
216
+ } else if (d.ds === 'comments') {
217
+ comments = d.items || [];
218
+ focusedId = d.focusedId || null;
219
+ renderPins();
220
+ }
221
+ });
222
+
223
+ function announce() {
224
+ parent.postMessage({ ds: 'ready', route: location.pathname }, '*');
225
+ }
226
+ announce();
227
+ // The parent resets its bridge flag on iframe load, which fires after the
228
+ // first announce. Announce again once the page is fully loaded.
229
+ window.addEventListener('load', announce);
230
+ window.addEventListener('popstate', announce);
231
+ var push = history.pushState;
232
+ history.pushState = function () { push.apply(this, arguments); announce(); };
233
+ })();
package/public/style.css CHANGED
@@ -67,6 +67,29 @@ button { font: inherit; cursor: pointer; }
67
67
 
68
68
  .repo-name { font-size: 13.5px; font-weight: 600; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
69
69
 
70
+ .rail-filter {
71
+ display: flex;
72
+ align-items: center;
73
+ gap: 7px;
74
+ margin: 2px 12px 4px;
75
+ padding: 4px 9px;
76
+ border: 1px solid var(--border);
77
+ border-radius: var(--radius);
78
+ color: var(--text-3);
79
+ background: #fff;
80
+ }
81
+ .rail-filter:focus-within { border-color: #c9c9cf; color: var(--text-2); }
82
+ .rail-filter input {
83
+ border: 0;
84
+ outline: none;
85
+ background: transparent;
86
+ font: inherit;
87
+ font-size: 12px;
88
+ color: var(--text);
89
+ width: 100%;
90
+ }
91
+ .rail-filter input::placeholder { color: var(--text-3); }
92
+
70
93
  #rail-sections { flex: 1; overflow-y: auto; padding: 4px 8px 12px; }
71
94
 
72
95
  .rail-section-label {
@@ -370,6 +393,19 @@ button { font: inherit; cursor: pointer; }
370
393
  .comment-text { font-size: 12.5px; color: var(--text); }
371
394
  .comment-meta { font-size: 11px; color: var(--text-3); margin-top: 5px; display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
372
395
 
396
+ .comment-anchor {
397
+ margin-top: 5px;
398
+ font-size: 11px;
399
+ color: var(--text-2);
400
+ background: #f6f6f7;
401
+ border: 1px solid var(--border);
402
+ border-radius: 5px;
403
+ padding: 3px 7px;
404
+ overflow: hidden;
405
+ text-overflow: ellipsis;
406
+ white-space: nowrap;
407
+ }
408
+
373
409
  .chip {
374
410
  display: inline-flex;
375
411
  align-items: center;
package/src/previews.js CHANGED
@@ -4,6 +4,7 @@ import http from 'node:http';
4
4
  import net from 'node:net';
5
5
  import { spawn } from 'node:child_process';
6
6
  import { git, tryGit } from './git.js';
7
+ import { startInjectingProxy } from './proxy.js';
7
8
 
8
9
  const URL_RE = /(https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0):\d+[^\s'"]*)/;
9
10
  const START_TIMEOUT_MS = 90_000;
@@ -125,6 +126,25 @@ export class PreviewManager {
125
126
  return this.start(key, { cwd: wt, commit, worktree: wt });
126
127
  }
127
128
 
129
+ // Comments need to see inside the preview, so the dashboard never talks to
130
+ // the dev server directly. Each preview gets a proxy that injects the
131
+ // inspector script. If the proxy fails, fall back to the direct URL: the
132
+ // preview still works, only element pinning degrades.
133
+ attachProxy(rec, targetUrl) {
134
+ if (rec.targetUrl) return;
135
+ rec.targetUrl = targetUrl;
136
+ startInjectingProxy(targetUrl)
137
+ .then(({ server, url }) => {
138
+ rec.proxyServer = server;
139
+ rec.url = url;
140
+ rec.status = 'ready';
141
+ })
142
+ .catch(() => {
143
+ rec.url = targetUrl;
144
+ rec.status = 'ready';
145
+ });
146
+ }
147
+
128
148
  record(key) {
129
149
  let rec = this.previews.get(key);
130
150
  if (!rec) {
@@ -138,6 +158,8 @@ export class PreviewManager {
138
158
  const rec = this.record(key);
139
159
  rec.status = 'starting';
140
160
  rec.url = null;
161
+ rec.targetUrl = null;
162
+ if (rec.proxyServer) { try { rec.proxyServer.close(); } catch { /* gone */ } rec.proxyServer = null; }
141
163
  rec.error = null;
142
164
  rec.log = '';
143
165
  rec.commit = commit;
@@ -156,8 +178,7 @@ export class PreviewManager {
156
178
  const dir = path.resolve(cwd, cfg.dir || '.');
157
179
  const server = http.createServer((req, res) => serveStatic(dir, req, res));
158
180
  server.listen(0, '127.0.0.1', () => {
159
- rec.url = `http://localhost:${server.address().port}/`;
160
- rec.status = 'ready';
181
+ this.attachProxy(rec, `http://localhost:${server.address().port}/`);
161
182
  });
162
183
  rec.server = server;
163
184
  return rec;
@@ -176,11 +197,11 @@ export class PreviewManager {
176
197
  const onData = (buf) => {
177
198
  const text = stripAnsi(buf.toString());
178
199
  rec.log = (rec.log + text).slice(-8000);
179
- if (!rec.url) {
200
+ if (!rec.targetUrl) {
180
201
  const m = text.match(URL_RE);
181
202
  if (m) {
182
- rec.url = m[1].replace('0.0.0.0', 'localhost').replace('127.0.0.1', 'localhost');
183
- rec.status = 'ready';
203
+ const targetUrl = m[1].replace('0.0.0.0', 'localhost').replace('127.0.0.1', 'localhost');
204
+ this.attachProxy(rec, targetUrl);
184
205
  }
185
206
  }
186
207
  };
@@ -214,6 +235,9 @@ export class PreviewManager {
214
235
  if (rec.server) {
215
236
  try { rec.server.close(); } catch { /* gone */ }
216
237
  }
238
+ if (rec.proxyServer) {
239
+ try { rec.proxyServer.close(); } catch { /* gone */ }
240
+ }
217
241
  }
218
242
  }
219
243
  }
package/src/proxy.js ADDED
@@ -0,0 +1,94 @@
1
+ import fs from 'node:fs';
2
+ import net from 'node:net';
3
+ import http from 'node:http';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ const INSPECT_PATH = '/__design-share__/inspect.js';
8
+ const INSPECT_FILE = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public', 'inspect.js');
9
+ const INJECT_TAG = `<script src="${INSPECT_PATH}"></script>`;
10
+
11
+ // Sits in front of a preview dev server on its own port. Mirrors every path
12
+ // 1:1 so absolute asset URLs keep working, injects the inspector script into
13
+ // HTML responses, and pipes websocket upgrades straight through so hot module
14
+ // reload keeps working.
15
+ export function startInjectingProxy(targetUrl) {
16
+ const target = new URL(targetUrl);
17
+
18
+ const server = http.createServer((req, res) => {
19
+ if (req.url === INSPECT_PATH) {
20
+ res.writeHead(200, { 'Content-Type': 'text/javascript; charset=utf-8', 'Cache-Control': 'no-store' });
21
+ return res.end(fs.readFileSync(INSPECT_FILE));
22
+ }
23
+
24
+ const headers = { ...req.headers, host: target.host };
25
+ delete headers['accept-encoding']; // keep HTML plain so injection is trivial
26
+
27
+ const upstream = http.request({
28
+ hostname: target.hostname,
29
+ port: target.port,
30
+ path: req.url,
31
+ method: req.method,
32
+ headers,
33
+ }, (up) => {
34
+ const type = up.headers['content-type'] || '';
35
+ const outHeaders = { ...up.headers };
36
+
37
+ // Point same-host redirects back at the proxy
38
+ if (outHeaders.location) {
39
+ outHeaders.location = outHeaders.location.replace(target.host, `localhost:${server.address().port}`);
40
+ }
41
+
42
+ if (type.includes('text/html')) {
43
+ const chunks = [];
44
+ up.on('data', (c) => chunks.push(c));
45
+ up.on('end', () => {
46
+ let body = Buffer.concat(chunks).toString('utf8');
47
+ if (body.includes('</body>')) {
48
+ body = body.replace('</body>', `${INJECT_TAG}</body>`);
49
+ } else if (body.includes('</html>')) {
50
+ body = body.replace('</html>', `${INJECT_TAG}</html>`);
51
+ } else {
52
+ body += INJECT_TAG;
53
+ }
54
+ delete outHeaders['content-length'];
55
+ delete outHeaders['content-encoding'];
56
+ res.writeHead(up.statusCode, outHeaders);
57
+ res.end(body);
58
+ });
59
+ } else {
60
+ res.writeHead(up.statusCode, outHeaders);
61
+ up.pipe(res);
62
+ }
63
+ });
64
+
65
+ upstream.on('error', () => {
66
+ res.writeHead(502, { 'Content-Type': 'text/plain' });
67
+ res.end('preview process is not responding');
68
+ });
69
+ req.pipe(upstream);
70
+ });
71
+
72
+ // Raw tcp passthrough for websockets (vite/next HMR)
73
+ server.on('upgrade', (req, socket, head) => {
74
+ const conn = net.connect(Number(target.port), target.hostname, () => {
75
+ const headerLines = Object.entries(req.headers)
76
+ .map(([k, v]) => `${k}: ${k === 'host' ? target.host : v}`)
77
+ .join('\r\n');
78
+ conn.write(`${req.method} ${req.url} HTTP/1.1\r\n${headerLines}\r\n\r\n`);
79
+ if (head && head.length) conn.write(head);
80
+ socket.pipe(conn);
81
+ conn.pipe(socket);
82
+ });
83
+ const drop = () => { socket.destroy(); conn.destroy(); };
84
+ conn.on('error', drop);
85
+ socket.on('error', drop);
86
+ });
87
+
88
+ return new Promise((resolve, reject) => {
89
+ server.once('error', reject);
90
+ server.listen(0, '127.0.0.1', () => {
91
+ resolve({ server, url: `http://localhost:${server.address().port}/` });
92
+ });
93
+ });
94
+ }
package/src/server.js CHANGED
@@ -128,6 +128,10 @@ export class DesignShareServer {
128
128
  route: body.route || '/',
129
129
  xPct: body.xPct,
130
130
  yPct: body.yPct,
131
+ selector: typeof body.selector === 'string' ? body.selector.slice(0, 500) : null,
132
+ elementLabel: typeof body.elementLabel === 'string' ? body.elementLabel.slice(0, 200) : null,
133
+ relX: typeof body.relX === 'number' ? body.relX : null,
134
+ relY: typeof body.relY === 'number' ? body.relY : null,
131
135
  viewportLabel: body.viewportLabel || 'desktop',
132
136
  viewportW: body.viewportW,
133
137
  viewportH: body.viewportH,