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/dist/index.cjs CHANGED
@@ -60,6 +60,7 @@ function getClientScript(options) {
60
60
  // \u2500\u2500\u2500 State \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
61
61
  var fiActive = false;
62
62
  var measureMode = false;
63
+ var commentMode = false; // C: hide props/measure overlays for design review \u2014 Specs still capture them
63
64
  var pinEl = null;
64
65
  var pinHighlight = null;
65
66
  var lastHovered = null;
@@ -225,7 +226,7 @@ function getClientScript(options) {
225
226
  hoverFx(listBtn, { background: 'rgba(255,255,255,0.32)' }, { background: 'rgba(255,255,255,0.16)' });
226
227
 
227
228
  var clearBtn = document.createElement('span');
228
- clearBtn.textContent = '\u2715 Delete all annotations';
229
+ clearBtn.textContent = '\u2715 Delete all';
229
230
  clearBtn.title = 'Delete all annotations';
230
231
  Object.assign(clearBtn.style, {
231
232
  display: 'none',
@@ -257,7 +258,18 @@ function getClientScript(options) {
257
258
  chevron.addEventListener('click', function (e) { e.stopPropagation(); moveSide(); });
258
259
  hoverFx(chevron, { opacity: '1', transform: 'scale(1.2)' }, { opacity: '0.8', transform: 'scale(1)' });
259
260
 
261
+ // Sync status in the control bar (only when a bridge is configured): spinner while
262
+ // syncing, green when synced, red on error \u2014 so you always know what's staged.
263
+ var pillSync = document.createElement('span');
264
+ Object.assign(pillSync.style, {
265
+ display: 'none', width: '8px', height: '8px', borderRadius: '999px',
266
+ background: '#6B7280', flexShrink: '0', boxSizing: 'border-box',
267
+ });
268
+ pillSync.title = 'Sync status \u2014 click to re-sync';
269
+ pillSync.addEventListener('click', function (e) { e.stopPropagation(); if (BRIDGE) doSync(); });
270
+
260
271
  pill.appendChild(iconBtn);
272
+ pill.appendChild(pillSync);
261
273
  pill.appendChild(pillText);
262
274
  pill.appendChild(listBtn);
263
275
  pill.appendChild(clearBtn);
@@ -265,6 +277,30 @@ function getClientScript(options) {
265
277
  pillWrap.appendChild(pill);
266
278
  document.body.appendChild(pillWrap);
267
279
 
280
+ // Keyframes for the sync spinner (injected once).
281
+ var spinStyle = document.createElement('style');
282
+ spinStyle.textContent = '@keyframes __specterSpin{to{transform:rotate(360deg)}}';
283
+ markUI(spinStyle);
284
+ document.head.appendChild(spinStyle);
285
+
286
+ // Shared dot styling for the control-bar AND side-panel sync indicators, so they
287
+ // always match: spinner while syncing, green when synced, red on error.
288
+ function applyDotState(el, state) {
289
+ el.style.boxSizing = 'border-box';
290
+ if (state === 'syncing') {
291
+ 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' });
292
+ } else if (state === 'synced') {
293
+ Object.assign(el.style, { width: '8px', height: '8px', background: GREEN, border: 'none', animation: 'none' });
294
+ } else { // offline / error
295
+ Object.assign(el.style, { width: '8px', height: '8px', background: '#F26D6D', border: 'none', animation: 'none' });
296
+ }
297
+ }
298
+ function setPillSync(state) {
299
+ if (!BRIDGE) { pillSync.style.display = 'none'; return; }
300
+ pillSync.style.display = 'inline-block';
301
+ applyDotState(pillSync, state);
302
+ }
303
+
268
304
  pillWrap.addEventListener('mouseenter', function () {
269
305
  clearMeasureOverlay();
270
306
  clearMeasureTargetHL();
@@ -294,6 +330,12 @@ function getClientScript(options) {
294
330
  }
295
331
  }
296
332
 
333
+ // The mode is shown at all times (persistent prefix), so you always know whether
334
+ // hovering shows properties, measurements, or nothing (Comment).
335
+ function modeLabel() {
336
+ return commentMode ? 'Comment' : (measureMode ? 'Measure' : 'Properties');
337
+ }
338
+
297
339
  function expandPill(text) {
298
340
  pill.style.maxWidth = '820px';
299
341
  pillText.style.display = 'inline';
@@ -301,19 +343,17 @@ function getClientScript(options) {
301
343
  listBtn.style.display = specs.length > 0 ? 'inline-flex' : 'none';
302
344
  clearBtn.style.display = specs.length > 0 ? 'inline' : 'none';
303
345
  pillExpanded = true;
304
- if (text) { pillText.textContent = text; return; }
305
- if (specs.length > 0) {
306
- pillText.textContent = specs.length + (specs.length === 1 ? ' Spec' : ' Specs') + ' \xB7 P add \xB7 L panel \xB7 Cmd+C copy';
307
- } else if (measureMode) {
308
- pillText.textContent = 'Measure \xB7 hover distances \xB7 P mark \xB7 M pin \xB7 Cmd+C copy \xB7 Option toggle';
309
- } else {
310
- pillText.textContent = 'Properties \xB7 P mark / annotate \xB7 Cmd+C copy \xB7 Option measure';
311
- }
346
+ // Just the mode (+ the action buttons when Specs exist). Shortcuts live in the
347
+ // side panel now, so the pill stays short.
348
+ pillText.textContent = text || modeLabel();
312
349
  }
313
350
 
351
+ // "Collapsed" = the compact resting state: mode label only, no hints/buttons.
352
+ // Still shows the mode so it's visible at all times.
314
353
  function collapsePill() {
315
- pill.style.maxWidth = '32px';
316
- pillText.style.display = 'none';
354
+ pillText.textContent = modeLabel();
355
+ pillText.style.display = 'inline';
356
+ pill.style.maxWidth = '220px';
317
357
  chevron.style.display = 'none';
318
358
  listBtn.style.display = 'none';
319
359
  clearBtn.style.display = 'none';
@@ -322,7 +362,7 @@ function getClientScript(options) {
322
362
 
323
363
  function flashMode() {
324
364
  if (pillExpanded && pillWrap.matches(':hover')) return;
325
- var text = measureMode ? '\u2B21 Measure mode' : '\u25C9 Properties mode';
365
+ var text = commentMode ? 'Comment mode' : (measureMode ? 'Measure mode' : 'Properties mode');
326
366
  expandPill(text);
327
367
  clearTimeout(flashTimer);
328
368
  flashTimer = setTimeout(function () {
@@ -343,11 +383,13 @@ function getClientScript(options) {
343
383
  function activate() {
344
384
  fiActive = true;
345
385
  measureMode = false;
386
+ commentMode = false;
346
387
  pillWrap.style.display = 'block';
347
388
  document.body.style.cursor = 'crosshair';
348
389
  updatePill();
349
390
  startLoop();
350
391
  reflowSpecs();
392
+ if (BRIDGE) doSync(); // resolve the control-bar sync dot (green if reachable, red if not)
351
393
  }
352
394
 
353
395
  function deactivate() {
@@ -631,24 +673,37 @@ function getClientScript(options) {
631
673
  return ['[Specter]', head, 'find: ' + resolveLocator(data.el), props.join(' \xB7 ')].join('\\n');
632
674
  }
633
675
 
676
+ // Group Specs on the SAME element so their identical properties aren't emitted
677
+ // twice \u2014 several comments on one thing share ONE property block, each with its
678
+ // own change note. Measure Specs always stand alone (each is a distinct reading).
679
+ function groupSpecs() {
680
+ var groups = [];
681
+ for (var i = 0; i < specs.length; i++) {
682
+ var s = specs[i], g = null;
683
+ if (s.kind === 'element' && s.el) {
684
+ for (var j = 0; j < groups.length; j++) { if (groups[j].kind === 'element' && groups[j].el === s.el) { g = groups[j]; break; } }
685
+ }
686
+ if (g) g.specs.push(s);
687
+ else groups.push({ kind: s.kind, el: s.el, specs: [s] });
688
+ }
689
+ return groups;
690
+ }
691
+
634
692
  // Copy every Spec \u2014 using each Spec's body snapshotted at mark time, so Specs
635
693
  // whose element is currently hidden (e.g. inside a closed modal) still copy.
694
+ // Same-element Specs collapse into one block (properties once, notes stacked).
636
695
  function buildSpecsCopyText() {
637
- var n = specs.length;
638
- return specs.map(function (spec, i) {
639
- var tag = spec.kind === 'measure' ? 'Specter Measure' : 'Specter';
696
+ var groups = groupSpecs();
697
+ var n = groups.length;
698
+ return groups.map(function (g, i) {
699
+ var tag = g.kind === 'measure' ? 'Specter Measure' : 'Specter';
640
700
  var header = n > 1 ? '[' + tag + ' ' + (i + 1) + '/' + n + ']' : '[' + tag + ']';
641
- if (spec.note) header += '\\n\u270F\uFE0F CHANGE: ' + spec.note;
642
- return spec.body.replace(/^\\[Specter[^\\]]*\\]/, function () { return header; });
701
+ var notes = g.specs.filter(function (s) { return s.note; }).map(function (s) { return '\u270F\uFE0F CHANGE: ' + s.note; });
702
+ if (notes.length) header += '\\n' + notes.join('\\n');
703
+ return g.specs[0].body.replace(/^\\[Specter[^\\]]*\\]/, function () { return header; });
643
704
  }).join('\\n\\n---\\n\\n');
644
705
  }
645
706
 
646
- function linesToHTML(str) {
647
- return str.split('\\n').map(function (l) {
648
- return '<div style="margin-bottom:4px;color:#fff">' + esc(l) + '</div>';
649
- }).join('');
650
- }
651
-
652
707
  // \u2500\u2500\u2500 Hover outline \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
653
708
  function setHoverOutline(el) {
654
709
  if (outlinedEl === el) return;
@@ -680,12 +735,14 @@ function getClientScript(options) {
680
735
  }
681
736
 
682
737
  function reRenderTooltip() {
738
+ if (commentMode) { hideTooltip(); return; }
683
739
  if (measureMode) {
684
- var text = (pinEl && pinEl !== lastHovered) ? measureBetween(pinEl, lastHovered) : measureToNeighbor(lastHovered);
685
- tooltip.innerHTML = linesToHTML(text);
686
- } else {
687
- tooltip.innerHTML = buildHumanDisplay(buildInfo(lastHovered));
740
+ // Redraw the overlay on scroll/resize; readout box stays hidden (see hover handler).
741
+ if (pinEl && pinEl !== lastHovered) measureBetween(pinEl, lastHovered); else measureToNeighbor(lastHovered);
742
+ hideTooltip();
743
+ return;
688
744
  }
745
+ tooltip.innerHTML = buildHumanDisplay(buildInfo(lastHovered));
689
746
  positionTooltip(lastMouse.x, lastMouse.y);
690
747
  }
691
748
 
@@ -841,7 +898,7 @@ function getClientScript(options) {
841
898
 
842
899
  function updateBadgeContent(spec) {
843
900
  spec.num.textContent = String(specs.indexOf(spec) + 1);
844
- if (spec.note) spec.noteSpan.textContent = '\u270F\uFE0F ' + spec.note;
901
+ if (spec.note) spec.noteSpan.textContent = spec.note;
845
902
  else spec.noteSpan.textContent = spec.kind === 'measure' ? '\u2B21 measure' : '';
846
903
  }
847
904
 
@@ -886,10 +943,6 @@ function getClientScript(options) {
886
943
  updateBadgeContent(spec);
887
944
  }
888
945
 
889
- function findElementSpec(el) {
890
- for (var i = 0; i < specs.length; i++) if (specs[i].kind === 'element' && specs[i].el === el) return specs[i];
891
- return null;
892
- }
893
946
 
894
947
  // If the element sits inside a dialog/modal/drawer/menu, produce a hint for how to
895
948
  // bring it back when it's later hidden \u2014 a declarative opener (aria-controls /
@@ -928,7 +981,9 @@ function getClientScript(options) {
928
981
  if (pinEl && lastHovered && lastHovered !== pinEl) { el = pinEl; body = buildMeasureCopyText(pinEl, lastHovered); }
929
982
  else { el = anchorEl; body = buildNeighborCopyText(anchorEl); }
930
983
  } else {
931
- if (findElementSpec(anchorEl)) return; // P over an already-Spec'd element \u2192 nothing
984
+ // Multiple Specs per element are allowed in every mode \u2014 clustering fans the badges
985
+ // out and same-element output collapses to one property block. Mode only changes
986
+ // what's shown on screen, never whether you can add an annotation.
932
987
  body = buildLLMClipboard(buildInfo(anchorEl));
933
988
  }
934
989
  clearHoverOutline();
@@ -1166,14 +1221,74 @@ function getClientScript(options) {
1166
1221
  }
1167
1222
 
1168
1223
  var panelList = document.createElement('div');
1169
- Object.assign(panelList.style, { flex: '1', overflowY: 'auto', overflowX: 'hidden' });
1224
+ // overscroll-behavior:contain stops the panel's scroll from chaining into the page.
1225
+ Object.assign(panelList.style, { flex: '1', overflowY: 'auto', overflowX: 'hidden', overscrollBehavior: 'contain' });
1226
+
1227
+ // Keyboard shortcuts reference \u2014 collapsible, collapsed by default, lives here (not
1228
+ // in the pill) so the pill stays short.
1229
+ var panelKeys = document.createElement('div');
1230
+ Object.assign(panelKeys.style, {
1231
+ flexShrink: '0', fontSize: '11px', lineHeight: '18px', color: LABEL,
1232
+ borderTop: '1px solid rgba(255,255,255,0.08)',
1233
+ });
1234
+ (function () {
1235
+ var rows = [
1236
+ ['P', 'mark / comment the hovered element'],
1237
+ ['C', 'toggle Comment mode (hide properties)'],
1238
+ ['\\u2325 Option', 'toggle Measure mode'],
1239
+ ['M', 'pin an element to measure from'],
1240
+ ['Cmd/Ctrl+C', 'copy all Specs'],
1241
+ ['L', 'toggle this panel'],
1242
+ ];
1243
+ var head = document.createElement('div');
1244
+ 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' });
1245
+ var caret = document.createElement('span');
1246
+ caret.textContent = '\\u203A'; // \u203A
1247
+ Object.assign(caret.style, { display: 'inline-block', fontSize: '16px', lineHeight: '1', transition: 'transform 0.15s ease', transform: 'rotate(0deg)' });
1248
+ var headText = document.createElement('span');
1249
+ headText.textContent = 'Keyboard shortcuts';
1250
+ head.appendChild(caret);
1251
+ head.appendChild(headText);
1252
+ var body = document.createElement('div');
1253
+ Object.assign(body.style, { display: 'none', padding: '0 16px 12px' }); // collapsed by default
1254
+ rows.forEach(function (r) {
1255
+ var row = document.createElement('div');
1256
+ Object.assign(row.style, { display: 'flex', gap: '8px', marginBottom: '2px' });
1257
+ var k = document.createElement('span');
1258
+ k.textContent = r[0];
1259
+ Object.assign(k.style, { color: '#E0A3F5', fontWeight: '700', minWidth: '78px', flexShrink: '0' });
1260
+ var d = document.createElement('span');
1261
+ d.textContent = r[1];
1262
+ row.appendChild(k); row.appendChild(d);
1263
+ body.appendChild(row);
1264
+ });
1265
+ var open = false;
1266
+ head.addEventListener('click', function (e) {
1267
+ e.stopPropagation();
1268
+ open = !open;
1269
+ body.style.display = open ? 'block' : 'none';
1270
+ caret.style.transform = open ? 'rotate(90deg)' : 'rotate(0deg)';
1271
+ });
1272
+ panelKeys.appendChild(head);
1273
+ panelKeys.appendChild(body);
1274
+ })();
1170
1275
 
1171
1276
  panelWrap.appendChild(panelHead);
1172
1277
  panelWrap.appendChild(panelTools);
1173
1278
  panelWrap.appendChild(panelHint);
1174
1279
  panelWrap.appendChild(panelList);
1280
+ panelWrap.appendChild(panelKeys);
1175
1281
  document.body.appendChild(panelWrap);
1176
1282
 
1283
+ // Panel scroll and page scroll are mutually exclusive: wheel over the scrollable list
1284
+ // scrolls it natively (overscroll-behavior:contain stops it chaining at the bounds);
1285
+ // wheel over any non-scrolling part of the panel never scrolls the page behind it.
1286
+ panelWrap.addEventListener('wheel', function (e) {
1287
+ var canScroll = panelList.scrollHeight > panelList.clientHeight;
1288
+ if (panelList.contains(e.target) && canScroll) return; // let the list scroll natively
1289
+ e.preventDefault();
1290
+ }, { passive: false });
1291
+
1177
1292
  // \u2500\u2500 Auto-sync: mirror the browser's current Specs to the local bridge \u2500\u2500
1178
1293
  // Debounced so rapid edits/typing collapse into one POST. The bridge replaces
1179
1294
  // its snapshot for this URL, so it always reflects what's in the panel \u2014 no
@@ -1181,24 +1296,33 @@ function getClientScript(options) {
1181
1296
  var syncTimer = null;
1182
1297
  function setSyncState(s) {
1183
1298
  if (!BRIDGE) return;
1184
- if (s === 'syncing') { dot.style.background = '#F59E0B'; dotLabel.textContent = 'Syncing\u2026'; }
1185
- else if (s === 'synced') { dot.style.background = GREEN; dotLabel.textContent = specs.length ? (specs.length + (specs.length === 1 ? ' Spec synced' : ' Specs synced')) : 'Synced'; }
1186
- else { dot.style.background = '#6B7280'; dotLabel.textContent = 'Bridge offline'; }
1299
+ setPillSync(s); // control-bar dot
1300
+ applyDotState(dot, s); // side-panel dot \u2014 same spinner/green/red
1301
+ if (s === 'syncing') { dotLabel.textContent = 'Syncing\u2026'; }
1302
+ else if (s === 'synced') { dotLabel.textContent = specs.length ? (specs.length + (specs.length === 1 ? ' Spec synced' : ' Specs synced')) : 'Synced'; }
1303
+ else { dotLabel.textContent = 'Bridge offline'; }
1187
1304
  }
1188
1305
  function doSync() {
1189
1306
  if (!BRIDGE) return;
1190
1307
  setSyncState('syncing');
1308
+ // Keep the spinner up for at least 1s even on an instant localhost sync, so the
1309
+ // feedback is actually perceptible instead of flashing by.
1310
+ var started = Date.now();
1311
+ var settle = function (state) {
1312
+ var wait = Math.max(0, 1000 - (Date.now() - started));
1313
+ setTimeout(function () { setSyncState(state); }, wait);
1314
+ };
1191
1315
  var payload = {
1192
1316
  url: location.href,
1193
1317
  text: buildSpecsCopyText(),
1194
- specs: specs.map(function (s, i) {
1195
- return { num: i + 1, note: s.note || '', kind: s.kind, body: s.body };
1318
+ specs: groupSpecs().map(function (g, i) {
1319
+ 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 };
1196
1320
  }),
1197
1321
  };
1198
1322
  fetch(BRIDGE + '/specs', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) })
1199
1323
  .then(function (r) { if (!r.ok) throw 0; return r.json(); })
1200
- .then(function () { setSyncState('synced'); })
1201
- .catch(function () { setSyncState('offline'); });
1324
+ .then(function () { settle('synced'); })
1325
+ .catch(function () { settle('offline'); });
1202
1326
  }
1203
1327
  function scheduleSync() {
1204
1328
  if (!BRIDGE) return;
@@ -1342,7 +1466,7 @@ function getClientScript(options) {
1342
1466
  Object.assign(hbar.style, { display: 'flex', alignItems: 'center', gap: '7px', marginTop: '2px', flexWrap: 'wrap' });
1343
1467
  var tag = document.createElement('span');
1344
1468
  tag.textContent = 'HIDDEN';
1345
- Object.assign(tag.style, { fontSize: '9px', fontWeight: '700', letterSpacing: '0.05em', color: '#3A2A05', background: '#F59E0B', borderRadius: '4px', padding: '2px 6px', flexShrink: '0' });
1469
+ Object.assign(tag.style, { fontSize: '11px', fontWeight: '700', letterSpacing: '0.05em', color: '#3A2A05', background: '#F59E0B', borderRadius: '4px', padding: '2px 6px', flexShrink: '0' });
1346
1470
  var hint = document.createElement('span');
1347
1471
  hint.textContent = spec.locate || 'Not on the page right now';
1348
1472
  Object.assign(hint.style, { fontSize: '11px', color: '#C9CBD2', overflow: 'hidden', textOverflow: 'ellipsis' });
@@ -1606,9 +1730,9 @@ function getClientScript(options) {
1606
1730
  position: 'absolute',
1607
1731
  background: RED,
1608
1732
  color: '#fff',
1609
- fontSize: '10px',
1733
+ fontSize: '12px',
1610
1734
  fontFamily: MONO,
1611
- padding: '1px 4px',
1735
+ padding: '2px 5px',
1612
1736
  borderRadius: '3px',
1613
1737
  pointerEvents: 'none',
1614
1738
  whiteSpace: 'nowrap',
@@ -1808,12 +1932,24 @@ function getClientScript(options) {
1808
1932
 
1809
1933
  lastHovered = target;
1810
1934
 
1935
+ // Comment mode: outline only (so you know what you're commenting on), no
1936
+ // properties/measure overlay on screen. The Spec still captures everything.
1937
+ if (commentMode) {
1938
+ clearMeasureOverlay();
1939
+ clearMeasureTargetHL();
1940
+ setHoverOutline(target);
1941
+ hideTooltip();
1942
+ return;
1943
+ }
1944
+
1811
1945
  if (measureMode) {
1812
1946
  clearHoverOutline();
1813
1947
  showMeasureTargetHL(target);
1814
- var text = (pinEl && pinEl !== target) ? measureBetween(pinEl, target) : measureToNeighbor(target);
1815
- tooltip.innerHTML = linesToHTML(text);
1816
- positionTooltip(e.clientX, e.clientY);
1948
+ // Draw the on-screen measurement overlay (the px badges + lines) but keep
1949
+ // the dark readout box hidden \u2014 it occludes the very measurements it reports.
1950
+ // The full readout still copies with Cmd+C. (Properties mode keeps its box.)
1951
+ if (pinEl && pinEl !== target) measureBetween(pinEl, target); else measureToNeighbor(target);
1952
+ hideTooltip();
1817
1953
  if (pinEl) updatePinHL();
1818
1954
  } else {
1819
1955
  clearMeasureOverlay();
@@ -1895,6 +2031,21 @@ function getClientScript(options) {
1895
2031
  return;
1896
2032
  }
1897
2033
 
2034
+ // C \u2014 toggle Comment mode (outline only; props/measure hidden but still captured).
2035
+ // Plain c only \u2014 Cmd/Ctrl+C copy was handled above and returned.
2036
+ if ((e.key === 'c' || e.key === 'C') && !e.metaKey && !e.ctrlKey) {
2037
+ e.preventDefault();
2038
+ commentMode = !commentMode;
2039
+ if (commentMode && measureMode) { measureMode = false; clearPin(); }
2040
+ clearMeasureOverlay();
2041
+ clearMeasureTargetHL();
2042
+ clearHoverOutline();
2043
+ hideTooltip();
2044
+ if (lastHovered && commentMode) setHoverOutline(lastHovered);
2045
+ flashMode();
2046
+ return;
2047
+ }
2048
+
1898
2049
  // Esc only HIDES the plugin \u2014 Specs persist and return on reactivate.
1899
2050
  if (e.key === 'Escape') {
1900
2051
  if (panelOpen) { hidePanel(); return; }