design-share 0.2.2 → 0.3.1

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.
package/README.md CHANGED
@@ -62,3 +62,7 @@ Quitting with Ctrl+C in the terminal is respected as a deliberate stop. Closing
62
62
  ## Requirements
63
63
 
64
64
  Node 18 or newer, git, and a repo with an origin remote (solo local mode works without one).
65
+
66
+ ---
67
+
68
+ Built by [Callebe](https://github.com/callebe01). If design-share saves your team some friction, you can [buy me a coffee](https://buymeacoffee.com/callebe).
@@ -1,5 +1,12 @@
1
1
  #!/usr/bin/env node
2
- import { run } from '../src/cli.js';
2
+ const major = Number(process.versions.node.split('.')[0]);
3
+ if (major < 18) {
4
+ console.error(`\n design-share needs Node 18 or newer. You are on ${process.versions.node}.`);
5
+ console.error(' Update at https://nodejs.org and run npx design-share again.\n');
6
+ process.exit(1);
7
+ }
8
+
9
+ const { run } = await import('../src/cli.js');
3
10
 
4
11
  run(process.argv.slice(2)).catch((err) => {
5
12
  console.error(`\n \x1b[31m✕\x1b[0m ${err.message}`);
@@ -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.1</string>
12
+ <string>0.3.0</string>
13
13
  <key>CFBundleShortVersionString</key>
14
- <string>0.1.1</string>
14
+ <string>0.3.0</string>
15
15
  <key>CFBundleExecutable</key>
16
16
  <string>DesignShare</string>
17
17
  <key>CFBundlePackageType</key>
package/package.json CHANGED
@@ -1,9 +1,15 @@
1
1
  {
2
2
  "name": "design-share",
3
- "version": "0.2.2",
3
+ "version": "0.3.1",
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",
7
+ "funding": "https://buymeacoffee.com/callebe",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/callebe01/design-share.git"
11
+ },
12
+ "homepage": "https://github.com/callebe01/design-share#readme",
7
13
  "type": "module",
8
14
  "bin": {
9
15
  "design-share": "bin/design-share.js"
package/public/app.js CHANGED
@@ -7,8 +7,124 @@ const icons = {
7
7
  message: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"/></svg>',
8
8
  check: '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>',
9
9
  plus: '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="M12 5v14"/></svg>',
10
+ eye: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/></svg>',
10
11
  };
11
12
 
13
+ /* ---------- PR panel (read only) ---------- */
14
+
15
+ function renderPrChip() {
16
+ const chip = el('pr-chip');
17
+ const sel = state.selected;
18
+ const pr = sel && state.board.prs ? state.board.prs[sel.branch] : null;
19
+ chip.hidden = !pr;
20
+ if (pr) {
21
+ const open = !el('pr-panel').hidden;
22
+ chip.innerHTML = `PR #${pr.number} <span class="chev">${open ? '▴' : '▾'}</span>`;
23
+ chip.title = pr.title || '';
24
+ } else {
25
+ el('pr-panel').hidden = true;
26
+ }
27
+ }
28
+
29
+ async function togglePrPanel(force) {
30
+ const panel = el('pr-panel');
31
+ const show = force !== undefined ? force : panel.hidden;
32
+ if (!show) { panel.hidden = true; renderPrChip(); return; }
33
+ const sel = state.selected;
34
+ if (!sel) return;
35
+ panel.innerHTML = '<div class="pr-loading"><div class="spinner"></div></div>';
36
+ panel.hidden = false;
37
+ renderPrChip();
38
+ const r = await fetch(`/api/pr?branch=${encodeURIComponent(sel.branch)}`).then((x) => x.json());
39
+ if (panel.hidden) return; // closed while loading
40
+ if (!r.pr) {
41
+ panel.innerHTML = '<div class="pr-loading">Could not load the PR right now.</div>';
42
+ return;
43
+ }
44
+ const pr = r.pr;
45
+ const stateCls = pr.state === 'OPEN' ? 'live' : pr.state === 'MERGED' ? 'merged' : 'closed';
46
+ panel.innerHTML = `
47
+ <div class="pr-head">
48
+ <span class="dot ${stateCls}"></span>
49
+ <span class="pr-title">${escapeHtml(pr.title)}</span>
50
+ <span class="pr-num">#${pr.number}</span>
51
+ <a class="pr-open" href="${escapeHtml(pr.url)}" target="_blank" rel="noopener">Open on GitHub</a>
52
+ </div>
53
+ <div class="pr-byline">${escapeHtml(pr.author)} · ${escapeHtml(pr.state.toLowerCase())} · ${timeAgo(Date.parse(pr.createdAt) / 1000)}</div>
54
+ ${pr.body ? `<div class="pr-body">${escapeHtml(pr.body)}</div>` : '<div class="pr-body dim-note">No description.</div>'}
55
+ ${pr.comments.length ? `
56
+ <div class="pr-comments-label">Comments (${pr.comments.length})</div>
57
+ ${pr.comments.map((c) => `
58
+ <div class="pr-comment">
59
+ <div class="pr-comment-head">
60
+ <span class="avatar">${escapeHtml((c.author || '?').slice(0, 1))}</span>
61
+ <b>${escapeHtml(c.author)}</b>
62
+ <span class="pr-comment-time">${timeAgo(Date.parse(c.createdAt) / 1000)}</span>
63
+ </div>
64
+ <div class="pr-comment-body">${escapeHtml(c.body)}</div>
65
+ </div>`).join('')}` : '<div class="dim-note" style="margin-top:8px">No comments on the PR yet.</div>'}
66
+ `;
67
+ }
68
+
69
+ /* ---------- review requests ---------- */
70
+
71
+ function branchRequests(branch) {
72
+ if (!state.board) return [];
73
+ return (state.board.requests || [])
74
+ .filter((r) => r.branch === branch)
75
+ .sort((a, b) => b.updatedAt - a.updatedAt);
76
+ }
77
+
78
+ function openRequestForMe(branch) {
79
+ const me = state.board.me.slug;
80
+ return branchRequests(branch).find((r) => r.toUser === me && !r.resolvedAt);
81
+ }
82
+
83
+ function renderRequestButton() {
84
+ const b = state.board;
85
+ const sel = state.selected;
86
+ const btn = el('btn-request');
87
+ const roster = (b.users || []).filter((u) => u.user !== b.me.slug);
88
+ const visible = !!sel && roster.length > 0;
89
+ btn.hidden = !visible;
90
+ if (visible) btn.innerHTML = `${icons.eye} <span>Request review</span>`;
91
+ }
92
+
93
+ function toggleRequestPopover(force) {
94
+ const pop = el('request-popover');
95
+ const show = force !== undefined ? force : pop.hidden;
96
+ if (!show) { pop.hidden = true; return; }
97
+ const b = state.board;
98
+ const sel = state.selected;
99
+ if (!sel) return;
100
+ const roster = (b.users || [])
101
+ .filter((u) => u.user !== b.me.slug)
102
+ .sort((x, y) => (y.lastSeen || 0) - (x.lastSeen || 0));
103
+ pop.innerHTML = '';
104
+ const head = document.createElement('div');
105
+ head.className = 'popover-head';
106
+ head.textContent = `Ask someone to review ${sel.branch}`;
107
+ pop.appendChild(head);
108
+ for (const u of roster) {
109
+ const already = branchRequests(sel.branch).some((r) => r.toUser === u.user && !r.resolvedAt && r.fromUser === b.me.slug);
110
+ const row = document.createElement('button');
111
+ row.className = 'popover-row';
112
+ row.innerHTML = `
113
+ <span class="avatar">${escapeHtml((u.name || u.user).slice(0, 1))}</span>
114
+ <span class="popover-name">${escapeHtml(u.name || u.user)}</span>
115
+ <span class="popover-meta">${already ? 'asked' : timeAgo((u.lastSeen || 0) / 1000)}</span>`;
116
+ row.disabled = already;
117
+ row.onclick = async () => {
118
+ await api('/api/requests', { branch: sel.branch, toUser: u.user, toName: u.name });
119
+ toggleRequestPopover(false);
120
+ toast(`Asked ${u.name || u.user} to review ${sel.branch}`);
121
+ refresh();
122
+ };
123
+ pop.appendChild(row);
124
+ }
125
+ pop.hidden = false;
126
+ }
127
+
12
128
  const el = (id) => document.getElementById(id);
13
129
  const state = {
14
130
  board: null,
@@ -189,9 +305,11 @@ function branchRow({ user, branch, time, dim, own }) {
189
305
 
190
306
  const unresolved = openCount(branch);
191
307
  const pr = b.prs && b.prs[branch];
308
+ const askedMe = openRequestForMe(branch);
192
309
  btn.innerHTML = `
193
310
  <span class="dot ${dotCls}"></span>
194
311
  <span class="branch-label">${escapeHtml(branch)}</span>
312
+ ${askedMe ? `<span class="badge review" title="${escapeHtml((askedMe.fromName || askedMe.fromUser) + ' asked for your review')}">review</span>` : ''}
195
313
  ${unresolved ? `<span class="badge">${unresolved}</span>` : ''}
196
314
  ${pr ? `<span class="pr-link" title="${escapeHtml(`PR #${pr.number}: ${pr.title || ''}`)}">#${pr.number}</span>` : ''}
197
315
  <span class="meta">${timeAgo(time)}</span>`;
@@ -209,6 +327,7 @@ function branchRow({ user, branch, time, dim, own }) {
209
327
 
210
328
  async function select(user, branch) {
211
329
  state.selected = { user, branch };
330
+ el('pr-panel').hidden = true;
212
331
  state.commentMode = false;
213
332
  state.pendingPin = null;
214
333
  state.focusedComment = null;
@@ -295,6 +414,9 @@ function renderStage() {
295
414
  el('loading-log').textContent = ps && ps.logTail ? ps.logTail.trim() : '';
296
415
  }
297
416
 
417
+ renderRequestButton();
418
+ renderPrChip();
419
+
298
420
  const cm = el('btn-comment-mode');
299
421
  cm.innerHTML = `${icons.message} <span>${state.commentMode ? 'Click an element to pin' : 'Comment'}</span>`;
300
422
  cm.classList.toggle('active-mode', state.commentMode);
@@ -353,11 +475,13 @@ window.addEventListener('message', (e) => {
353
475
  el('composer-text').value = '';
354
476
  el('composer-text').focus();
355
477
  } else if (d.ds === 'pin-click') {
356
- state.focusedComment = d.id;
478
+ state.focusedComment = state.focusedComment === d.id ? null : d.id;
357
479
  renderComments();
358
480
  pushCommentsToFrame();
359
- const card = document.querySelector(`[data-comment="${d.id}"]`);
360
- if (card) card.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
481
+ if (state.focusedComment) {
482
+ const card = document.querySelector(`[data-comment="${d.id}"]`);
483
+ if (card) card.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
484
+ }
361
485
  }
362
486
  });
363
487
 
@@ -459,6 +583,34 @@ function renderComments() {
459
583
  el('comments-count').textContent = open.length ? `${open.length} open` : '';
460
584
 
461
585
  const frag = document.createDocumentFragment();
586
+
587
+ const me = state.board.me.slug;
588
+ for (const r of branchRequests(sel.branch).filter((x) => !x.resolvedAt)) {
589
+ const card = document.createElement('div');
590
+ card.className = 'request-card' + (r.toUser === me ? ' for-me' : '');
591
+ if (r.toUser === me) {
592
+ card.innerHTML = `<span class="request-text"><b>${escapeHtml(r.fromName || r.fromUser)}</b> asked for your review</span>
593
+ <span class="request-meta">${timeAgo(r.createdAt / 1000)}</span>`;
594
+ const done = document.createElement('button');
595
+ done.className = 'btn small';
596
+ done.textContent = 'Mark reviewed';
597
+ done.onclick = async () => { await api(`/api/requests/${r.id}/resolve`, { how: 'done' }); refresh(); };
598
+ card.appendChild(done);
599
+ } else if (r.fromUser === me) {
600
+ card.innerHTML = `<span class="request-text">You asked <b>${escapeHtml(r.toName || r.toUser)}</b> to review</span>
601
+ <span class="request-meta">${timeAgo(r.createdAt / 1000)}</span>`;
602
+ const cancel = document.createElement('button');
603
+ cancel.className = 'link-btn';
604
+ cancel.textContent = 'Cancel';
605
+ cancel.onclick = async () => { await api(`/api/requests/${r.id}/resolve`, { how: 'cancelled' }); refresh(); };
606
+ card.appendChild(cancel);
607
+ } else {
608
+ card.innerHTML = `<span class="request-text"><b>${escapeHtml(r.fromName || r.fromUser)}</b> asked <b>${escapeHtml(r.toName || r.toUser)}</b> to review</span>
609
+ <span class="request-meta">${timeAgo(r.createdAt / 1000)}</span>`;
610
+ }
611
+ frag.appendChild(card);
612
+ }
613
+
462
614
  if (!comments.length) {
463
615
  const d = document.createElement('div');
464
616
  d.className = 'empty-note';
@@ -539,11 +691,10 @@ function commentCard(c, num) {
539
691
 
540
692
  card.onclick = (e) => {
541
693
  if (e.target === reply || e.target.tagName === 'BUTTON') return;
542
- state.focusedComment = c.id;
694
+ state.focusedComment = state.focusedComment === c.id ? null : c.id;
695
+ renderComments();
543
696
  renderPins();
544
697
  pushCommentsToFrame();
545
- document.querySelectorAll('.comment-card').forEach((x) => x.classList.remove('focused'));
546
- card.classList.add('focused');
547
698
  };
548
699
  return card;
549
700
  }
@@ -575,7 +726,14 @@ el('btn-retry').onclick = () => pollPreview();
575
726
  document.addEventListener('keydown', (e) => {
576
727
  if (e.key === 'Escape') {
577
728
  if (!el('composer').hidden) closeComposer();
729
+ else if (!el('pr-panel').hidden) togglePrPanel(false);
578
730
  else if (state.commentMode) { state.commentMode = false; renderStage(); }
731
+ else if (state.focusedComment) {
732
+ state.focusedComment = null;
733
+ renderComments();
734
+ renderPins();
735
+ pushCommentsToFrame();
736
+ }
579
737
  }
580
738
  });
581
739
 
@@ -623,6 +781,26 @@ function parseHash() {
623
781
  state.railFilter = e.target.value;
624
782
  renderRail();
625
783
  });
784
+ const setRailCollapsed = (collapsed) => {
785
+ document.getElementById('app').classList.toggle('rail-collapsed', collapsed);
786
+ el('btn-expand').hidden = !collapsed;
787
+ try { localStorage.setItem('ds-rail-collapsed', collapsed ? '1' : ''); } catch { /* private mode */ }
788
+ };
789
+ el('btn-collapse').onclick = () => setRailCollapsed(true);
790
+ el('btn-expand').onclick = () => setRailCollapsed(false);
791
+ try { if (localStorage.getItem('ds-rail-collapsed') === '1') setRailCollapsed(true); } catch { /* private mode */ }
792
+ document.addEventListener('keydown', (e) => {
793
+ if (e.key === '[' && !isTyping()) {
794
+ setRailCollapsed(!document.getElementById('app').classList.contains('rail-collapsed'));
795
+ }
796
+ });
797
+
798
+ el('btn-request').onclick = (e) => { e.stopPropagation(); toggleRequestPopover(); };
799
+ el('pr-chip').onclick = (e) => { e.stopPropagation(); togglePrPanel(); };
800
+ document.addEventListener('click', (e) => {
801
+ if (!el('request-popover').hidden && !e.target.closest('.request-wrap')) toggleRequestPopover(false);
802
+ if (!el('pr-panel').hidden && !e.target.closest('#pr-panel') && !e.target.closest('#pr-chip')) togglePrPanel(false);
803
+ });
626
804
  renderViewportSeg();
627
805
  await refresh();
628
806
  const target = parseHash();
package/public/index.html CHANGED
@@ -12,6 +12,9 @@
12
12
  <div class="rail-head">
13
13
  <span class="logo-mark"></span>
14
14
  <span id="repo-name" class="repo-name">…</span>
15
+ <button id="btn-collapse" class="icon-btn" title="Collapse sidebar [">
16
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18"/></svg>
17
+ </button>
15
18
  </div>
16
19
  <div class="rail-filter">
17
20
  <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>
@@ -20,20 +23,33 @@
20
23
  <div id="rail-sections"></div>
21
24
  <div class="rail-foot">
22
25
  <div id="sync-status" class="sync-status"></div>
26
+ <a class="coffee-link" href="https://buymeacoffee.com/callebe" target="_blank" rel="noopener">
27
+ <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 2v2"/><path d="M14 2v2"/><path d="M16 8a1 1 0 0 1 1 1v8a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1h14a4 4 0 1 1 0 8h-1"/><path d="M6 2v2"/></svg>
28
+ buy me a coffee
29
+ </a>
23
30
  </div>
24
31
  </aside>
25
32
 
26
33
  <main id="stage">
27
34
  <header id="stage-head">
28
35
  <div class="stage-title-wrap">
36
+ <button id="btn-expand" class="icon-btn" title="Show sidebar [" hidden>
37
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18"/></svg>
38
+ </button>
29
39
  <span id="stage-title" class="stage-title">pick a branch</span>
30
40
  <span id="stage-meta" class="stage-meta"></span>
41
+ <button id="pr-chip" class="pr-chip" hidden></button>
31
42
  </div>
32
43
  <div class="stage-actions">
33
44
  <div class="seg" id="viewport-seg"></div>
45
+ <div class="request-wrap">
46
+ <button class="btn ghost" id="btn-request"></button>
47
+ <div id="request-popover" hidden></div>
48
+ </div>
34
49
  <button class="btn" id="btn-comment-mode"></button>
35
50
  </div>
36
51
  </header>
52
+ <div id="pr-panel" hidden></div>
37
53
  <div id="frame-area">
38
54
  <div id="frame-wrap">
39
55
  <div id="frame-empty" class="frame-state">
package/public/style.css CHANGED
@@ -65,7 +65,23 @@ button { font: inherit; cursor: pointer; }
65
65
  background: #fff;
66
66
  }
67
67
 
68
- .repo-name { font-size: 13.5px; font-weight: 600; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
68
+ .repo-name { font-size: 13.5px; font-weight: 600; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
69
+
70
+ .icon-btn {
71
+ display: inline-flex;
72
+ align-items: center;
73
+ justify-content: center;
74
+ width: 24px;
75
+ height: 24px;
76
+ border: 0;
77
+ border-radius: var(--radius);
78
+ background: transparent;
79
+ color: var(--text-3);
80
+ flex: none;
81
+ }
82
+ .icon-btn:hover { color: var(--text); background: #f2f2f4; }
83
+
84
+ #app.rail-collapsed #rail { display: none; }
69
85
 
70
86
  .rail-filter {
71
87
  display: flex;
@@ -135,6 +151,135 @@ button { font: inherit; cursor: pointer; }
135
151
  }
136
152
  .pr-link:hover { color: var(--accent); border-color: var(--accent); }
137
153
 
154
+ .badge.review { color: var(--amber); border-color: #ecd9b8; background: #fdf8ee; }
155
+
156
+ .pr-chip {
157
+ display: inline-flex;
158
+ align-items: center;
159
+ gap: 4px;
160
+ font-size: 10.5px;
161
+ color: var(--text-2);
162
+ border: 1px solid var(--border);
163
+ border-radius: 99px;
164
+ padding: 0 8px;
165
+ line-height: 17px;
166
+ background: #fff;
167
+ cursor: pointer;
168
+ flex: none;
169
+ }
170
+ .pr-chip:hover { color: var(--text); border-color: #d0d0d5; }
171
+ .pr-chip .chev { font-size: 8px; color: var(--text-3); }
172
+
173
+ #pr-panel {
174
+ position: absolute;
175
+ top: 46px;
176
+ left: 14px;
177
+ width: min(560px, calc(100% - 28px));
178
+ max-height: 55vh;
179
+ overflow-y: auto;
180
+ background: var(--panel);
181
+ border: 1px solid var(--border);
182
+ border-radius: 0 0 10px 10px;
183
+ border-top: 0;
184
+ box-shadow: var(--shadow);
185
+ padding: 12px 14px;
186
+ z-index: 15;
187
+ }
188
+ #stage { position: relative; }
189
+
190
+ .pr-loading { display: flex; justify-content: center; padding: 18px; color: var(--text-3); font-size: 12px; }
191
+ .pr-head { display: flex; align-items: center; gap: 8px; }
192
+ .pr-head .dot.merged { background: #8250df; }
193
+ .pr-head .dot.closed { background: var(--red); }
194
+ .pr-title { font-size: 13.5px; font-weight: 600; flex: 1; min-width: 0; }
195
+ .pr-num { font-size: 11.5px; color: var(--text-3); flex: none; }
196
+ .pr-open { font-size: 11.5px; flex: none; color: var(--text-2); text-decoration: none; border: 1px solid var(--border); border-radius: var(--radius); padding: 2px 8px; }
197
+ .pr-open:hover { color: var(--text); border-color: #d0d0d5; }
198
+ .pr-byline { font-size: 11.5px; color: var(--text-3); margin: 4px 0 8px; }
199
+ .pr-body {
200
+ font-size: 12.5px;
201
+ color: var(--text);
202
+ white-space: pre-wrap;
203
+ word-break: break-word;
204
+ border: 1px solid var(--border);
205
+ border-radius: 8px;
206
+ padding: 9px 11px;
207
+ background: #fafafa;
208
+ max-height: 220px;
209
+ overflow-y: auto;
210
+ }
211
+ .dim-note { font-size: 12px; color: var(--text-3); }
212
+ .pr-comments-label {
213
+ font-size: 10.5px;
214
+ font-weight: 600;
215
+ letter-spacing: 0.06em;
216
+ text-transform: uppercase;
217
+ color: var(--text-3);
218
+ margin: 12px 0 6px;
219
+ }
220
+ .pr-comment { border-top: 1px solid var(--border); padding: 8px 0; }
221
+ .pr-comment-head { display: flex; align-items: center; gap: 7px; font-size: 12px; margin-bottom: 3px; }
222
+ .pr-comment-time { font-size: 10.5px; color: var(--text-3); }
223
+ .pr-comment-body { font-size: 12.5px; color: var(--text-2); white-space: pre-wrap; word-break: break-word; }
224
+
225
+ .request-wrap { position: relative; }
226
+ #request-popover {
227
+ position: absolute;
228
+ top: 32px;
229
+ right: 0;
230
+ width: 240px;
231
+ background: var(--panel);
232
+ border: 1px solid var(--border);
233
+ border-radius: 8px;
234
+ box-shadow: var(--shadow);
235
+ padding: 6px;
236
+ z-index: 20;
237
+ }
238
+ .popover-head {
239
+ font-size: 11px;
240
+ color: var(--text-3);
241
+ padding: 5px 8px 7px;
242
+ border-bottom: 1px solid var(--border);
243
+ margin-bottom: 4px;
244
+ white-space: nowrap;
245
+ overflow: hidden;
246
+ text-overflow: ellipsis;
247
+ }
248
+ .popover-row {
249
+ display: flex;
250
+ align-items: center;
251
+ gap: 8px;
252
+ width: 100%;
253
+ border: 0;
254
+ background: transparent;
255
+ border-radius: var(--radius);
256
+ padding: 6px 8px;
257
+ font-size: 12.5px;
258
+ color: var(--text);
259
+ text-align: left;
260
+ }
261
+ .popover-row:hover:not(:disabled) { background: #f2f2f4; }
262
+ .popover-row:disabled { opacity: 0.5; cursor: default; }
263
+ .popover-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
264
+ .popover-meta { font-size: 10.5px; color: var(--text-3); flex: none; }
265
+
266
+ .request-card {
267
+ display: flex;
268
+ align-items: center;
269
+ gap: 8px;
270
+ flex-wrap: wrap;
271
+ border: 1px solid var(--border);
272
+ border-radius: 8px;
273
+ padding: 8px 10px;
274
+ margin-bottom: 8px;
275
+ font-size: 12px;
276
+ color: var(--text-2);
277
+ background: #fafafa;
278
+ }
279
+ .request-card.for-me { border-color: #ecd9b8; background: #fdf8ee; color: var(--text); }
280
+ .request-card .request-text { flex: 1; min-width: 120px; }
281
+ .request-card .request-meta { font-size: 10.5px; color: var(--text-3); }
282
+
138
283
  .dot { width: 7px; height: 7px; border-radius: 50%; flex: none; }
139
284
  .dot.live { background: var(--green); }
140
285
  .dot.idle { background: #d4d4d8; }
@@ -164,9 +309,19 @@ button { font: inherit; cursor: pointer; }
164
309
  }
165
310
  .share-cta:hover { color: var(--text); border-color: #d0d0d5; }
166
311
 
167
- .rail-foot { border-top: 1px solid var(--border); padding: 8px 16px; }
312
+ .rail-foot { border-top: 1px solid var(--border); padding: 8px 16px; display: flex; flex-direction: column; gap: 4px; }
168
313
  .sync-status { font-size: 11px; color: var(--text-3); display: flex; align-items: center; gap: 6px; }
169
314
  .sync-status .dot { width: 6px; height: 6px; }
315
+ .coffee-link {
316
+ display: inline-flex;
317
+ align-items: center;
318
+ gap: 5px;
319
+ font-size: 10.5px;
320
+ color: var(--text-3);
321
+ text-decoration: none;
322
+ width: fit-content;
323
+ }
324
+ .coffee-link:hover { color: var(--text-2); }
170
325
 
171
326
  /* ---------- stage ---------- */
172
327
 
@@ -247,12 +402,17 @@ button { font: inherit; cursor: pointer; }
247
402
  max-width: 100%;
248
403
  background: var(--panel);
249
404
  transition: max-width 0.18s ease;
405
+ min-height: 100%;
406
+ }
407
+ #frame-wrap.vw-tablet,
408
+ #frame-wrap.vw-mobile {
409
+ margin: 0 auto;
410
+ box-shadow: var(--shadow);
250
411
  border-left: 1px solid var(--border);
251
412
  border-right: 1px solid var(--border);
252
- min-height: 100%;
253
413
  }
254
- #frame-wrap.vw-tablet { max-width: 834px; margin: 0 auto; box-shadow: var(--shadow); }
255
- #frame-wrap.vw-mobile { max-width: 390px; margin: 0 auto; box-shadow: var(--shadow); }
414
+ #frame-wrap.vw-tablet { max-width: 834px; }
415
+ #frame-wrap.vw-mobile { max-width: 390px; }
256
416
 
257
417
  #frame { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; background: #fff; }
258
418
 
package/src/github.js CHANGED
@@ -3,6 +3,37 @@ import { execFile } from 'node:child_process';
3
3
  // PR links come from the gh CLI so no tokens are stored or handled: gh uses
4
4
  // whoever is already logged in. Machines without gh (or without auth) simply
5
5
  // return an empty map and the dashboard shows no PR links.
6
+ // Read only PR detail for the header panel: description plus conversation.
7
+ export function prDetail(repoRoot, number) {
8
+ return new Promise((resolve) => {
9
+ execFile('gh', [
10
+ 'pr', 'view', String(number),
11
+ '--json', 'number,title,body,state,author,url,createdAt,comments',
12
+ ], { cwd: repoRoot, timeout: 15_000 }, (err, stdout) => {
13
+ if (err) return resolve(null);
14
+ try {
15
+ const d = JSON.parse(stdout);
16
+ resolve({
17
+ number: d.number,
18
+ title: d.title,
19
+ body: d.body || '',
20
+ state: d.state,
21
+ url: d.url,
22
+ author: (d.author && d.author.login) || '',
23
+ createdAt: d.createdAt,
24
+ comments: (d.comments || []).map((c) => ({
25
+ author: (c.author && c.author.login) || '',
26
+ body: c.body || '',
27
+ createdAt: c.createdAt,
28
+ })),
29
+ });
30
+ } catch {
31
+ resolve(null);
32
+ }
33
+ });
34
+ });
35
+ }
36
+
6
37
  export function listOpenPRs(repoRoot) {
7
38
  return new Promise((resolve) => {
8
39
  execFile('gh', [
package/src/server.js CHANGED
@@ -5,7 +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
+ import { listOpenPRs, prDetail } from './github.js';
9
9
 
10
10
  const PUBLIC_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public');
11
11
  const SYNC_INTERVAL_MS = 15_000;
@@ -89,6 +89,8 @@ export class DesignShareServer {
89
89
  heads,
90
90
  otherBranches: others,
91
91
  comments: Object.values(state.comments || {}),
92
+ users: Object.values(state.users || {}),
93
+ requests: Object.values(state.requests || {}),
92
94
  previews: this.previews.statuses(),
93
95
  prs: this.prs,
94
96
  sync: {
@@ -108,6 +110,19 @@ export class DesignShareServer {
108
110
  if (req.method === 'GET' && p === '/api/board') {
109
111
  return json(res, 200, await this.board());
110
112
  }
113
+ if (req.method === 'GET' && p === '/api/pr') {
114
+ const branch = url.searchParams.get('branch') || '';
115
+ const pr = this.prs[branch];
116
+ if (!pr) return json(res, 200, { pr: null });
117
+ this._prDetailCache = this._prDetailCache || new Map();
118
+ const cached = this._prDetailCache.get(pr.number);
119
+ if (cached && Date.now() - cached.at < 120_000) {
120
+ return json(res, 200, { pr: cached.data });
121
+ }
122
+ const data = await prDetail(this.repo.root, pr.number);
123
+ if (data) this._prDetailCache.set(pr.number, { at: Date.now(), data });
124
+ return json(res, 200, { pr: data });
125
+ }
111
126
  if (req.method === 'POST' && p === '/api/share') {
112
127
  const body = await readBody(req);
113
128
  const branch = body.branch || (await currentBranch(this.repo.root));
@@ -145,9 +160,27 @@ export class DesignShareServer {
145
160
  commit,
146
161
  text: String(body.text || '').slice(0, 4000),
147
162
  });
163
+ this.store.autoResolveRequests(body.branch);
148
164
  this.syncSoon();
149
165
  return json(res, 200, { ok: true, id });
150
166
  }
167
+ if (req.method === 'POST' && p === '/api/requests') {
168
+ const body = await readBody(req);
169
+ const id = this.store.requestReview({
170
+ branch: String(body.branch || ''),
171
+ toUser: String(body.toUser || ''),
172
+ toName: String(body.toName || body.toUser || ''),
173
+ });
174
+ this.syncSoon();
175
+ return json(res, 200, { ok: true, id });
176
+ }
177
+ const reqResolveMatch = p.match(/^\/api\/requests\/([a-z0-9]+)\/resolve$/);
178
+ if (req.method === 'POST' && reqResolveMatch) {
179
+ const body = await readBody(req);
180
+ this.store.resolveRequest(reqResolveMatch[1], body.how);
181
+ this.syncSoon();
182
+ return json(res, 200, { ok: true });
183
+ }
151
184
  const replyMatch = p.match(/^\/api\/comments\/([a-z0-9]+)\/replies$/);
152
185
  if (req.method === 'POST' && replyMatch) {
153
186
  const body = await readBody(req);
package/src/state.js CHANGED
@@ -5,7 +5,7 @@ const REF = 'refs/design-share/state';
5
5
  const REMOTE_REF = 'refs/design-share/remote';
6
6
 
7
7
  export function emptyState() {
8
- return { version: 1, shares: {}, comments: {} };
8
+ return { version: 1, shares: {}, comments: {}, users: {}, requests: {} };
9
9
  }
10
10
 
11
11
  export function newId() {
@@ -45,6 +45,18 @@ export function mergeStates(a, b) {
45
45
  out.comments[k] = ca || cb;
46
46
  }
47
47
  }
48
+ const userKeys = new Set([...Object.keys(a.users || {}), ...Object.keys(b.users || {})]);
49
+ for (const k of userKeys) {
50
+ const ua = (a.users || {})[k];
51
+ const ub = (b.users || {})[k];
52
+ out.users[k] = ua && ub ? ((ua.lastSeen || 0) >= (ub.lastSeen || 0) ? ua : ub) : (ua || ub);
53
+ }
54
+ const reqKeys = new Set([...Object.keys(a.requests || {}), ...Object.keys(b.requests || {})]);
55
+ for (const k of reqKeys) {
56
+ const ra = (a.requests || {})[k];
57
+ const rb = (b.requests || {})[k];
58
+ out.requests[k] = ra && rb ? newer(ra, rb) : (ra || rb);
59
+ }
48
60
  return out;
49
61
  }
50
62
 
@@ -93,8 +105,22 @@ export class StateStore {
93
105
  await git(this.repoRoot, ['update-ref', REF, commit]);
94
106
  }
95
107
 
108
+ // Presence: mark this user as someone who runs design-share so the
109
+ // review request picker only offers reachable people. Throttled so it
110
+ // does not force a push on every sync cycle.
111
+ stampPresence() {
112
+ const now = Date.now();
113
+ const u = (this.state.users || {})[this.identity.slug];
114
+ if (u && now - (u.lastSeen || 0) < 60 * 60 * 1000) return;
115
+ this.mutate((s) => {
116
+ s.users = s.users || {};
117
+ s.users[this.identity.slug] = { user: this.identity.slug, name: this.identity.name, lastSeen: now };
118
+ });
119
+ }
120
+
96
121
  // Fetch remote state, merge, persist locally, push if anything moved.
97
122
  async sync() {
123
+ this.stampPresence();
98
124
  try {
99
125
  let remoteState = null;
100
126
  if (this.hasRemote) {
@@ -175,6 +201,59 @@ export class StateStore {
175
201
  return id;
176
202
  }
177
203
 
204
+ requestReview({ branch, toUser, toName }) {
205
+ const id = newId();
206
+ const now = Date.now();
207
+ this.mutate((s) => {
208
+ s.requests = s.requests || {};
209
+ // One open request per branch per person: refresh an existing one.
210
+ const existing = Object.values(s.requests).find(
211
+ (r) => r.branch === branch && r.toUser === toUser && !r.resolvedAt,
212
+ );
213
+ const key = existing ? existing.id : id;
214
+ s.requests[key] = {
215
+ id: key,
216
+ branch,
217
+ fromUser: this.identity.slug,
218
+ fromName: this.identity.name,
219
+ toUser,
220
+ toName,
221
+ createdAt: existing ? existing.createdAt : now,
222
+ updatedAt: now,
223
+ resolvedAt: null,
224
+ resolvedBy: null,
225
+ };
226
+ });
227
+ return id;
228
+ }
229
+
230
+ resolveRequest(requestId, how) {
231
+ const now = Date.now();
232
+ this.mutate((s) => {
233
+ const r = (s.requests || {})[requestId];
234
+ if (!r) return;
235
+ r.resolvedAt = now;
236
+ r.resolvedBy = this.identity.slug;
237
+ r.resolvedHow = how || 'done';
238
+ r.updatedAt = now;
239
+ });
240
+ }
241
+
242
+ // A comment from the requested person is the review happening; close the loop.
243
+ autoResolveRequests(branch) {
244
+ const now = Date.now();
245
+ this.mutate((s) => {
246
+ for (const r of Object.values(s.requests || {})) {
247
+ if (r.branch === branch && r.toUser === this.identity.slug && !r.resolvedAt) {
248
+ r.resolvedAt = now;
249
+ r.resolvedBy = this.identity.slug;
250
+ r.resolvedHow = 'commented';
251
+ r.updatedAt = now;
252
+ }
253
+ }
254
+ });
255
+ }
256
+
178
257
  reply(commentId, text) {
179
258
  const now = Date.now();
180
259
  this.mutate((s) => {