design-share 0.3.0 → 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).
package/package.json CHANGED
@@ -1,9 +1,15 @@
1
1
  {
2
2
  "name": "design-share",
3
- "version": "0.3.0",
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
@@ -10,6 +10,62 @@ const icons = {
10
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>',
11
11
  };
12
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
+
13
69
  /* ---------- review requests ---------- */
14
70
 
15
71
  function branchRequests(branch) {
@@ -271,6 +327,7 @@ function branchRow({ user, branch, time, dim, own }) {
271
327
 
272
328
  async function select(user, branch) {
273
329
  state.selected = { user, branch };
330
+ el('pr-panel').hidden = true;
274
331
  state.commentMode = false;
275
332
  state.pendingPin = null;
276
333
  state.focusedComment = null;
@@ -358,6 +415,7 @@ function renderStage() {
358
415
  }
359
416
 
360
417
  renderRequestButton();
418
+ renderPrChip();
361
419
 
362
420
  const cm = el('btn-comment-mode');
363
421
  cm.innerHTML = `${icons.message} <span>${state.commentMode ? 'Click an element to pin' : 'Comment'}</span>`;
@@ -668,6 +726,7 @@ el('btn-retry').onclick = () => pollPreview();
668
726
  document.addEventListener('keydown', (e) => {
669
727
  if (e.key === 'Escape') {
670
728
  if (!el('composer').hidden) closeComposer();
729
+ else if (!el('pr-panel').hidden) togglePrPanel(false);
671
730
  else if (state.commentMode) { state.commentMode = false; renderStage(); }
672
731
  else if (state.focusedComment) {
673
732
  state.focusedComment = null;
@@ -722,9 +781,25 @@ function parseHash() {
722
781
  state.railFilter = e.target.value;
723
782
  renderRail();
724
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
+
725
798
  el('btn-request').onclick = (e) => { e.stopPropagation(); toggleRequestPopover(); };
799
+ el('pr-chip').onclick = (e) => { e.stopPropagation(); togglePrPanel(); };
726
800
  document.addEventListener('click', (e) => {
727
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);
728
803
  });
729
804
  renderViewportSeg();
730
805
  await refresh();
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,14 +23,22 @@
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>
@@ -38,6 +49,7 @@
38
49
  <button class="btn" id="btn-comment-mode"></button>
39
50
  </div>
40
51
  </header>
52
+ <div id="pr-panel" hidden></div>
41
53
  <div id="frame-area">
42
54
  <div id="frame-wrap">
43
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;
@@ -137,6 +153,75 @@ button { font: inherit; cursor: pointer; }
137
153
 
138
154
  .badge.review { color: var(--amber); border-color: #ecd9b8; background: #fdf8ee; }
139
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
+
140
225
  .request-wrap { position: relative; }
141
226
  #request-popover {
142
227
  position: absolute;
@@ -224,9 +309,19 @@ button { font: inherit; cursor: pointer; }
224
309
  }
225
310
  .share-cta:hover { color: var(--text); border-color: #d0d0d5; }
226
311
 
227
- .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; }
228
313
  .sync-status { font-size: 11px; color: var(--text-3); display: flex; align-items: center; gap: 6px; }
229
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); }
230
325
 
231
326
  /* ---------- stage ---------- */
232
327
 
@@ -307,12 +402,17 @@ button { font: inherit; cursor: pointer; }
307
402
  max-width: 100%;
308
403
  background: var(--panel);
309
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);
310
411
  border-left: 1px solid var(--border);
311
412
  border-right: 1px solid var(--border);
312
- min-height: 100%;
313
413
  }
314
- #frame-wrap.vw-tablet { max-width: 834px; margin: 0 auto; box-shadow: var(--shadow); }
315
- #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; }
316
416
 
317
417
  #frame { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; background: #fff; }
318
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;
@@ -110,6 +110,19 @@ export class DesignShareServer {
110
110
  if (req.method === 'GET' && p === '/api/board') {
111
111
  return json(res, 200, await this.board());
112
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
+ }
113
126
  if (req.method === 'POST' && p === '/api/share') {
114
127
  const body = await readBody(req);
115
128
  const branch = body.branch || (await currentBranch(this.repo.root));