design-share 0.2.2 → 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.2",
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,
@@ -189,9 +249,11 @@ function branchRow({ user, branch, time, dim, own }) {
189
249
 
190
250
  const unresolved = openCount(branch);
191
251
  const pr = b.prs && b.prs[branch];
252
+ const askedMe = openRequestForMe(branch);
192
253
  btn.innerHTML = `
193
254
  <span class="dot ${dotCls}"></span>
194
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>` : ''}
195
257
  ${unresolved ? `<span class="badge">${unresolved}</span>` : ''}
196
258
  ${pr ? `<span class="pr-link" title="${escapeHtml(`PR #${pr.number}: ${pr.title || ''}`)}">#${pr.number}</span>` : ''}
197
259
  <span class="meta">${timeAgo(time)}</span>`;
@@ -295,6 +357,8 @@ function renderStage() {
295
357
  el('loading-log').textContent = ps && ps.logTail ? ps.logTail.trim() : '';
296
358
  }
297
359
 
360
+ renderRequestButton();
361
+
298
362
  const cm = el('btn-comment-mode');
299
363
  cm.innerHTML = `${icons.message} <span>${state.commentMode ? 'Click an element to pin' : 'Comment'}</span>`;
300
364
  cm.classList.toggle('active-mode', state.commentMode);
@@ -353,11 +417,13 @@ window.addEventListener('message', (e) => {
353
417
  el('composer-text').value = '';
354
418
  el('composer-text').focus();
355
419
  } else if (d.ds === 'pin-click') {
356
- state.focusedComment = d.id;
420
+ state.focusedComment = state.focusedComment === d.id ? null : d.id;
357
421
  renderComments();
358
422
  pushCommentsToFrame();
359
- const card = document.querySelector(`[data-comment="${d.id}"]`);
360
- 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
+ }
361
427
  }
362
428
  });
363
429
 
@@ -459,6 +525,34 @@ function renderComments() {
459
525
  el('comments-count').textContent = open.length ? `${open.length} open` : '';
460
526
 
461
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
+
462
556
  if (!comments.length) {
463
557
  const d = document.createElement('div');
464
558
  d.className = 'empty-note';
@@ -539,11 +633,10 @@ function commentCard(c, num) {
539
633
 
540
634
  card.onclick = (e) => {
541
635
  if (e.target === reply || e.target.tagName === 'BUTTON') return;
542
- state.focusedComment = c.id;
636
+ state.focusedComment = state.focusedComment === c.id ? null : c.id;
637
+ renderComments();
543
638
  renderPins();
544
639
  pushCommentsToFrame();
545
- document.querySelectorAll('.comment-card').forEach((x) => x.classList.remove('focused'));
546
- card.classList.add('focused');
547
640
  };
548
641
  return card;
549
642
  }
@@ -576,6 +669,12 @@ document.addEventListener('keydown', (e) => {
576
669
  if (e.key === 'Escape') {
577
670
  if (!el('composer').hidden) closeComposer();
578
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
+ }
579
678
  }
580
679
  });
581
680
 
@@ -623,6 +722,10 @@ function parseHash() {
623
722
  state.railFilter = e.target.value;
624
723
  renderRail();
625
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
+ });
626
729
  renderViewportSeg();
627
730
  await refresh();
628
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
@@ -135,6 +135,66 @@ button { font: inherit; cursor: pointer; }
135
135
  }
136
136
  .pr-link:hover { color: var(--accent); border-color: var(--accent); }
137
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
+
138
198
  .dot { width: 7px; height: 7px; border-radius: 50%; flex: none; }
139
199
  .dot.live { background: var(--green); }
140
200
  .dot.idle { background: #d4d4d8; }
package/src/server.js CHANGED
@@ -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: {
@@ -145,9 +147,27 @@ export class DesignShareServer {
145
147
  commit,
146
148
  text: String(body.text || '').slice(0, 4000),
147
149
  });
150
+ this.store.autoResolveRequests(body.branch);
148
151
  this.syncSoon();
149
152
  return json(res, 200, { ok: true, id });
150
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
+ }
151
171
  const replyMatch = p.match(/^\/api\/comments\/([a-z0-9]+)\/replies$/);
152
172
  if (req.method === 'POST' && replyMatch) {
153
173
  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) => {