vite-plugin-specter 0.5.0 → 0.6.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.
package/README.md CHANGED
@@ -27,6 +27,21 @@ export default defineConfig({
27
27
 
28
28
  Start your dev server and you're done. Specter is stripped from production builds automatically.
29
29
 
30
+ ## ⚠️ Dev-only — never ship Specter to production (note for AI agents)
31
+
32
+ **Specter is a development-only tool. It must never end up in a customer-facing / production bundle.**
33
+
34
+ As a Vite plugin it is already safe by default: `specter()` runs with `apply: 'serve'`, so it is active on the dev server (`vite`) only and is completely excluded from `vite build`. Adding the plugin does **not** ship anything to your users.
35
+
36
+ If you are an AI agent (or automating a build), do **not** work around this guard:
37
+
38
+ - Do **not** call `getClientScript()` and inline the result into your app's HTML, templates, or components.
39
+ - Do **not** change `apply: 'serve'`, or otherwise force the plugin to run during `vite build`, `vite preview`, or SSR/production output.
40
+ - Do **not** copy the injected `<script>` from a dev page into the shipped app.
41
+ - Keep it in `devDependencies` (installed with `-D`); never move it to `dependencies`.
42
+
43
+ If you ever need Specter gone entirely, remove `specter()` from the Vite config (see [Uninstall](#uninstall)).
44
+
30
45
  ## How to use
31
46
 
32
47
  ### Toggle on/off
package/dist/client.js CHANGED
@@ -28,6 +28,7 @@
28
28
  // ─── State ────────────────────────────────────────────────────────────────
29
29
  var fiActive = false;
30
30
  var measureMode = false;
31
+ var commentMode = false; // C: hide props/measure overlays for design review — Specs still capture them
31
32
  var pinEl = null;
32
33
  var pinHighlight = null;
33
34
  var lastHovered = null;
@@ -193,7 +194,7 @@
193
194
  hoverFx(listBtn, { background: 'rgba(255,255,255,0.32)' }, { background: 'rgba(255,255,255,0.16)' });
194
195
 
195
196
  var clearBtn = document.createElement('span');
196
- clearBtn.textContent = '✕ Delete all annotations';
197
+ clearBtn.textContent = '✕ Delete all';
197
198
  clearBtn.title = 'Delete all annotations';
198
199
  Object.assign(clearBtn.style, {
199
200
  display: 'none',
@@ -225,7 +226,18 @@
225
226
  chevron.addEventListener('click', function (e) { e.stopPropagation(); moveSide(); });
226
227
  hoverFx(chevron, { opacity: '1', transform: 'scale(1.2)' }, { opacity: '0.8', transform: 'scale(1)' });
227
228
 
229
+ // Sync status in the control bar (only when a bridge is configured): spinner while
230
+ // syncing, green when synced, red on error — so you always know what's staged.
231
+ var pillSync = document.createElement('span');
232
+ Object.assign(pillSync.style, {
233
+ display: 'none', width: '8px', height: '8px', borderRadius: '999px',
234
+ background: '#6B7280', flexShrink: '0', boxSizing: 'border-box',
235
+ });
236
+ pillSync.title = 'Sync status — click to re-sync';
237
+ pillSync.addEventListener('click', function (e) { e.stopPropagation(); if (BRIDGE) doSync(); });
238
+
228
239
  pill.appendChild(iconBtn);
240
+ pill.appendChild(pillSync);
229
241
  pill.appendChild(pillText);
230
242
  pill.appendChild(listBtn);
231
243
  pill.appendChild(clearBtn);
@@ -233,6 +245,30 @@
233
245
  pillWrap.appendChild(pill);
234
246
  document.body.appendChild(pillWrap);
235
247
 
248
+ // Keyframes for the sync spinner (injected once).
249
+ var spinStyle = document.createElement('style');
250
+ spinStyle.textContent = '@keyframes __specterSpin{to{transform:rotate(360deg)}}';
251
+ markUI(spinStyle);
252
+ document.head.appendChild(spinStyle);
253
+
254
+ // Shared dot styling for the control-bar AND side-panel sync indicators, so they
255
+ // always match: spinner while syncing, green when synced, red on error.
256
+ function applyDotState(el, state) {
257
+ el.style.boxSizing = 'border-box';
258
+ if (state === 'syncing') {
259
+ Object.assign(el.style, { width: '10px', height: '10px', background: 'transparent', border: '2px solid rgba(255,255,255,0.35)', borderTopColor: '#fff', animation: '__specterSpin 0.6s linear infinite' });
260
+ } else if (state === 'synced') {
261
+ Object.assign(el.style, { width: '8px', height: '8px', background: GREEN, border: 'none', animation: 'none' });
262
+ } else { // offline / error
263
+ Object.assign(el.style, { width: '8px', height: '8px', background: '#F26D6D', border: 'none', animation: 'none' });
264
+ }
265
+ }
266
+ function setPillSync(state) {
267
+ if (!BRIDGE) { pillSync.style.display = 'none'; return; }
268
+ pillSync.style.display = 'inline-block';
269
+ applyDotState(pillSync, state);
270
+ }
271
+
236
272
  pillWrap.addEventListener('mouseenter', function () {
237
273
  clearMeasureOverlay();
238
274
  clearMeasureTargetHL();
@@ -262,6 +298,12 @@
262
298
  }
263
299
  }
264
300
 
301
+ // The mode is shown at all times (persistent prefix), so you always know whether
302
+ // hovering shows properties, measurements, or nothing (Comment).
303
+ function modeLabel() {
304
+ return commentMode ? 'Comment' : (measureMode ? 'Measure' : 'Properties');
305
+ }
306
+
265
307
  function expandPill(text) {
266
308
  pill.style.maxWidth = '820px';
267
309
  pillText.style.display = 'inline';
@@ -269,19 +311,17 @@
269
311
  listBtn.style.display = specs.length > 0 ? 'inline-flex' : 'none';
270
312
  clearBtn.style.display = specs.length > 0 ? 'inline' : 'none';
271
313
  pillExpanded = true;
272
- if (text) { pillText.textContent = text; return; }
273
- if (specs.length > 0) {
274
- pillText.textContent = specs.length + (specs.length === 1 ? ' Spec' : ' Specs') + ' · P add · L panel · Cmd+C copy';
275
- } else if (measureMode) {
276
- pillText.textContent = 'Measure · hover distances · P mark · M pin · Cmd+C copy · Option toggle';
277
- } else {
278
- pillText.textContent = 'Properties · P mark / annotate · Cmd+C copy · Option measure';
279
- }
314
+ // Just the mode (+ the action buttons when Specs exist). Shortcuts live in the
315
+ // side panel now, so the pill stays short.
316
+ pillText.textContent = text || modeLabel();
280
317
  }
281
318
 
319
+ // "Collapsed" = the compact resting state: mode label only, no hints/buttons.
320
+ // Still shows the mode so it's visible at all times.
282
321
  function collapsePill() {
283
- pill.style.maxWidth = '32px';
284
- pillText.style.display = 'none';
322
+ pillText.textContent = modeLabel();
323
+ pillText.style.display = 'inline';
324
+ pill.style.maxWidth = '220px';
285
325
  chevron.style.display = 'none';
286
326
  listBtn.style.display = 'none';
287
327
  clearBtn.style.display = 'none';
@@ -290,7 +330,7 @@
290
330
 
291
331
  function flashMode() {
292
332
  if (pillExpanded && pillWrap.matches(':hover')) return;
293
- var text = measureMode ? 'Measure mode' : 'Properties mode';
333
+ var text = commentMode ? 'Comment mode' : (measureMode ? 'Measure mode' : 'Properties mode');
294
334
  expandPill(text);
295
335
  clearTimeout(flashTimer);
296
336
  flashTimer = setTimeout(function () {
@@ -311,11 +351,13 @@
311
351
  function activate() {
312
352
  fiActive = true;
313
353
  measureMode = false;
354
+ commentMode = false;
314
355
  pillWrap.style.display = 'block';
315
356
  document.body.style.cursor = 'crosshair';
316
357
  updatePill();
317
358
  startLoop();
318
359
  reflowSpecs();
360
+ if (BRIDGE) doSync(); // resolve the control-bar sync dot (green if reachable, red if not)
319
361
  }
320
362
 
321
363
  function deactivate() {
@@ -599,24 +641,37 @@
599
641
  return ['[Specter]', head, 'find: ' + resolveLocator(data.el), props.join(' · ')].join('\n');
600
642
  }
601
643
 
644
+ // Group Specs on the SAME element so their identical properties aren't emitted
645
+ // twice — several comments on one thing share ONE property block, each with its
646
+ // own change note. Measure Specs always stand alone (each is a distinct reading).
647
+ function groupSpecs() {
648
+ var groups = [];
649
+ for (var i = 0; i < specs.length; i++) {
650
+ var s = specs[i], g = null;
651
+ if (s.kind === 'element' && s.el) {
652
+ for (var j = 0; j < groups.length; j++) { if (groups[j].kind === 'element' && groups[j].el === s.el) { g = groups[j]; break; } }
653
+ }
654
+ if (g) g.specs.push(s);
655
+ else groups.push({ kind: s.kind, el: s.el, specs: [s] });
656
+ }
657
+ return groups;
658
+ }
659
+
602
660
  // Copy every Spec — using each Spec's body snapshotted at mark time, so Specs
603
661
  // whose element is currently hidden (e.g. inside a closed modal) still copy.
662
+ // Same-element Specs collapse into one block (properties once, notes stacked).
604
663
  function buildSpecsCopyText() {
605
- var n = specs.length;
606
- return specs.map(function (spec, i) {
607
- var tag = spec.kind === 'measure' ? 'Specter Measure' : 'Specter';
664
+ var groups = groupSpecs();
665
+ var n = groups.length;
666
+ return groups.map(function (g, i) {
667
+ var tag = g.kind === 'measure' ? 'Specter Measure' : 'Specter';
608
668
  var header = n > 1 ? '[' + tag + ' ' + (i + 1) + '/' + n + ']' : '[' + tag + ']';
609
- if (spec.note) header += '\n✏️ CHANGE: ' + spec.note;
610
- return spec.body.replace(/^\[Specter[^\]]*\]/, function () { return header; });
669
+ var notes = g.specs.filter(function (s) { return s.note; }).map(function (s) { return '✏️ CHANGE: ' + s.note; });
670
+ if (notes.length) header += '\n' + notes.join('\n');
671
+ return g.specs[0].body.replace(/^\[Specter[^\]]*\]/, function () { return header; });
611
672
  }).join('\n\n---\n\n');
612
673
  }
613
674
 
614
- function linesToHTML(str) {
615
- return str.split('\n').map(function (l) {
616
- return '<div style="margin-bottom:4px;color:#fff">' + esc(l) + '</div>';
617
- }).join('');
618
- }
619
-
620
675
  // ─── Hover outline ──────────────────────────────────────────────────────────
621
676
  function setHoverOutline(el) {
622
677
  if (outlinedEl === el) return;
@@ -648,12 +703,14 @@
648
703
  }
649
704
 
650
705
  function reRenderTooltip() {
706
+ if (commentMode) { hideTooltip(); return; }
651
707
  if (measureMode) {
652
- var text = (pinEl && pinEl !== lastHovered) ? measureBetween(pinEl, lastHovered) : measureToNeighbor(lastHovered);
653
- tooltip.innerHTML = linesToHTML(text);
654
- } else {
655
- tooltip.innerHTML = buildHumanDisplay(buildInfo(lastHovered));
708
+ // Redraw the overlay on scroll/resize; readout box stays hidden (see hover handler).
709
+ if (pinEl && pinEl !== lastHovered) measureBetween(pinEl, lastHovered); else measureToNeighbor(lastHovered);
710
+ hideTooltip();
711
+ return;
656
712
  }
713
+ tooltip.innerHTML = buildHumanDisplay(buildInfo(lastHovered));
657
714
  positionTooltip(lastMouse.x, lastMouse.y);
658
715
  }
659
716
 
@@ -809,7 +866,7 @@
809
866
 
810
867
  function updateBadgeContent(spec) {
811
868
  spec.num.textContent = String(specs.indexOf(spec) + 1);
812
- if (spec.note) spec.noteSpan.textContent = '✏️ ' + spec.note;
869
+ if (spec.note) spec.noteSpan.textContent = spec.note;
813
870
  else spec.noteSpan.textContent = spec.kind === 'measure' ? '⬡ measure' : '';
814
871
  }
815
872
 
@@ -854,10 +911,6 @@
854
911
  updateBadgeContent(spec);
855
912
  }
856
913
 
857
- function findElementSpec(el) {
858
- for (var i = 0; i < specs.length; i++) if (specs[i].kind === 'element' && specs[i].el === el) return specs[i];
859
- return null;
860
- }
861
914
 
862
915
  // If the element sits inside a dialog/modal/drawer/menu, produce a hint for how to
863
916
  // bring it back when it's later hidden — a declarative opener (aria-controls /
@@ -896,7 +949,9 @@
896
949
  if (pinEl && lastHovered && lastHovered !== pinEl) { el = pinEl; body = buildMeasureCopyText(pinEl, lastHovered); }
897
950
  else { el = anchorEl; body = buildNeighborCopyText(anchorEl); }
898
951
  } else {
899
- if (findElementSpec(anchorEl)) return; // P over an already-Spec'd element nothing
952
+ // Multiple Specs per element are allowed in every mode — clustering fans the badges
953
+ // out and same-element output collapses to one property block. Mode only changes
954
+ // what's shown on screen, never whether you can add an annotation.
900
955
  body = buildLLMClipboard(buildInfo(anchorEl));
901
956
  }
902
957
  clearHoverOutline();
@@ -1134,14 +1189,74 @@
1134
1189
  }
1135
1190
 
1136
1191
  var panelList = document.createElement('div');
1137
- Object.assign(panelList.style, { flex: '1', overflowY: 'auto', overflowX: 'hidden' });
1192
+ // overscroll-behavior:contain stops the panel's scroll from chaining into the page.
1193
+ Object.assign(panelList.style, { flex: '1', overflowY: 'auto', overflowX: 'hidden', overscrollBehavior: 'contain' });
1194
+
1195
+ // Keyboard shortcuts reference — collapsible, collapsed by default, lives here (not
1196
+ // in the pill) so the pill stays short.
1197
+ var panelKeys = document.createElement('div');
1198
+ Object.assign(panelKeys.style, {
1199
+ flexShrink: '0', fontSize: '11px', lineHeight: '18px', color: LABEL,
1200
+ borderTop: '1px solid rgba(255,255,255,0.08)',
1201
+ });
1202
+ (function () {
1203
+ var rows = [
1204
+ ['P', 'mark / comment the hovered element'],
1205
+ ['C', 'toggle Comment mode (hide properties)'],
1206
+ ['\u2325 Option', 'toggle Measure mode'],
1207
+ ['M', 'pin an element to measure from'],
1208
+ ['Cmd/Ctrl+C', 'copy all Specs'],
1209
+ ['L', 'toggle this panel'],
1210
+ ];
1211
+ var head = document.createElement('div');
1212
+ Object.assign(head.style, { display: 'flex', alignItems: 'center', gap: '6px', color: '#8A8D96', fontWeight: '700', letterSpacing: '0.04em', textTransform: 'uppercase', fontSize: '11px', padding: '10px 16px', cursor: 'pointer', userSelect: 'none' });
1213
+ var caret = document.createElement('span');
1214
+ caret.textContent = '\u203A'; // ›
1215
+ Object.assign(caret.style, { display: 'inline-block', fontSize: '16px', lineHeight: '1', transition: 'transform 0.15s ease', transform: 'rotate(0deg)' });
1216
+ var headText = document.createElement('span');
1217
+ headText.textContent = 'Keyboard shortcuts';
1218
+ head.appendChild(caret);
1219
+ head.appendChild(headText);
1220
+ var body = document.createElement('div');
1221
+ Object.assign(body.style, { display: 'none', padding: '0 16px 12px' }); // collapsed by default
1222
+ rows.forEach(function (r) {
1223
+ var row = document.createElement('div');
1224
+ Object.assign(row.style, { display: 'flex', gap: '8px', marginBottom: '2px' });
1225
+ var k = document.createElement('span');
1226
+ k.textContent = r[0];
1227
+ Object.assign(k.style, { color: '#E0A3F5', fontWeight: '700', minWidth: '78px', flexShrink: '0' });
1228
+ var d = document.createElement('span');
1229
+ d.textContent = r[1];
1230
+ row.appendChild(k); row.appendChild(d);
1231
+ body.appendChild(row);
1232
+ });
1233
+ var open = false;
1234
+ head.addEventListener('click', function (e) {
1235
+ e.stopPropagation();
1236
+ open = !open;
1237
+ body.style.display = open ? 'block' : 'none';
1238
+ caret.style.transform = open ? 'rotate(90deg)' : 'rotate(0deg)';
1239
+ });
1240
+ panelKeys.appendChild(head);
1241
+ panelKeys.appendChild(body);
1242
+ })();
1138
1243
 
1139
1244
  panelWrap.appendChild(panelHead);
1140
1245
  panelWrap.appendChild(panelTools);
1141
1246
  panelWrap.appendChild(panelHint);
1142
1247
  panelWrap.appendChild(panelList);
1248
+ panelWrap.appendChild(panelKeys);
1143
1249
  document.body.appendChild(panelWrap);
1144
1250
 
1251
+ // Panel scroll and page scroll are mutually exclusive: wheel over the scrollable list
1252
+ // scrolls it natively (overscroll-behavior:contain stops it chaining at the bounds);
1253
+ // wheel over any non-scrolling part of the panel never scrolls the page behind it.
1254
+ panelWrap.addEventListener('wheel', function (e) {
1255
+ var canScroll = panelList.scrollHeight > panelList.clientHeight;
1256
+ if (panelList.contains(e.target) && canScroll) return; // let the list scroll natively
1257
+ e.preventDefault();
1258
+ }, { passive: false });
1259
+
1145
1260
  // ── Auto-sync: mirror the browser's current Specs to the local bridge ──
1146
1261
  // Debounced so rapid edits/typing collapse into one POST. The bridge replaces
1147
1262
  // its snapshot for this URL, so it always reflects what's in the panel — no
@@ -1149,24 +1264,33 @@
1149
1264
  var syncTimer = null;
1150
1265
  function setSyncState(s) {
1151
1266
  if (!BRIDGE) return;
1152
- if (s === 'syncing') { dot.style.background = '#F59E0B'; dotLabel.textContent = 'Syncing…'; }
1153
- else if (s === 'synced') { dot.style.background = GREEN; dotLabel.textContent = specs.length ? (specs.length + (specs.length === 1 ? ' Spec synced' : ' Specs synced')) : 'Synced'; }
1154
- else { dot.style.background = '#6B7280'; dotLabel.textContent = 'Bridge offline'; }
1267
+ setPillSync(s); // control-bar dot
1268
+ applyDotState(dot, s); // side-panel dot same spinner/green/red
1269
+ if (s === 'syncing') { dotLabel.textContent = 'Syncing…'; }
1270
+ else if (s === 'synced') { dotLabel.textContent = specs.length ? (specs.length + (specs.length === 1 ? ' Spec synced' : ' Specs synced')) : 'Synced'; }
1271
+ else { dotLabel.textContent = 'Bridge offline'; }
1155
1272
  }
1156
1273
  function doSync() {
1157
1274
  if (!BRIDGE) return;
1158
1275
  setSyncState('syncing');
1276
+ // Keep the spinner up for at least 1s even on an instant localhost sync, so the
1277
+ // feedback is actually perceptible instead of flashing by.
1278
+ var started = Date.now();
1279
+ var settle = function (state) {
1280
+ var wait = Math.max(0, 1000 - (Date.now() - started));
1281
+ setTimeout(function () { setSyncState(state); }, wait);
1282
+ };
1159
1283
  var payload = {
1160
1284
  url: location.href,
1161
1285
  text: buildSpecsCopyText(),
1162
- specs: specs.map(function (s, i) {
1163
- return { num: i + 1, note: s.note || '', kind: s.kind, body: s.body };
1286
+ specs: groupSpecs().map(function (g, i) {
1287
+ return { num: i + 1, note: g.specs.map(function (s) { return s.note; }).filter(Boolean).join('\n'), kind: g.kind, body: g.specs[0].body };
1164
1288
  }),
1165
1289
  };
1166
1290
  fetch(BRIDGE + '/specs', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) })
1167
1291
  .then(function (r) { if (!r.ok) throw 0; return r.json(); })
1168
- .then(function () { setSyncState('synced'); })
1169
- .catch(function () { setSyncState('offline'); });
1292
+ .then(function () { settle('synced'); })
1293
+ .catch(function () { settle('offline'); });
1170
1294
  }
1171
1295
  function scheduleSync() {
1172
1296
  if (!BRIDGE) return;
@@ -1310,7 +1434,7 @@
1310
1434
  Object.assign(hbar.style, { display: 'flex', alignItems: 'center', gap: '7px', marginTop: '2px', flexWrap: 'wrap' });
1311
1435
  var tag = document.createElement('span');
1312
1436
  tag.textContent = 'HIDDEN';
1313
- Object.assign(tag.style, { fontSize: '9px', fontWeight: '700', letterSpacing: '0.05em', color: '#3A2A05', background: '#F59E0B', borderRadius: '4px', padding: '2px 6px', flexShrink: '0' });
1437
+ Object.assign(tag.style, { fontSize: '11px', fontWeight: '700', letterSpacing: '0.05em', color: '#3A2A05', background: '#F59E0B', borderRadius: '4px', padding: '2px 6px', flexShrink: '0' });
1314
1438
  var hint = document.createElement('span');
1315
1439
  hint.textContent = spec.locate || 'Not on the page right now';
1316
1440
  Object.assign(hint.style, { fontSize: '11px', color: '#C9CBD2', overflow: 'hidden', textOverflow: 'ellipsis' });
@@ -1574,9 +1698,9 @@
1574
1698
  position: 'absolute',
1575
1699
  background: RED,
1576
1700
  color: '#fff',
1577
- fontSize: '10px',
1701
+ fontSize: '12px',
1578
1702
  fontFamily: MONO,
1579
- padding: '1px 4px',
1703
+ padding: '2px 5px',
1580
1704
  borderRadius: '3px',
1581
1705
  pointerEvents: 'none',
1582
1706
  whiteSpace: 'nowrap',
@@ -1776,12 +1900,24 @@
1776
1900
 
1777
1901
  lastHovered = target;
1778
1902
 
1903
+ // Comment mode: outline only (so you know what you're commenting on), no
1904
+ // properties/measure overlay on screen. The Spec still captures everything.
1905
+ if (commentMode) {
1906
+ clearMeasureOverlay();
1907
+ clearMeasureTargetHL();
1908
+ setHoverOutline(target);
1909
+ hideTooltip();
1910
+ return;
1911
+ }
1912
+
1779
1913
  if (measureMode) {
1780
1914
  clearHoverOutline();
1781
1915
  showMeasureTargetHL(target);
1782
- var text = (pinEl && pinEl !== target) ? measureBetween(pinEl, target) : measureToNeighbor(target);
1783
- tooltip.innerHTML = linesToHTML(text);
1784
- positionTooltip(e.clientX, e.clientY);
1916
+ // Draw the on-screen measurement overlay (the px badges + lines) but keep
1917
+ // the dark readout box hidden — it occludes the very measurements it reports.
1918
+ // The full readout still copies with Cmd+C. (Properties mode keeps its box.)
1919
+ if (pinEl && pinEl !== target) measureBetween(pinEl, target); else measureToNeighbor(target);
1920
+ hideTooltip();
1785
1921
  if (pinEl) updatePinHL();
1786
1922
  } else {
1787
1923
  clearMeasureOverlay();
@@ -1863,6 +1999,21 @@
1863
1999
  return;
1864
2000
  }
1865
2001
 
2002
+ // C — toggle Comment mode (outline only; props/measure hidden but still captured).
2003
+ // Plain c only — Cmd/Ctrl+C copy was handled above and returned.
2004
+ if ((e.key === 'c' || e.key === 'C') && !e.metaKey && !e.ctrlKey) {
2005
+ e.preventDefault();
2006
+ commentMode = !commentMode;
2007
+ if (commentMode && measureMode) { measureMode = false; clearPin(); }
2008
+ clearMeasureOverlay();
2009
+ clearMeasureTargetHL();
2010
+ clearHoverOutline();
2011
+ hideTooltip();
2012
+ if (lastHovered && commentMode) setHoverOutline(lastHovered);
2013
+ flashMode();
2014
+ return;
2015
+ }
2016
+
1866
2017
  // Esc only HIDES the plugin — Specs persist and return on reactivate.
1867
2018
  if (e.key === 'Escape') {
1868
2019
  if (panelOpen) { hidePanel(); return; }