design-share 0.1.0 → 0.2.2

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.2",
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);
@@ -166,12 +188,20 @@ function branchRow({ user, branch, time, dim, own }) {
166
188
  else if (isOwnServing && prev && prev.status === 'ready') dotCls = 'live';
167
189
 
168
190
  const unresolved = openCount(branch);
191
+ const pr = b.prs && b.prs[branch];
169
192
  btn.innerHTML = `
170
193
  <span class="dot ${dotCls}"></span>
171
194
  <span class="branch-label">${escapeHtml(branch)}</span>
172
195
  ${unresolved ? `<span class="badge">${unresolved}</span>` : ''}
196
+ ${pr ? `<span class="pr-link" title="${escapeHtml(`PR #${pr.number}: ${pr.title || ''}`)}">#${pr.number}</span>` : ''}
173
197
  <span class="meta">${timeAgo(time)}</span>`;
174
198
  btn.onclick = () => select(user, branch);
199
+ if (pr) {
200
+ btn.querySelector('.pr-link').onclick = (e) => {
201
+ e.stopPropagation();
202
+ window.open(pr.url, '_blank');
203
+ };
204
+ }
175
205
  return btn;
176
206
  }
177
207
 
@@ -183,6 +213,7 @@ async function select(user, branch) {
183
213
  state.pendingPin = null;
184
214
  state.focusedComment = null;
185
215
  state.frameUrl = null;
216
+ state.bridge = false;
186
217
  location.hash = `#/u/${encodeURIComponent(user)}/${encodeURIComponent(branch)}`;
187
218
  el('frame').hidden = true;
188
219
  el('frame').src = 'about:blank';
@@ -228,7 +259,9 @@ function renderStage() {
228
259
  }
229
260
 
230
261
  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);
262
+ const authorName = share ? share.name
263
+ : sel.user === b.me.slug ? b.me.name
264
+ : (b.heads[sel.branch] && b.heads[sel.branch].author) || sel.user;
232
265
  const head = b.heads[sel.branch];
233
266
  el('stage-title').textContent = sel.branch;
234
267
  el('stage-meta').textContent = `${authorName}${head ? ' · updated ' + timeAgo(head.time) : ''}${sel.user === b.me.slug && sel.branch === b.ownBranch ? ' · your working copy' : ''}`;
@@ -263,19 +296,78 @@ function renderStage() {
263
296
  }
264
297
 
265
298
  const cm = el('btn-comment-mode');
266
- cm.innerHTML = `${icons.message} <span>${state.commentMode ? 'Click to place pin' : 'Comment'}</span>`;
299
+ cm.innerHTML = `${icons.message} <span>${state.commentMode ? 'Click an element to pin' : 'Comment'}</span>`;
267
300
  cm.classList.toggle('active-mode', state.commentMode);
268
- el('pin-layer').classList.toggle('capture', state.commentMode);
301
+ // The in-page inspector handles hover + capture when present; the overlay
302
+ // only captures clicks as a fallback for previews the proxy could not reach.
303
+ el('pin-layer').classList.toggle('capture', state.commentMode && !state.bridge);
304
+ sendToFrame({ ds: 'mode', comment: state.commentMode });
269
305
 
270
306
  renderPins();
271
307
  }
272
308
 
309
+ function sendToFrame(msg) {
310
+ const frame = el('frame');
311
+ if (frame.contentWindow) {
312
+ try { frame.contentWindow.postMessage(msg, '*'); } catch { /* not ready */ }
313
+ }
314
+ }
315
+
316
+ function pushCommentsToFrame() {
317
+ const sel = state.selected;
318
+ if (!sel || !state.bridge) return;
319
+ const items = branchComments(sel.branch).map((c, i) => ({
320
+ id: c.id,
321
+ n: i + 1,
322
+ selector: c.selector || null,
323
+ relX: c.relX,
324
+ relY: c.relY,
325
+ xPct: c.xPct,
326
+ yPct: c.yPct,
327
+ resolved: !!c.resolvedAt,
328
+ }));
329
+ sendToFrame({ ds: 'comments', items, focusedId: state.focusedComment });
330
+ }
331
+
332
+ window.addEventListener('message', (e) => {
333
+ const frame = el('frame');
334
+ if (e.source !== frame.contentWindow) return;
335
+ const d = e.data || {};
336
+ if (d.ds === 'ready') {
337
+ state.bridge = true;
338
+ state.route = d.route || '/';
339
+ sendToFrame({ ds: 'mode', comment: state.commentMode });
340
+ pushCommentsToFrame();
341
+ renderStage();
342
+ } else if (d.ds === 'pin') {
343
+ state.pendingPin = {
344
+ xPct: d.xPct, yPct: d.yPct,
345
+ selector: d.selector, elementLabel: d.elementLabel,
346
+ relX: d.relX, relY: d.relY, route: d.route,
347
+ };
348
+ const composer = el('composer');
349
+ composer.hidden = false;
350
+ const wrap = el('frame-wrap');
351
+ composer.style.left = Math.min(d.clientX, wrap.clientWidth - 260) + 'px';
352
+ composer.style.top = Math.min(d.clientY + 14, wrap.clientHeight - 120) + 'px';
353
+ el('composer-text').value = '';
354
+ el('composer-text').focus();
355
+ } else if (d.ds === 'pin-click') {
356
+ state.focusedComment = d.id;
357
+ renderComments();
358
+ pushCommentsToFrame();
359
+ const card = document.querySelector(`[data-comment="${d.id}"]`);
360
+ if (card) card.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
361
+ }
362
+ });
363
+
273
364
  /* ---------- pins ---------- */
274
365
 
275
366
  function renderPins() {
276
367
  const layer = el('pin-layer');
277
368
  layer.querySelectorAll('.pin').forEach((p) => p.remove());
278
369
  const sel = state.selected;
370
+ if (state.bridge) { pushCommentsToFrame(); return; }
279
371
  if (!sel || el('frame').hidden) return;
280
372
  const comments = branchComments(sel.branch);
281
373
  comments.forEach((c, i) => {
@@ -317,12 +409,17 @@ async function postComment() {
317
409
  const sel = state.selected;
318
410
  if (!text || !sel || !state.pendingPin) return;
319
411
  const rect = el('pin-layer').getBoundingClientRect();
412
+ const pin = state.pendingPin;
320
413
  await api('/api/comments', {
321
414
  targetUser: sel.user,
322
415
  branch: sel.branch,
323
- route: state.frameUrl ? new URL(state.frameUrl).pathname : '/',
324
- xPct: state.pendingPin.xPct,
325
- yPct: state.pendingPin.yPct,
416
+ route: pin.route || state.route || '/',
417
+ xPct: pin.xPct,
418
+ yPct: pin.yPct,
419
+ selector: pin.selector || null,
420
+ elementLabel: pin.elementLabel || null,
421
+ relX: pin.relX,
422
+ relY: pin.relY,
326
423
  viewportLabel: state.viewport,
327
424
  viewportW: Math.round(rect.width),
328
425
  viewportH: Math.round(rect.height),
@@ -400,6 +497,7 @@ function commentCard(c, num) {
400
497
  <span class="comment-num">${num}</span>
401
498
  </div>
402
499
  <div class="comment-text">${escapeHtml(c.text)}</div>
500
+ ${c.elementLabel ? `<div class="comment-anchor">${escapeHtml(c.elementLabel)}</div>` : ''}
403
501
  <div class="comment-meta">
404
502
  <span>${timeAgo(c.createdAt / 1000)}</span>
405
503
  ${c.viewportLabel ? `<span>· ${escapeHtml(c.viewportLabel)}</span>` : ''}
@@ -443,6 +541,7 @@ function commentCard(c, num) {
443
541
  if (e.target === reply || e.target.tagName === 'BUTTON') return;
444
542
  state.focusedComment = c.id;
445
543
  renderPins();
544
+ pushCommentsToFrame();
446
545
  document.querySelectorAll('.comment-card').forEach((x) => x.classList.remove('focused'));
447
546
  card.classList.add('focused');
448
547
  };
@@ -514,6 +613,16 @@ function parseHash() {
514
613
  }
515
614
 
516
615
  (async function boot() {
616
+ // Each iframe navigation starts a fresh document; assume no inspector until
617
+ // the injected script announces itself again.
618
+ el('frame').addEventListener('load', () => {
619
+ state.bridge = false;
620
+ setTimeout(() => renderStage(), 300);
621
+ });
622
+ el('branch-filter').addEventListener('input', (e) => {
623
+ state.railFilter = e.target.value;
624
+ renderRail();
625
+ });
517
626
  renderViewportSeg();
518
627
  await refresh();
519
628
  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 {
@@ -100,6 +123,18 @@ button { font: inherit; cursor: pointer; }
100
123
  .branch-row .meta { font-size: 11px; color: var(--text-3); flex: none; }
101
124
  .branch-row.dim { color: var(--text-2); }
102
125
 
126
+ .pr-link {
127
+ font-size: 10.5px;
128
+ color: var(--text-3);
129
+ border: 1px solid var(--border);
130
+ border-radius: 99px;
131
+ padding: 0 6px;
132
+ line-height: 16px;
133
+ flex: none;
134
+ background: #fff;
135
+ }
136
+ .pr-link:hover { color: var(--accent); border-color: var(--accent); }
137
+
103
138
  .dot { width: 7px; height: 7px; border-radius: 50%; flex: none; }
104
139
  .dot.live { background: var(--green); }
105
140
  .dot.idle { background: #d4d4d8; }
@@ -370,6 +405,19 @@ button { font: inherit; cursor: pointer; }
370
405
  .comment-text { font-size: 12.5px; color: var(--text); }
371
406
  .comment-meta { font-size: 11px; color: var(--text-3); margin-top: 5px; display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
372
407
 
408
+ .comment-anchor {
409
+ margin-top: 5px;
410
+ font-size: 11px;
411
+ color: var(--text-2);
412
+ background: #f6f6f7;
413
+ border: 1px solid var(--border);
414
+ border-radius: 5px;
415
+ padding: 3px 7px;
416
+ overflow: hidden;
417
+ text-overflow: ellipsis;
418
+ white-space: nowrap;
419
+ }
420
+
373
421
  .chip {
374
422
  display: inline-flex;
375
423
  align-items: center;
package/src/github.js ADDED
@@ -0,0 +1,24 @@
1
+ import { execFile } from 'node:child_process';
2
+
3
+ // PR links come from the gh CLI so no tokens are stored or handled: gh uses
4
+ // whoever is already logged in. Machines without gh (or without auth) simply
5
+ // return an empty map and the dashboard shows no PR links.
6
+ export function listOpenPRs(repoRoot) {
7
+ return new Promise((resolve) => {
8
+ execFile('gh', [
9
+ 'pr', 'list', '--state', 'open', '--limit', '200',
10
+ '--json', 'number,url,headRefName,title',
11
+ ], { cwd: repoRoot, timeout: 15_000 }, (err, stdout) => {
12
+ if (err) return resolve({});
13
+ try {
14
+ const byBranch = {};
15
+ for (const pr of JSON.parse(stdout)) {
16
+ byBranch[pr.headRefName] = { number: pr.number, url: pr.url, title: pr.title };
17
+ }
18
+ resolve(byBranch);
19
+ } catch {
20
+ resolve({});
21
+ }
22
+ });
23
+ });
24
+ }
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
@@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url';
5
5
  import { tryGit } from './git.js';
6
6
  import { currentBranch } from './detect.js';
7
7
  import { serveStatic } from './previews.js';
8
+ import { listOpenPRs } from './github.js';
8
9
 
9
10
  const PUBLIC_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public');
10
11
  const SYNC_INTERVAL_MS = 15_000;
@@ -36,6 +37,11 @@ export class DesignShareServer {
36
37
  this.port = port;
37
38
  this.server = null;
38
39
  this.timers = [];
40
+ this.prs = {};
41
+ }
42
+
43
+ async refreshPRs() {
44
+ this.prs = await listOpenPRs(this.repo.root);
39
45
  }
40
46
 
41
47
  async branchHeads() {
@@ -84,6 +90,7 @@ export class DesignShareServer {
84
90
  otherBranches: others,
85
91
  comments: Object.values(state.comments || {}),
86
92
  previews: this.previews.statuses(),
93
+ prs: this.prs,
87
94
  sync: {
88
95
  hasRemote: this.store.hasRemote,
89
96
  lastSync: this.store.lastSync,
@@ -128,6 +135,10 @@ export class DesignShareServer {
128
135
  route: body.route || '/',
129
136
  xPct: body.xPct,
130
137
  yPct: body.yPct,
138
+ selector: typeof body.selector === 'string' ? body.selector.slice(0, 500) : null,
139
+ elementLabel: typeof body.elementLabel === 'string' ? body.elementLabel.slice(0, 200) : null,
140
+ relX: typeof body.relX === 'number' ? body.relX : null,
141
+ relY: typeof body.relY === 'number' ? body.relY : null,
131
142
  viewportLabel: body.viewportLabel || 'desktop',
132
143
  viewportW: body.viewportW,
133
144
  viewportH: body.viewportH,
@@ -198,6 +209,8 @@ export class DesignShareServer {
198
209
  () => tryGit(this.repo.root, ['fetch', '--quiet', 'origin']),
199
210
  FETCH_INTERVAL_MS,
200
211
  ));
212
+ this.refreshPRs();
213
+ this.timers.push(setInterval(() => this.refreshPRs(), 180_000));
201
214
  resolve(port);
202
215
  });
203
216
  };