design-share 0.2.0 → 0.3.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.
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "design-share",
3
- "version": "0.2.0",
3
+ "version": "0.3.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
@@ -7,8 +7,68 @@ 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
+ /* ---------- review requests ---------- */
14
+
15
+ function branchRequests(branch) {
16
+ if (!state.board) return [];
17
+ return (state.board.requests || [])
18
+ .filter((r) => r.branch === branch)
19
+ .sort((a, b) => b.updatedAt - a.updatedAt);
20
+ }
21
+
22
+ function openRequestForMe(branch) {
23
+ const me = state.board.me.slug;
24
+ return branchRequests(branch).find((r) => r.toUser === me && !r.resolvedAt);
25
+ }
26
+
27
+ function renderRequestButton() {
28
+ const b = state.board;
29
+ const sel = state.selected;
30
+ const btn = el('btn-request');
31
+ const roster = (b.users || []).filter((u) => u.user !== b.me.slug);
32
+ const visible = !!sel && roster.length > 0;
33
+ btn.hidden = !visible;
34
+ if (visible) btn.innerHTML = `${icons.eye} <span>Request review</span>`;
35
+ }
36
+
37
+ function toggleRequestPopover(force) {
38
+ const pop = el('request-popover');
39
+ const show = force !== undefined ? force : pop.hidden;
40
+ if (!show) { pop.hidden = true; return; }
41
+ const b = state.board;
42
+ const sel = state.selected;
43
+ if (!sel) return;
44
+ const roster = (b.users || [])
45
+ .filter((u) => u.user !== b.me.slug)
46
+ .sort((x, y) => (y.lastSeen || 0) - (x.lastSeen || 0));
47
+ pop.innerHTML = '';
48
+ const head = document.createElement('div');
49
+ head.className = 'popover-head';
50
+ head.textContent = `Ask someone to review ${sel.branch}`;
51
+ pop.appendChild(head);
52
+ for (const u of roster) {
53
+ const already = branchRequests(sel.branch).some((r) => r.toUser === u.user && !r.resolvedAt && r.fromUser === b.me.slug);
54
+ const row = document.createElement('button');
55
+ row.className = 'popover-row';
56
+ row.innerHTML = `
57
+ <span class="avatar">${escapeHtml((u.name || u.user).slice(0, 1))}</span>
58
+ <span class="popover-name">${escapeHtml(u.name || u.user)}</span>
59
+ <span class="popover-meta">${already ? 'asked' : timeAgo((u.lastSeen || 0) / 1000)}</span>`;
60
+ row.disabled = already;
61
+ row.onclick = async () => {
62
+ await api('/api/requests', { branch: sel.branch, toUser: u.user, toName: u.name });
63
+ toggleRequestPopover(false);
64
+ toast(`Asked ${u.name || u.user} to review ${sel.branch}`);
65
+ refresh();
66
+ };
67
+ pop.appendChild(row);
68
+ }
69
+ pop.hidden = false;
70
+ }
71
+
12
72
  const el = (id) => document.getElementById(id);
13
73
  const state = {
14
74
  board: null,
@@ -188,12 +248,22 @@ function branchRow({ user, branch, time, dim, own }) {
188
248
  else if (isOwnServing && prev && prev.status === 'ready') dotCls = 'live';
189
249
 
190
250
  const unresolved = openCount(branch);
251
+ const pr = b.prs && b.prs[branch];
252
+ const askedMe = openRequestForMe(branch);
191
253
  btn.innerHTML = `
192
254
  <span class="dot ${dotCls}"></span>
193
255
  <span class="branch-label">${escapeHtml(branch)}</span>
256
+ ${askedMe ? `<span class="badge review" title="${escapeHtml((askedMe.fromName || askedMe.fromUser) + ' asked for your review')}">review</span>` : ''}
194
257
  ${unresolved ? `<span class="badge">${unresolved}</span>` : ''}
258
+ ${pr ? `<span class="pr-link" title="${escapeHtml(`PR #${pr.number}: ${pr.title || ''}`)}">#${pr.number}</span>` : ''}
195
259
  <span class="meta">${timeAgo(time)}</span>`;
196
260
  btn.onclick = () => select(user, branch);
261
+ if (pr) {
262
+ btn.querySelector('.pr-link').onclick = (e) => {
263
+ e.stopPropagation();
264
+ window.open(pr.url, '_blank');
265
+ };
266
+ }
197
267
  return btn;
198
268
  }
199
269
 
@@ -287,6 +357,8 @@ function renderStage() {
287
357
  el('loading-log').textContent = ps && ps.logTail ? ps.logTail.trim() : '';
288
358
  }
289
359
 
360
+ renderRequestButton();
361
+
290
362
  const cm = el('btn-comment-mode');
291
363
  cm.innerHTML = `${icons.message} <span>${state.commentMode ? 'Click an element to pin' : 'Comment'}</span>`;
292
364
  cm.classList.toggle('active-mode', state.commentMode);
@@ -345,11 +417,13 @@ window.addEventListener('message', (e) => {
345
417
  el('composer-text').value = '';
346
418
  el('composer-text').focus();
347
419
  } else if (d.ds === 'pin-click') {
348
- state.focusedComment = d.id;
420
+ state.focusedComment = state.focusedComment === d.id ? null : d.id;
349
421
  renderComments();
350
422
  pushCommentsToFrame();
351
- const card = document.querySelector(`[data-comment="${d.id}"]`);
352
- if (card) card.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
423
+ if (state.focusedComment) {
424
+ const card = document.querySelector(`[data-comment="${d.id}"]`);
425
+ if (card) card.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
426
+ }
353
427
  }
354
428
  });
355
429
 
@@ -451,6 +525,34 @@ function renderComments() {
451
525
  el('comments-count').textContent = open.length ? `${open.length} open` : '';
452
526
 
453
527
  const frag = document.createDocumentFragment();
528
+
529
+ const me = state.board.me.slug;
530
+ for (const r of branchRequests(sel.branch).filter((x) => !x.resolvedAt)) {
531
+ const card = document.createElement('div');
532
+ card.className = 'request-card' + (r.toUser === me ? ' for-me' : '');
533
+ if (r.toUser === me) {
534
+ card.innerHTML = `<span class="request-text"><b>${escapeHtml(r.fromName || r.fromUser)}</b> asked for your review</span>
535
+ <span class="request-meta">${timeAgo(r.createdAt / 1000)}</span>`;
536
+ const done = document.createElement('button');
537
+ done.className = 'btn small';
538
+ done.textContent = 'Mark reviewed';
539
+ done.onclick = async () => { await api(`/api/requests/${r.id}/resolve`, { how: 'done' }); refresh(); };
540
+ card.appendChild(done);
541
+ } else if (r.fromUser === me) {
542
+ card.innerHTML = `<span class="request-text">You asked <b>${escapeHtml(r.toName || r.toUser)}</b> to review</span>
543
+ <span class="request-meta">${timeAgo(r.createdAt / 1000)}</span>`;
544
+ const cancel = document.createElement('button');
545
+ cancel.className = 'link-btn';
546
+ cancel.textContent = 'Cancel';
547
+ cancel.onclick = async () => { await api(`/api/requests/${r.id}/resolve`, { how: 'cancelled' }); refresh(); };
548
+ card.appendChild(cancel);
549
+ } else {
550
+ card.innerHTML = `<span class="request-text"><b>${escapeHtml(r.fromName || r.fromUser)}</b> asked <b>${escapeHtml(r.toName || r.toUser)}</b> to review</span>
551
+ <span class="request-meta">${timeAgo(r.createdAt / 1000)}</span>`;
552
+ }
553
+ frag.appendChild(card);
554
+ }
555
+
454
556
  if (!comments.length) {
455
557
  const d = document.createElement('div');
456
558
  d.className = 'empty-note';
@@ -531,11 +633,10 @@ function commentCard(c, num) {
531
633
 
532
634
  card.onclick = (e) => {
533
635
  if (e.target === reply || e.target.tagName === 'BUTTON') return;
534
- state.focusedComment = c.id;
636
+ state.focusedComment = state.focusedComment === c.id ? null : c.id;
637
+ renderComments();
535
638
  renderPins();
536
639
  pushCommentsToFrame();
537
- document.querySelectorAll('.comment-card').forEach((x) => x.classList.remove('focused'));
538
- card.classList.add('focused');
539
640
  };
540
641
  return card;
541
642
  }
@@ -568,6 +669,12 @@ document.addEventListener('keydown', (e) => {
568
669
  if (e.key === 'Escape') {
569
670
  if (!el('composer').hidden) closeComposer();
570
671
  else if (state.commentMode) { state.commentMode = false; renderStage(); }
672
+ else if (state.focusedComment) {
673
+ state.focusedComment = null;
674
+ renderComments();
675
+ renderPins();
676
+ pushCommentsToFrame();
677
+ }
571
678
  }
572
679
  });
573
680
 
@@ -615,6 +722,10 @@ function parseHash() {
615
722
  state.railFilter = e.target.value;
616
723
  renderRail();
617
724
  });
725
+ el('btn-request').onclick = (e) => { e.stopPropagation(); toggleRequestPopover(); };
726
+ document.addEventListener('click', (e) => {
727
+ if (!el('request-popover').hidden && !e.target.closest('.request-wrap')) toggleRequestPopover(false);
728
+ });
618
729
  renderViewportSeg();
619
730
  await refresh();
620
731
  const target = parseHash();
package/public/index.html CHANGED
@@ -31,6 +31,10 @@
31
31
  </div>
32
32
  <div class="stage-actions">
33
33
  <div class="seg" id="viewport-seg"></div>
34
+ <div class="request-wrap">
35
+ <button class="btn ghost" id="btn-request"></button>
36
+ <div id="request-popover" hidden></div>
37
+ </div>
34
38
  <button class="btn" id="btn-comment-mode"></button>
35
39
  </div>
36
40
  </header>
package/public/style.css CHANGED
@@ -123,6 +123,78 @@ button { font: inherit; cursor: pointer; }
123
123
  .branch-row .meta { font-size: 11px; color: var(--text-3); flex: none; }
124
124
  .branch-row.dim { color: var(--text-2); }
125
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
+
138
+ .badge.review { color: var(--amber); border-color: #ecd9b8; background: #fdf8ee; }
139
+
140
+ .request-wrap { position: relative; }
141
+ #request-popover {
142
+ position: absolute;
143
+ top: 32px;
144
+ right: 0;
145
+ width: 240px;
146
+ background: var(--panel);
147
+ border: 1px solid var(--border);
148
+ border-radius: 8px;
149
+ box-shadow: var(--shadow);
150
+ padding: 6px;
151
+ z-index: 20;
152
+ }
153
+ .popover-head {
154
+ font-size: 11px;
155
+ color: var(--text-3);
156
+ padding: 5px 8px 7px;
157
+ border-bottom: 1px solid var(--border);
158
+ margin-bottom: 4px;
159
+ white-space: nowrap;
160
+ overflow: hidden;
161
+ text-overflow: ellipsis;
162
+ }
163
+ .popover-row {
164
+ display: flex;
165
+ align-items: center;
166
+ gap: 8px;
167
+ width: 100%;
168
+ border: 0;
169
+ background: transparent;
170
+ border-radius: var(--radius);
171
+ padding: 6px 8px;
172
+ font-size: 12.5px;
173
+ color: var(--text);
174
+ text-align: left;
175
+ }
176
+ .popover-row:hover:not(:disabled) { background: #f2f2f4; }
177
+ .popover-row:disabled { opacity: 0.5; cursor: default; }
178
+ .popover-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
179
+ .popover-meta { font-size: 10.5px; color: var(--text-3); flex: none; }
180
+
181
+ .request-card {
182
+ display: flex;
183
+ align-items: center;
184
+ gap: 8px;
185
+ flex-wrap: wrap;
186
+ border: 1px solid var(--border);
187
+ border-radius: 8px;
188
+ padding: 8px 10px;
189
+ margin-bottom: 8px;
190
+ font-size: 12px;
191
+ color: var(--text-2);
192
+ background: #fafafa;
193
+ }
194
+ .request-card.for-me { border-color: #ecd9b8; background: #fdf8ee; color: var(--text); }
195
+ .request-card .request-text { flex: 1; min-width: 120px; }
196
+ .request-card .request-meta { font-size: 10.5px; color: var(--text-3); }
197
+
126
198
  .dot { width: 7px; height: 7px; border-radius: 50%; flex: none; }
127
199
  .dot.live { background: var(--green); }
128
200
  .dot.idle { background: #d4d4d8; }
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/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() {
@@ -83,7 +89,10 @@ export class DesignShareServer {
83
89
  heads,
84
90
  otherBranches: others,
85
91
  comments: Object.values(state.comments || {}),
92
+ users: Object.values(state.users || {}),
93
+ requests: Object.values(state.requests || {}),
86
94
  previews: this.previews.statuses(),
95
+ prs: this.prs,
87
96
  sync: {
88
97
  hasRemote: this.store.hasRemote,
89
98
  lastSync: this.store.lastSync,
@@ -138,9 +147,27 @@ export class DesignShareServer {
138
147
  commit,
139
148
  text: String(body.text || '').slice(0, 4000),
140
149
  });
150
+ this.store.autoResolveRequests(body.branch);
141
151
  this.syncSoon();
142
152
  return json(res, 200, { ok: true, id });
143
153
  }
154
+ if (req.method === 'POST' && p === '/api/requests') {
155
+ const body = await readBody(req);
156
+ const id = this.store.requestReview({
157
+ branch: String(body.branch || ''),
158
+ toUser: String(body.toUser || ''),
159
+ toName: String(body.toName || body.toUser || ''),
160
+ });
161
+ this.syncSoon();
162
+ return json(res, 200, { ok: true, id });
163
+ }
164
+ const reqResolveMatch = p.match(/^\/api\/requests\/([a-z0-9]+)\/resolve$/);
165
+ if (req.method === 'POST' && reqResolveMatch) {
166
+ const body = await readBody(req);
167
+ this.store.resolveRequest(reqResolveMatch[1], body.how);
168
+ this.syncSoon();
169
+ return json(res, 200, { ok: true });
170
+ }
144
171
  const replyMatch = p.match(/^\/api\/comments\/([a-z0-9]+)\/replies$/);
145
172
  if (req.method === 'POST' && replyMatch) {
146
173
  const body = await readBody(req);
@@ -202,6 +229,8 @@ export class DesignShareServer {
202
229
  () => tryGit(this.repo.root, ['fetch', '--quiet', 'origin']),
203
230
  FETCH_INTERVAL_MS,
204
231
  ));
232
+ this.refreshPRs();
233
+ this.timers.push(setInterval(() => this.refreshPRs(), 180_000));
205
234
  resolve(port);
206
235
  });
207
236
  };
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) => {