@comty.io/core 0.1.0 → 0.1.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/src/browser.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { getComtyStyle } from "./style.js";
2
2
  import { createAnchorMatcher } from "./anchors.js";
3
- import { createScreenshotCapture } from "./capture.js";
3
+ import { createScreenshotCapture, createColorMagnifier } from "./capture.js";
4
4
  import { createCommentThread } from "./comments.js";
5
+ import { createMeasurements } from "./measurements.js";
5
6
  // Shared browser runtime. Both npm and the standalone CDN script use this file.
6
7
  const instanceKey = Symbol.for("comty.sdk.instance");
7
8
 
@@ -139,6 +140,13 @@ export function initComty(options = {}) {
139
140
  const style = document.createElement("style");
140
141
  style.innerHTML = getComtyStyle();
141
142
  document.head.appendChild(style);
143
+ if (!document.querySelector('link[data-comty-font]')) {
144
+ const font = document.createElement('link');
145
+ font.rel = 'stylesheet';
146
+ font.href = 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap';
147
+ font.dataset.comtyFont = '';
148
+ document.head.appendChild(font);
149
+ }
142
150
 
143
151
  const toolbar = document.createElement("div");
144
152
  toolbar.className = "comty-toolbar";
@@ -152,6 +160,12 @@ export function initComty(options = {}) {
152
160
  toolbarSurface.className = "comty-toolbar-surface";
153
161
  toolbar.appendChild(toolbarSurface);
154
162
  const icon = (paths) => `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`;
163
+ const dragHandle = document.createElement("span");
164
+ dragHandle.className = "comty-toolbar-grip";
165
+ dragHandle.title = "Drag to move";
166
+ dragHandle.setAttribute("aria-hidden", "true");
167
+ dragHandle.innerHTML = icon('<g fill="currentColor" stroke="none"><circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/><circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/><circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/></g>');
168
+ toolbar.appendChild(dragHandle);
155
169
  function toolButton(label, paths, action) {
156
170
  const button = document.createElement("button");
157
171
  button.className = "comty-control-button";
@@ -199,6 +213,10 @@ export function initComty(options = {}) {
199
213
  agentHistoryBtn.setAttribute("aria-expanded", "false");
200
214
  agentTools.appendChild(agentHistoryBtn);
201
215
  }
216
+ const primaryTools = document.createElement("div");
217
+ primaryTools.className = "comty-tool-primary";
218
+ toolbar.insertBefore(primaryTools, browseBtn);
219
+ for (const button of [browseBtn, inspectorBtn, btn, agentBtn].filter(Boolean)) primaryTools.appendChild(button);
202
220
  const inspectorTools = document.createElement("div");
203
221
  inspectorTools.className = "comty-tool-context";
204
222
  inspectorTools.hidden = true;
@@ -212,11 +230,24 @@ export function initComty(options = {}) {
212
230
  let rulerDrag = null;
213
231
  const rulerGuides = [];
214
232
  let colorPickerController = null;
215
- let colorToastTimer = null;
216
- let colorToastAnimation = null;
233
+ const recentColors = [];
234
+ const recentFonts = [];
235
+ let propertyKind = null;
236
+ let propertySelection = null;
237
+ let propertyOpener = null;
238
+ let propertyAnimation = null;
239
+ let propertyStripAnimation = null;
240
+ let propertyPicking = null;
241
+ const colorMagnifier = createColorMagnifier({ root, isComtyElement, capture: () => captureViewportScreenshot() });
217
242
  const overlayTools = document.createElement("div");
218
243
  overlayTools.className = "comty-overlay-tools";
219
244
  root.prepend(overlayTools);
245
+ const measurements = createMeasurements({
246
+ root,
247
+ isComtyElement,
248
+ isElementTreeHidden,
249
+ onExit() { rulersEnabled = false; syncInspectorTools(); },
250
+ });
220
251
  const gridLayer = document.createElement("div");
221
252
  gridLayer.className = "comty-layout-guides";
222
253
  overlayTools.appendChild(gridLayer);
@@ -228,6 +259,11 @@ export function initComty(options = {}) {
228
259
  toolPanel.className = "comty-tool-panel";
229
260
  toolPanel.hidden = true;
230
261
  root.appendChild(toolPanel);
262
+ const propertyPanel = document.createElement("section");
263
+ propertyPanel.className = "comty-property-panel";
264
+ propertyPanel.id = "comty-property-panel";
265
+ propertyPanel.hidden = true;
266
+ root.appendChild(propertyPanel);
231
267
  const xrayStyle = document.createElement("style");
232
268
  // Use an outline overlay: geometry and the page's existing styles stay intact.
233
269
  xrayStyle.textContent = `body *:not(#comty-root):not(#comty-root *) { outline: 1px solid ${getComputedStyle(root).getPropertyValue('--comty-xray').trim()} !important; }`;
@@ -236,10 +272,17 @@ export function initComty(options = {}) {
236
272
  syncInspectorTools();
237
273
  });
238
274
  const rulersBtn = toolButton("Rulers · R", '<path d="M3 3h18v6H9v12H3Z"/><path d="M7 3v3m4-3v3m4-3v3M3 11h3m-3 4h3m-3 4h3"/>', () => {
275
+ closePropertyPanel();
276
+ if (motion.enabled) setMotionEnabled(false);
239
277
  rulersEnabled = !rulersEnabled;
278
+ selectedElement = null;
279
+ measuredRegion = null;
280
+ inspectDrag = null;
281
+ hideInspector();
240
282
  syncInspectorTools();
241
283
  });
242
284
  const gridBtn = toolButton("Layout guides · G", '<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 10h18M10 3v18"/>', () => {
285
+ closePropertyPanel();
243
286
  if (motion.enabled) setMotionEnabled(false);
244
287
  gridPanelOpen = !gridPanelOpen;
245
288
  if (gridPanelOpen && !layoutGuides.length) addLayoutGuide();
@@ -247,10 +290,16 @@ export function initComty(options = {}) {
247
290
  syncInspectorTools();
248
291
  });
249
292
  gridBtn.setAttribute("aria-expanded", "false");
250
- const pipetteBtn = toolButton("Sample color · P", '<path d="m16 3 5 5-4 4-5-5Zm-3 5L4 17v3h3l9-9M11 6l7 7"/>', sampleColor);
293
+ const pipetteBtn = toolButton("Sample color · P", '<path d="m16 3 5 5-4 4-5-5Zm-3 5L4 17v3h3l9-9M11 6l7 7"/>', event => togglePropertyPanel("color", pipetteBtn, event.shiftKey));
294
+ pipetteBtn.title = "Sample color · P · Shift-click to sample the screen";
295
+ const typographyBtn = toolButton("Typography · T", '<path d="m5 20 7-16 7 16M8 14h8M3 20h5m8 0h5M10 4h4"/>', () => togglePropertyPanel("font", typographyBtn));
296
+ for (const button of [pipetteBtn, typographyBtn]) {
297
+ button.setAttribute("aria-controls", propertyPanel.id);
298
+ button.setAttribute("aria-expanded", "false");
299
+ }
251
300
  const motionBtn = toolButton("Motion · M", '<circle cx="12" cy="14" r="8"/><path d="M9 2h6m-3 4V2m6 5 2-2m-8 5v4l3 2"/>', () => setMotionEnabled(!motion.enabled));
252
301
  motionBtn.setAttribute("aria-expanded", "false");
253
- for (const button of [xrayBtn, rulersBtn, gridBtn, pipetteBtn, motionBtn]) inspectorTools.appendChild(button);
302
+ for (const button of [xrayBtn, rulersBtn, gridBtn, pipetteBtn, typographyBtn, motionBtn]) inspectorTools.appendChild(button);
254
303
 
255
304
  const motion = { enabled: false, speed: 1, paused: false, managed: new Map(), shadowRoots: [], nextRootScan: 0 };
256
305
  const motionPanel = document.createElement('section');
@@ -299,6 +348,9 @@ export function initComty(options = {}) {
299
348
  motion.enabled = enabled; motionPanel.hidden = !enabled;
300
349
  motionBtn.setAttribute('aria-pressed', String(enabled)); motionBtn.setAttribute('aria-expanded', String(enabled));
301
350
  if (enabled) {
351
+ closePropertyPanel();
352
+ rulersEnabled = false;
353
+ syncInspectorTools();
302
354
  gridPanelOpen = false; toolPanel.hidden = true; gridBtn.setAttribute('aria-expanded', 'false');
303
355
  measuredRegion = null; inspectDrag = null; selectedElement = null; hideInspector();
304
356
  motion.nextRootScan = 0; updateMotion(); renderMotionPanel(); positionToolbar(); ensureLiveTracking();
@@ -402,32 +454,101 @@ export function initComty(options = {}) {
402
454
  }
403
455
  }
404
456
 
457
+ let closeGuideMenu = () => {};
458
+ function guideSelect(label, options, value, onChange) {
459
+ const trigger = document.createElement("button");
460
+ trigger.type = "button";
461
+ trigger.className = "comty-guide-select";
462
+ trigger.setAttribute("aria-label", label);
463
+ trigger.setAttribute("aria-haspopup", "menu");
464
+ trigger.setAttribute("aria-expanded", "false");
465
+ function setValue(next) {
466
+ trigger.value = next;
467
+ trigger.innerHTML = `${options.find(option => option[0] === next)[1]}${icon('<path d="m8 10 4 4 4-4"/>')}`;
468
+ }
469
+ setValue(value);
470
+ function open() {
471
+ const wasOpen = trigger.getAttribute("aria-expanded") === "true";
472
+ closeGuideMenu();
473
+ if (wasOpen) return;
474
+ root.querySelectorAll('.comty-popover').forEach(popover => popover.remove());
475
+ const menu = document.createElement("div");
476
+ menu.className = "comty-popover comty-guide-popover";
477
+ menu.setAttribute("role", "menu");
478
+ menu.setAttribute("aria-label", label);
479
+ const controller = new AbortController();
480
+ const close = (focus = false) => {
481
+ controller.abort(); menu.remove();
482
+ trigger.setAttribute("aria-expanded", "false");
483
+ if (focus && trigger.isConnected) trigger.focus();
484
+ closeGuideMenu = () => {};
485
+ };
486
+ closeGuideMenu = close;
487
+ const items = options.map(([key, name]) => {
488
+ const item = document.createElement("button");
489
+ item.type = "button";
490
+ item.className = "comty-popover-item";
491
+ item.setAttribute("role", "menuitemradio");
492
+ item.setAttribute("aria-checked", String(key === trigger.value));
493
+ item.textContent = name;
494
+ if (key === trigger.value) {
495
+ const check = document.createElement("span");
496
+ check.className = "comty-popover-item-check";
497
+ check.innerHTML = icon('<path d="m5 12 4 4 10-10"/>');
498
+ item.appendChild(check);
499
+ }
500
+ item.onclick = () => { close(); setValue(key); onChange(key); if (trigger.isConnected) trigger.focus(); };
501
+ menu.appendChild(item);
502
+ return item;
503
+ });
504
+ toolPanel.appendChild(menu);
505
+ trigger.setAttribute("aria-expanded", "true");
506
+ const rect = trigger.getBoundingClientRect();
507
+ menu.style.left = `${Math.max(8, Math.min(rect.right - menu.offsetWidth, innerWidth - menu.offsetWidth - 8))}px`;
508
+ menu.style.top = `${rect.bottom + menu.offsetHeight + 4 <= innerHeight - 8 ? rect.bottom + 4 : Math.max(8, rect.top - menu.offsetHeight - 4)}px`;
509
+ items[options.findIndex(option => option[0] === trigger.value)].focus();
510
+ menu.onkeydown = event => {
511
+ const index = items.indexOf(document.activeElement);
512
+ if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
513
+ event.preventDefault(); event.stopPropagation();
514
+ items[event.key === "Home" ? 0 : event.key === "End" ? items.length - 1 : (index + (event.key === "ArrowDown" ? 1 : -1) + items.length) % items.length].focus();
515
+ } else if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); close(true); }
516
+ else if (event.key === "Tab") close();
517
+ };
518
+ document.addEventListener("pointerdown", event => {
519
+ if (!menu.contains(event.target) && !trigger.contains(event.target)) close();
520
+ }, {capture: true, signal: controller.signal});
521
+ window.addEventListener("resize", () => close(), {signal: controller.signal});
522
+ }
523
+ trigger.onclick = open;
524
+ trigger.onkeydown = event => {
525
+ if (["ArrowDown", "ArrowUp"].includes(event.key)) { event.preventDefault(); open(); }
526
+ };
527
+ return { trigger, setValue };
528
+ }
529
+
405
530
  function renderGridPanel() {
406
- clearTimeout(colorToastTimer);
407
- colorToastAnimation?.cancel();
408
- colorToastAnimation = null;
409
- toolPanel.classList.remove("comty-color-toast");
531
+ closeGuideMenu();
410
532
  toolPanel.hidden = !gridPanelOpen || !session.inspect;
411
533
  gridBtn.setAttribute("aria-expanded", String(gridPanelOpen && session.inspect));
412
534
  if (toolPanel.hidden) return;
413
535
  toolPanel.setAttribute("aria-label", "Layout guides");
414
- toolPanel.innerHTML = `<div class="comty-tool-panel-header"><strong>Layout guides</strong><select aria-label="Guide type"><option value="columns">Columns</option><option value="rows">Rows</option><option value="grid">Grid</option></select><button type="button" data-add aria-label="Add layout guide">${icon('<path d="M12 5v14M5 12h14"/>')}</button></div><div class="comty-guide-list"></div>`;
536
+ toolPanel.innerHTML = `<div class="comty-tool-panel-header"><strong>Layout guides</strong><span data-guide-type></span><button type="button" data-add aria-label="Add layout guide">${icon('<path d="M12 5v14M5 12h14"/>')}</button></div><div class="comty-guide-list"></div>`;
415
537
  const selectedGuide = layoutGuides.find(guide => guide.id === selectedGuideId) || layoutGuides[0];
416
538
  selectedGuideId = selectedGuide?.id ?? null;
417
- const typeSelect = toolPanel.querySelector("select");
418
- typeSelect.value = selectedGuide?.kind ?? "columns";
419
- typeSelect.onchange = () => {
539
+ const typeSelect = guideSelect("Guide type", [["columns", "Columns"], ["rows", "Rows"], ["grid", "Grid"]], selectedGuide?.kind ?? "columns", value => {
420
540
  const guide = layoutGuides.find(guide => guide.id === selectedGuideId);
421
541
  if (guide) {
422
- if ((guide.kind === "grid") !== (typeSelect.value === "grid")) guide.size = typeSelect.value === "grid" ? 8 : 72;
423
- guide.kind = typeSelect.value;
424
- } else addLayoutGuide(typeSelect.value);
542
+ if ((guide.kind === "grid") !== (value === "grid")) guide.size = value === "grid" ? 8 : 72;
543
+ guide.kind = value;
544
+ } else addLayoutGuide(value);
425
545
  renderGridPanel();
426
546
  syncInspectorTools();
427
- toolPanel.querySelector("select").focus();
428
- };
547
+ toolPanel.querySelector('[aria-label="Guide type"]').focus();
548
+ });
549
+ toolPanel.querySelector("[data-guide-type]").replaceWith(typeSelect.trigger);
429
550
  toolPanel.querySelector("[data-add]").onclick = () => {
430
- addLayoutGuide(toolPanel.querySelector("select").value);
551
+ addLayoutGuide(typeSelect.trigger.value);
431
552
  renderGridPanel();
432
553
  };
433
554
  const list = toolPanel.querySelector(".comty-guide-list");
@@ -440,8 +561,14 @@ export function initComty(options = {}) {
440
561
  item.innerHTML = `<div class="comty-guide-row"><button type="button" data-edit aria-expanded="false">${escapeInspectorText(name)} <span aria-hidden="true">›</span></button><button type="button" data-visible aria-label="Show ${guide.kind}" aria-pressed="${guide.visible}">${icon('<path d="M2 12s4-7 10-7 10 7 10 7-4 7-10 7S2 12 2 12Z"/><circle cx="12" cy="12" r="3"/>')}</button><button type="button" data-remove aria-label="Remove ${guide.kind}">${icon('<path d="M5 12h14"/>')}</button></div><div class="comty-guide-fields" hidden></div>`;
441
562
  const fields = item.querySelector(".comty-guide-fields");
442
563
  const numberField = (label, key, min, max) => `<label>${label}<input type="number" data-field="${key}" value="${guide[key]}" min="${min}" max="${max}" step="1"></label>`;
443
- fields.innerHTML = `${guide.kind !== "grid" ? numberField("Count", "count", 1, 24) : ""}<label>Color<input type="color" data-field="color" value="${guide.color}"></label>${numberField("Opacity %", "opacity", 0, 100)}${guide.kind !== "grid" ? `<label>Type<select data-field="align"><option value="stretch">Stretch</option><option value="start">Start</option><option value="center">Center</option><option value="end">End</option></select></label>` : ""}${numberField(guide.kind === "grid" ? "Size" : guide.kind === "rows" ? "Height" : "Width", "size", 1, 4096)}${numberField("Offset", "offset", 0, 4096)}${guide.kind !== "grid" ? numberField("Gutter", "gutter", 0, 800) : ""}`;
444
- if (guide.kind !== "grid") fields.querySelector("select").value = guide.align;
564
+ fields.innerHTML = `${guide.kind !== "grid" ? numberField("Count", "count", 1, 24) : ""}<label>Color<input type="color" data-field="color" value="${guide.color}"></label>${numberField("Opacity %", "opacity", 0, 100)}${guide.kind !== "grid" ? `<div class="comty-guide-align"><span>Type</span><span data-guide-align></span></div>` : ""}${numberField(guide.kind === "grid" ? "Size" : guide.kind === "rows" ? "Height" : "Width", "size", 1, 4096)}${numberField("Offset", "offset", 0, 4096)}${guide.kind !== "grid" ? numberField("Gutter", "gutter", 0, 800) : ""}`;
565
+ if (guide.kind !== "grid") {
566
+ const alignment = guideSelect("Guide alignment", [["stretch", "Stretch"], ["start", "Start"], ["center", "Center"], ["end", "End"]], guide.align, value => {
567
+ guide.align = value;
568
+ renderLayoutGuides();
569
+ });
570
+ fields.querySelector("[data-guide-align]").replaceWith(alignment.trigger);
571
+ }
445
572
  fields.oninput = (event) => {
446
573
  const input = event.target, key = input.dataset.field;
447
574
  if (!key) return;
@@ -457,7 +584,7 @@ export function initComty(options = {}) {
457
584
  };
458
585
  item.querySelector("[data-edit]").onclick = (event) => {
459
586
  selectedGuideId = guide.id;
460
- typeSelect.value = guide.kind;
587
+ typeSelect.setValue(guide.kind);
461
588
  fields.hidden = !fields.hidden;
462
589
  event.currentTarget.setAttribute("aria-expanded", String(!fields.hidden));
463
590
  };
@@ -477,12 +604,14 @@ export function initComty(options = {}) {
477
604
 
478
605
  function syncInspectorTools() {
479
606
  const visible = session.inspect;
607
+ measurements.setEnabled(visible && rulersEnabled && !motion.enabled);
480
608
  overlayTools.hidden = !visible;
481
609
  xrayBtn.setAttribute("aria-pressed", String(visible && xrayEnabled));
482
610
  rulersBtn.setAttribute("aria-pressed", String(visible && rulersEnabled));
483
611
  gridBtn.setAttribute("aria-pressed", String(visible && layoutGuides.some(guide => guide.visible)));
484
612
  gridBtn.setAttribute("aria-expanded", String(visible && gridPanelOpen));
485
613
  toolPanel.hidden = !visible || !gridPanelOpen;
614
+ if (toolPanel.hidden) closeGuideMenu();
486
615
  rulerLayer.hidden = !visible || !rulersEnabled;
487
616
  if (visible && xrayEnabled) {
488
617
  if (!xrayStyle.isConnected) document.head.appendChild(xrayStyle);
@@ -608,45 +737,221 @@ export function initComty(options = {}) {
608
737
  }
609
738
  document.addEventListener("pointercancel", cancelRulerDrag, true);
610
739
 
611
- function animateColorToast(closing = false) {
612
- colorToastAnimation?.cancel();
613
- colorToastAnimation = null;
614
- if (toolPanel.hidden) return;
615
- if (reducedMotion.matches || !toolPanel.classList.contains("comty-color-toast")) {
616
- if (closing) toolPanel.hidden = true;
617
- return;
740
+ function colorDetails(value, token = "") {
741
+ const channels = /^#([\da-f]{6})$/i.exec(value)
742
+ ? value.slice(1).match(/../g).map(part => parseInt(part, 16))
743
+ : /^rgba?\((.*)\)$/i.exec(value)?.[1].replace(/\s*\/\s*/g, " ").split(/[\s,]+/).filter(Boolean);
744
+ if (!channels || channels.length < 3) return null;
745
+ const [r, g, b] = channels.slice(0, 3).map(part => Math.max(0, Math.min(255, Math.round(part.endsWith?.("%") ? parseFloat(part) * 2.55 : Number(part)))));
746
+ if (![r, g, b].every(Number.isFinite)) return null;
747
+ const alpha = channels[3] == null ? 1 : Math.max(0, Math.min(1, channels[3].endsWith("%") ? parseFloat(channels[3]) / 100 : Number(channels[3])));
748
+ const hex = `#${[r, g, b].map(n => n.toString(16).padStart(2, "0")).join("").toUpperCase()}${alpha < 1 ? Math.round(alpha * 255).toString(16).padStart(2, "0").toUpperCase() : ""}`;
749
+ const [red, green, blue] = [r, g, b].map(n => n / 255);
750
+ const max = Math.max(red, green, blue), min = Math.min(red, green, blue), delta = max - min;
751
+ let hue = 0;
752
+ if (delta) hue = max === red ? ((green - blue) / delta) % 6 : max === green ? (blue - red) / delta + 2 : (red - green) / delta + 4;
753
+ hue = (hue * 60 + 360) % 360;
754
+ const lightness = (max + min) / 2;
755
+ const saturation = delta ? delta / (1 - Math.abs(2 * lightness - 1)) : 0;
756
+ const linear = [red, green, blue].map(n => n <= .04045 ? n / 12.92 : ((n + .055) / 1.055) ** 2.4);
757
+ const l = Math.cbrt(.4122214708 * linear[0] + .5363325363 * linear[1] + .0514459929 * linear[2]);
758
+ const m = Math.cbrt(.2119034982 * linear[0] + .6806995451 * linear[1] + .1073969566 * linear[2]);
759
+ const s = Math.cbrt(.0883024619 * linear[0] + .2817188376 * linear[1] + .6299787005 * linear[2]);
760
+ const L = .2104542553 * l + .793617785 * m - .0040720468 * s;
761
+ const a = 1.9779984951 * l - 2.428592205 * m + .4505937099 * s;
762
+ const bLab = .0259040371 * l + .7827717662 * m - .808675766 * s;
763
+ const chroma = Math.hypot(a, bLab);
764
+ const okHue = chroma < .001 ? 0 : (Math.atan2(bLab, a) * 180 / Math.PI + 360) % 360;
765
+ const suffix = alpha < 1 ? ` / ${Number(alpha.toFixed(2))}` : "";
766
+ return {
767
+ key: `${hex}|${token}`, hex, token,
768
+ name: ({ "#FFFFFF": "White", "#000000": "Black" })[hex] || (token ? token.replace(/^--/, "").replace(/[-_]/g, " ") : "Selected colour"),
769
+ rgb: alpha < 1 ? `rgba(${r}, ${g}, ${b}, ${Number(alpha.toFixed(2))})` : `rgb(${r}, ${g}, ${b})`,
770
+ hsl: `hsl(${Math.round(hue)} ${Math.round(saturation * 100)}% ${Math.round(lightness * 100)}%${suffix})`,
771
+ oklch: `oklch(${(L * 100).toFixed(1)}% ${chroma.toFixed(3)} ${Math.round(okHue)}${suffix})`,
772
+ };
773
+ }
774
+
775
+ function fontDetails(element) {
776
+ const css = getComputedStyle(element);
777
+ const color = colorDetails(css.color);
778
+ const values = [css.fontFamily, css.fontSize, css.fontWeight, css.lineHeight, css.letterSpacing, css.color];
779
+ return {
780
+ key: values.join("|"), family: css.fontFamily,
781
+ size: css.fontSize, weight: css.fontWeight, leading: css.lineHeight,
782
+ tracking: css.letterSpacing === "normal" ? "0px" : css.letterSpacing,
783
+ color,
784
+ css: `font-family: ${css.fontFamily};\nfont-size: ${css.fontSize};\nfont-weight: ${css.fontWeight};\nline-height: ${css.lineHeight};\nletter-spacing: ${css.letterSpacing};\ncolor: ${css.color};`,
785
+ };
786
+ }
787
+
788
+ function rememberProperty(list, item) {
789
+ const previous = list.findIndex(entry => entry.key === item.key);
790
+ if (previous !== -1) list.splice(previous, 1);
791
+ list.unshift(item);
792
+ list.length = Math.min(list.length, 4);
793
+ }
794
+
795
+ const copyIcon = icon('<rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"/>');
796
+ const checkIcon = icon('<path d="m5 12 4 4 10-10"/>');
797
+ const copyFeedbackTimers = new WeakMap();
798
+ function propertyRow(label, value, display = value) {
799
+ const unavailable = value === "—";
800
+ return `<div class="comty-property-row"><span>${label}</span><button type="button" data-copy="${escapeInspectorText(value)}" aria-label="Copy ${label}: ${escapeInspectorText(display)}" ${unavailable ? "disabled" : ""}><span>${escapeInspectorText(display)}</span>${unavailable ? "" : `<i class="comty-property-copy-icon" aria-hidden="true">${copyIcon}${checkIcon}</i>`}</button></div>`;
801
+ }
802
+
803
+ function propertyVisibleRect() {
804
+ const rect = propertyPanel.getBoundingClientRect();
805
+ const inset = getComputedStyle(propertyPanel).clipPath.match(/^inset\(([^)]+?)(?: round|\))/)?.[1].split(/\s+/).map(parseFloat) || [0];
806
+ const [top, right = top, bottom = top, left = right] = inset;
807
+ return { top: rect.top + top, right: rect.right - right, bottom: rect.bottom - bottom, left: rect.left + left };
808
+ }
809
+
810
+ function renderPropertyPanel(morph = false) {
811
+ if (!propertyKind) return;
812
+ const before = morph && !propertyPanel.hidden ? propertyVisibleRect() : null;
813
+ const oldDetail = propertyPanel.querySelector(".comty-property-detail")?.cloneNode(true);
814
+ const oldStrip = propertyPanel.querySelector(".comty-property-strip > :first-child")?.getBoundingClientRect();
815
+ propertyAnimation?.cancel(); propertyAnimation = null;
816
+ propertyStripAnimation?.cancel(); propertyStripAnimation = null;
817
+ propertyPanel.style.width = "";
818
+ const expanded = Boolean(propertySelection);
819
+ propertyPanel.dataset.kind = propertyKind;
820
+ propertyPanel.toggleAttribute("data-expanded", expanded);
821
+ propertyPanel.setAttribute("aria-label", propertyKind === "color" ? "Recent colours" : "Recent typography");
822
+ const recent = propertyKind === "color" ? recentColors : recentFonts;
823
+ const details = propertyKind === "color" && expanded
824
+ ? `<div class="comty-property-detail"><div class="comty-property-rows">${propertyRow("HEX", propertySelection.hex)}${propertyRow("RGB", propertySelection.rgb)}${propertyRow("HSL", propertySelection.hsl)}${propertyRow("OKLCH", propertySelection.oklch)}${propertyRow("Token", propertySelection.token ? `var(${propertySelection.token})` : "—")}</div></div>`
825
+ : propertyKind === "font" && expanded
826
+ ? `<div class="comty-property-detail"><div class="comty-property-rows">${propertyRow("Family", propertySelection.family)}${propertyRow("Size", propertySelection.size)}${propertyRow("Weight", propertySelection.weight)}${propertyRow("Leading", propertySelection.leading)}${propertyRow("Tracking", propertySelection.tracking)}${propertyRow("Colour", propertySelection.color?.hex || "—")}${propertyRow("CSS", propertySelection.css, "All of the above")}</div></div>`
827
+ : "";
828
+ const slots = Array.from({ length: 4 }, (_, index) => {
829
+ const item = recent[index];
830
+ if (!item) return '<span class="comty-property-empty" aria-hidden="true"></span>';
831
+ const label = propertyKind === "color" ? `${item.name}, ${item.hex}` : `Font ${index + 1}: ${item.family}`;
832
+ return `<button type="button" class="comty-property-recent" data-recent="${index}" aria-label="${escapeInspectorText(label)}" aria-pressed="${String(propertySelection?.key === item.key)}">${propertyKind === "font" ? "Aa" : ""}</button>`;
833
+ }).join("");
834
+ propertyPanel.innerHTML = `${details}<div class="comty-property-strip">${slots}</div><span class="comty-property-feedback" role="status"></span>`;
835
+ propertyPanel.hidden = false;
836
+ if (propertyKind === "color") {
837
+ propertyPanel.querySelectorAll(".comty-property-recent").forEach((button, index) => { button.style.backgroundColor = recent[index].hex; });
838
+ } else {
839
+ propertyPanel.querySelectorAll(".comty-property-recent").forEach((button, index) => { button.style.fontFamily = recent[index].family; button.style.fontWeight = recent[index].weight; });
840
+ const colourRow = propertyPanel.querySelector('.comty-property-row button[aria-label^="Copy Colour"]');
841
+ if (colourRow && propertySelection.color) colourRow.querySelector("span").style.setProperty("--comty-picked-color", propertySelection.color.hex);
618
842
  }
619
843
  positionToolbar();
620
- const above = toolPanel.getBoundingClientRect().bottom <= toolbar.getBoundingClientRect().top;
621
- toolPanel.style.transformOrigin = above ? "bottom right" : "top right";
622
- const content = toolPanel.firstChild;
623
- const style = getComputedStyle(root);
624
- const animation = toolPanel.animate([
625
- { opacity: 0, transform: `translateY(${above ? ".375rem" : "-.375rem"}) scale(.96)` },
626
- { opacity: 1, transform: "translateY(0) scale(1)" },
627
- ], {
628
- duration: parseFloat(style.getPropertyValue("--comty-motion-duration")),
629
- easing: style.getPropertyValue("--comty-ease-out").trim(),
630
- direction: closing ? "reverse" : "normal",
631
- fill: "both",
632
- });
633
- colorToastAnimation = animation;
844
+ if (reducedMotion.matches || !pointerInput || !morph || !before) return;
845
+ const target = propertyPanel.getBoundingClientRect();
846
+ const targetStrip = propertyPanel.querySelector(".comty-property-strip > :first-child").getBoundingClientRect();
847
+ // Keep the outgoing detail until the frame has contracted around the recent values.
848
+ if (!expanded && oldDetail) {
849
+ propertyPanel.prepend(oldDetail);
850
+ propertyPanel.style.width = getComputedStyle(root).getPropertyValue("--comty-property-width").trim();
851
+ positionToolbar();
852
+ }
853
+ const layout = propertyPanel.getBoundingClientRect();
854
+ const strip = propertyPanel.querySelector(".comty-property-strip");
855
+ const stripRect = strip.firstElementChild.getBoundingClientRect();
856
+ const radius = getComputedStyle(propertyPanel).borderRadius;
857
+ const clip = rect => `inset(${Math.max(0, rect.top - layout.top)}px ${Math.max(0, layout.right - rect.right)}px ${Math.max(0, layout.bottom - rect.bottom)}px ${Math.max(0, rect.left - layout.left)}px round ${radius})`;
858
+ const css = getComputedStyle(root);
859
+ const timing = { duration: parseFloat(css.getPropertyValue("--comty-motion-duration")), easing: css.getPropertyValue("--comty-ease-in-out").trim(), fill: "forwards" };
860
+ propertyAnimation = propertyPanel.animate([{ clipPath: clip(before) }, { clipPath: clip(target) }], timing);
861
+ propertyStripAnimation = strip.animate([
862
+ { transform: `translate(${oldStrip.left - stripRect.left}px, ${oldStrip.top - stripRect.top}px)` },
863
+ { transform: `translate(${targetStrip.left - stripRect.left}px, ${targetStrip.top - stripRect.top}px)` },
864
+ ], timing);
865
+ const animation = propertyAnimation;
634
866
  animation.onfinish = () => {
635
- if (colorToastAnimation !== animation) return;
636
- if (closing && toolPanel.firstChild === content) toolPanel.hidden = true;
867
+ if (propertyAnimation !== animation) return;
868
+ propertyAnimation = null;
869
+ if (!expanded) { oldDetail?.remove(); propertyPanel.style.width = ""; }
870
+ propertyStripAnimation?.cancel(); propertyStripAnimation = null;
637
871
  animation.cancel();
638
- colorToastAnimation = null;
872
+ positionToolbar();
639
873
  };
640
874
  }
641
875
 
642
- function dismissColorResultLater() {
643
- clearTimeout(colorToastTimer);
644
- const content = toolPanel.firstChild;
645
- colorToastTimer = setTimeout(() => {
646
- if (toolPanel.firstChild === content) animateColorToast(true);
647
- }, 4000);
876
+ function openPropertyPanel(kind, element, property, opener = null, sampledColor = null) {
877
+ propertyPicking = null;
878
+ colorMagnifier.setEnabled(false);
879
+ delete document.documentElement.dataset.comtyPropertyPicking;
880
+ if (motion.enabled) setMotionEnabled(false);
881
+ gridPanelOpen = false;
882
+ toolPanel.hidden = true;
883
+ gridBtn.setAttribute("aria-expanded", "false");
884
+ const item = kind === "color" && element
885
+ ? sampledColor ? colorDetails(sampledColor, inspectedBackgroundToken(sampledColor, element) || "") : colorDetails(getComputedStyle(element)[property === "background" ? "backgroundColor" : "color"], getColorTokens(element)[property === "background" ? "background" : "color"].at(0) || "")
886
+ : kind === "font" && element ? fontDetails(element) : null;
887
+ if (item) rememberProperty(kind === "color" ? recentColors : recentFonts, item);
888
+ propertyOpener = opener;
889
+ propertyKind = kind;
890
+ propertySelection = null;
891
+ for (const [button, active] of [[pipetteBtn, kind === "color"], [typographyBtn, kind === "font"]]) {
892
+ button.setAttribute("aria-pressed", String(active));
893
+ button.setAttribute("aria-expanded", String(active));
894
+ }
895
+ renderPropertyPanel();
648
896
  }
649
897
 
898
+ function togglePropertyPanel(kind, opener, screenSampling = false) {
899
+ if (!screenSampling && !propertyPanel.hidden && propertyKind === kind) { closePropertyPanel(); return; }
900
+ openPropertyPanel(kind, null, "color", opener);
901
+ rulersEnabled = false;
902
+ syncInspectorTools();
903
+ if (kind === "color" && screenSampling && window.EyeDropper) sampleColor();
904
+ else {
905
+ propertyPicking = kind;
906
+ colorMagnifier.setEnabled(kind === "color");
907
+ document.documentElement.dataset.comtyPropertyPicking = kind;
908
+ selectedElement = null;
909
+ hideInspector();
910
+ }
911
+ }
912
+
913
+ function closePropertyPanel(restoreFocus = false) {
914
+ if (propertyPanel.hidden) return;
915
+ if (propertyPicking === "font") hideInspector();
916
+ propertyAnimation?.cancel(); propertyAnimation = null;
917
+ propertyStripAnimation?.cancel(); propertyStripAnimation = null;
918
+ colorPickerController?.abort();
919
+ propertyPanel.hidden = true;
920
+ propertyKind = null; propertySelection = null; propertyPicking = null;
921
+ colorMagnifier.setEnabled(false);
922
+ delete document.documentElement.dataset.comtyPropertyPicking;
923
+ for (const button of [pipetteBtn, typographyBtn]) {
924
+ button.setAttribute("aria-pressed", "false");
925
+ button.setAttribute("aria-expanded", "false");
926
+ }
927
+ if (restoreFocus && propertyOpener?.isConnected) propertyOpener.focus({ preventScroll: true });
928
+ propertyOpener = null;
929
+ }
930
+
931
+ propertyPanel.onclick = async event => {
932
+ const button = event.target.closest("button");
933
+ if (!button) return;
934
+ if (button.dataset.recent != null) {
935
+ const item = (propertyKind === "color" ? recentColors : recentFonts)[Number(button.dataset.recent)];
936
+ propertySelection = propertySelection?.key === item.key ? null : item;
937
+ renderPropertyPanel(true);
938
+ propertyPanel.querySelector(`button[data-recent="${button.dataset.recent}"]`)?.focus({ preventScroll: true });
939
+ } else if (button.dataset.copy != null && button.dataset.copy !== "—") {
940
+ clearTimeout(copyFeedbackTimers.get(button));
941
+ try {
942
+ await navigator.clipboard.writeText(button.dataset.copy);
943
+ if (!button.isConnected || propertyPanel.hidden) return;
944
+ button.dataset.copied = "";
945
+ propertyPanel.querySelector(".comty-property-feedback").textContent = `${button.getAttribute("aria-label").slice(5)} copied`;
946
+ clearTimeout(copyFeedbackTimers.get(button));
947
+ copyFeedbackTimers.set(button, setTimeout(() => { delete button.dataset.copied; }, 1200));
948
+ } catch {
949
+ delete button.dataset.copied;
950
+ if (button.isConnected && !propertyPanel.hidden) propertyPanel.querySelector(".comty-property-feedback").textContent = "Clipboard unavailable";
951
+ }
952
+ }
953
+ };
954
+
650
955
  function inspectedBackgroundToken(hex, element) {
651
956
  if (!element?.isConnected || isElementTreeHidden(element)) return null;
652
957
  const sampled = `rgb(${hex.slice(1).match(/../g).map(part => parseInt(part, 16)).join(', ')})`;
@@ -664,53 +969,42 @@ export function initComty(options = {}) {
664
969
  async function sampleColor() {
665
970
  if (colorPickerController) return;
666
971
  const inspectedBackground = selectedElement?.isConnected && !isElementTreeHidden(selectedElement) ? selectedElement : inspectedElement;
667
- setMotionEnabled(false);
668
- gridPanelOpen = false;
669
- renderGridPanel();
670
- toolPanel.hidden = false;
671
- toolPanel.setAttribute("aria-label", "Color sampler");
672
972
  if (!window.EyeDropper) {
673
- toolPanel.textContent = "Screen color sampling is not supported by this browser. Open this page in Chrome or Edge.";
674
- dismissColorResultLater();
973
+ propertyPanel.querySelector(".comty-property-feedback").textContent = "Screen sampling requires Chrome or Edge";
675
974
  return;
676
975
  }
677
976
  colorPickerController = new AbortController();
678
- pipetteBtn.setAttribute("aria-pressed", "true");
679
977
  // Hide every overlay so the sampler reads the page's original pixels.
680
978
  const previousVisibility = root.style.visibility;
681
979
  root.style.visibility = "hidden";
682
980
  xrayStyle.remove();
683
981
  try {
684
982
  const { sRGBHex } = await new window.EyeDropper().open({ signal: colorPickerController.signal });
983
+ if (colorPickerController.signal.aborted || propertyKind !== "color") return;
685
984
  if (!/^#[0-9a-f]{6}$/i.test(sRGBHex)) throw new Error("Invalid sampled color");
686
- const rgb = sRGBHex.slice(1).match(/../g).map(part => parseInt(part, 16));
687
985
  const token = inspectedBackgroundToken(sRGBHex, inspectedBackground);
688
- toolPanel.classList.add("comty-color-toast");
689
- toolPanel.innerHTML = `<div class="comty-color-content">${token ? `<div class="comty-color-token">Element token · ${token}</div>` : ""}<div class="comty-color-values"><span class="comty-color-hex">${sRGBHex.toUpperCase()}</span><span class="comty-color-separator" aria-hidden="true">–</span><span class="comty-color-rgb">rgb(${rgb.join(", ")})</span></div><div class="comty-color-status" role="status">Copying…</div></div><span class="comty-color-swatch" aria-hidden="true"></span>`;
690
- toolPanel.querySelector(".comty-color-swatch").style.backgroundColor = sRGBHex;
691
- const status = toolPanel.querySelector(".comty-color-status");
692
- dismissColorResultLater();
693
- try {
694
- await navigator.clipboard.writeText(sRGBHex.toUpperCase());
695
- status.textContent = "Copied";
696
- } catch { status.textContent = "Select to copy"; }
986
+ const item = colorDetails(sRGBHex, token || "");
987
+ rememberProperty(recentColors, item);
988
+ propertyKind = "color";
989
+ propertySelection = null;
990
+ renderPropertyPanel();
991
+ try { await navigator.clipboard.writeText(item.hex); propertyPanel.querySelector(".comty-property-feedback").textContent = "HEX copied"; }
992
+ catch { propertyPanel.querySelector(".comty-property-feedback").textContent = "Select a value to copy"; }
697
993
  } catch (error) {
698
- if (error.name === "AbortError") toolPanel.hidden = true;
699
- else { toolPanel.textContent = "Unable to sample a color. Please try again."; dismissColorResultLater(); }
994
+ if (error.name !== "AbortError") propertyPanel.querySelector(".comty-property-feedback").textContent = "Unable to sample a colour";
700
995
  } finally {
701
996
  root.style.visibility = previousVisibility;
702
- if (toolPanel.classList.contains("comty-color-toast")) animateColorToast();
703
997
  colorPickerController = null;
704
- pipetteBtn.setAttribute("aria-pressed", "false");
705
998
  if (session.inspect && xrayEnabled) document.head.appendChild(xrayStyle);
706
999
  }
707
1000
  }
708
1001
  document.addEventListener("pointerdown", event => {
709
1002
  if (!toolPanel.hidden && !toolPanel.contains(event.target) && !toolbar.contains(event.target)) {
710
1003
  gridPanelOpen = false;
711
- animateColorToast(true);
1004
+ toolPanel.hidden = true;
712
1005
  gridBtn.setAttribute("aria-expanded", "false");
713
1006
  }
1007
+ if (!propertyPicking && !propertyPanel.hidden && !propertyPanel.contains(event.target) && !pipetteBtn.contains(event.target) && !typographyBtn.contains(event.target) && !event.target.closest?.(".comty-inspector-picker")) closePropertyPanel();
714
1008
  }, true);
715
1009
 
716
1010
  const sidepanelBtn = document.createElement("button");
@@ -771,11 +1065,56 @@ export function initComty(options = {}) {
771
1065
  let toolbarMotionFrame = 0;
772
1066
  let toolbarAnimations = [];
773
1067
  const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)");
1068
+ let workingIndicatorTimer = 0;
1069
+ function scheduleWorkingIndicators() {
1070
+ if (workingIndicatorTimer || reducedMotion.matches || document.hidden) return;
1071
+ workingIndicatorTimer = setTimeout(() => {
1072
+ workingIndicatorTimer = 0;
1073
+ if (!root.isConnected) return;
1074
+ const indicators = root.querySelectorAll('.comty-working-indicator');
1075
+ if (!indicators.length) return;
1076
+ for (const indicator of indicators) {
1077
+ const previous = Number(indicator.dataset.activeDots.split(',')[0]);
1078
+ const first = (previous + 1 + Math.floor(Math.random() * 8)) % 9;
1079
+ const second = (first + 1 + Math.floor(Math.random() * 8)) % 9;
1080
+ indicator.dataset.activeDots = `${first},${second}`;
1081
+ [...indicator.children].forEach((dot, index) => dot.toggleAttribute('data-active', index === first || index === second));
1082
+ }
1083
+ scheduleWorkingIndicators();
1084
+ }, 600);
1085
+ }
1086
+ function createWorkingIndicator() {
1087
+ const indicator = document.createElement('span');
1088
+ indicator.className = 'comty-working-indicator';
1089
+ indicator.setAttribute('aria-hidden', 'true');
1090
+ indicator.dataset.activeDots = '3,5';
1091
+ for (let index = 0; index < 9; index++) {
1092
+ const dot = document.createElement('i');
1093
+ dot.toggleAttribute('data-active', index === 3 || index === 5);
1094
+ indicator.appendChild(dot);
1095
+ }
1096
+ scheduleWorkingIndicators();
1097
+ return indicator;
1098
+ }
1099
+ function resetWorkingIndicators() {
1100
+ clearTimeout(workingIndicatorTimer);
1101
+ workingIndicatorTimer = 0;
1102
+ if (reducedMotion.matches) {
1103
+ for (const indicator of root.querySelectorAll('.comty-working-indicator')) {
1104
+ indicator.dataset.activeDots = '3,5';
1105
+ [...indicator.children].forEach((dot, index) => dot.toggleAttribute('data-active', index === 3 || index === 5));
1106
+ }
1107
+ }
1108
+ if (root.querySelector('.comty-working-indicator')) scheduleWorkingIndicators();
1109
+ }
1110
+ reducedMotion.addEventListener('change', resetWorkingIndicators);
1111
+ document.addEventListener('visibilitychange', resetWorkingIndicators);
774
1112
  const clampToolbar = (value, size, viewport) => Math.max(8, Math.min(value, viewport - size - 8));
775
1113
  function rememberToolbarPosition(left, top) {
776
1114
  const width = toolbar.offsetWidth, height = toolbar.offsetHeight;
777
1115
  const centerX = left + width / 2, centerY = top + height / 2;
778
- const horizontal = centerX < innerWidth / 3 ? "left" : centerX > innerWidth * 2 / 3 ? "right" : "center";
1116
+ const availableWidth = innerWidth - width;
1117
+ const horizontal = left < availableWidth / 3 ? "left" : left > availableWidth * 2 / 3 ? "right" : "center";
779
1118
  const vertical = centerY < innerHeight / 2 ? "top" : "bottom";
780
1119
  toolbarPosition = {
781
1120
  horizontal, vertical,
@@ -803,20 +1142,26 @@ export function initComty(options = {}) {
803
1142
  });
804
1143
  }
805
1144
  const rect = toolbar.getBoundingClientRect();
806
- for (const panel of [toolPanel, sidepanel, motionPanel]) {
1145
+ for (const panel of [toolPanel, propertyPanel, sidepanel, motionPanel]) {
807
1146
  if (panel.hidden || (panel === sidepanel && !sidepanelOpen)) continue;
808
1147
  const above = rect.top - 16;
809
1148
  const below = innerHeight - rect.bottom - 16;
810
- const useAbove = above >= panel.scrollHeight || above >= below;
1149
+ const useAbove = panel === propertyPanel ? above >= below : above >= panel.scrollHeight || above >= below;
811
1150
  panel.style.maxHeight = `${Math.max(0, useAbove ? above : below)}px`;
812
- panel.style.left = `${Math.max(8, Math.min(rect.right - panel.offsetWidth, innerWidth - panel.offsetWidth - 8))}px`;
1151
+ const edgeThreshold = toolbar.offsetHeight;
1152
+ const alignment = rect.left <= edgeThreshold ? "left" : innerWidth - rect.right <= edgeThreshold ? "right" : "center";
1153
+ const left = panel === propertyPanel
1154
+ ? alignment === "left" ? rect.left : alignment === "right" ? rect.right - panel.offsetWidth : rect.left + (rect.width - panel.offsetWidth) / 2
1155
+ : rect.right - panel.offsetWidth;
1156
+ if (panel === propertyPanel) panel.dataset.alignment = alignment;
1157
+ panel.style.left = `${Math.max(8, Math.min(left, innerWidth - panel.offsetWidth - 8))}px`;
813
1158
  panel.style.right = "auto";
814
1159
  panel.style.top = `${useAbove ? rect.top - panel.offsetHeight - 8 : rect.bottom + 8}px`;
815
1160
  panel.style.bottom = "auto";
816
1161
  }
817
1162
  }
818
1163
  const toolbarResize = new ResizeObserver(positionToolbar);
819
- [toolbar, toolPanel, sidepanel, motionPanel].forEach(node => toolbarResize.observe(node));
1164
+ [toolbar, toolPanel, propertyPanel, sidepanel, motionPanel].forEach(node => toolbarResize.observe(node));
820
1165
  window.addEventListener("resize", positionToolbar);
821
1166
 
822
1167
  toolbar.addEventListener("pointerdown", event => {
@@ -876,8 +1221,11 @@ export function initComty(options = {}) {
876
1221
 
877
1222
  function prepareToolbarMotion() {
878
1223
  if (toolbarMotionFrame) return;
879
- const nodes = [toolbarSurface, browseBtn, inspectorBtn, btn, agentBtn, divider, commentTools, agentTools, inspectorTools].filter(Boolean);
880
- const before = new Map(nodes.filter(node => !node.hidden).map(node => [node, node.getBoundingClientRect()]));
1224
+ const nodes = [toolbarSurface, dragHandle, primaryTools, commentTools, agentTools, inspectorTools].filter(Boolean);
1225
+ // Move each group's background with its icons, including when interrupted.
1226
+ const before = new Map(nodes.filter(node => !node.hidden).map(node => [node, {
1227
+ rect: node.getBoundingClientRect(), opacity: getComputedStyle(node).opacity,
1228
+ }]));
881
1229
  stopToolbarMotion();
882
1230
  toolbarMotionFrame = requestAnimationFrame(() => {
883
1231
  toolbarMotionFrame = 0;
@@ -887,8 +1235,8 @@ export function initComty(options = {}) {
887
1235
  const timing = { duration: parseFloat(css.getPropertyValue("--comty-motion-duration")), easing: css.getPropertyValue("--comty-ease-out").trim() };
888
1236
  for (const node of nodes.filter(node => !node.hidden)) {
889
1237
  const next = node.getBoundingClientRect(), previous = before.get(node);
890
- const transform = previous ? `translate(${previous.left - next.left}px, ${previous.top - next.top}px)${node === toolbarSurface ? ` scale(${previous.width / next.width}, ${previous.height / next.height})` : ""}` : "translateY(.25rem)";
891
- toolbarAnimations.push(node.animate([{ transform, opacity: previous ? 1 : 0 }, { transform: "none", opacity: 1 }], timing));
1238
+ const transform = previous ? `translate(${previous.rect.left - next.left}px, ${previous.rect.top - next.top}px)${node === toolbarSurface ? ` scale(${previous.rect.width / next.width}, ${previous.rect.height / next.height})` : ""}` : "none";
1239
+ toolbarAnimations.push(node.animate([{ transform, opacity: previous?.opacity ?? 0 }, { transform: "none", opacity: 1 }], timing));
892
1240
  }
893
1241
  });
894
1242
  }
@@ -1085,8 +1433,10 @@ export function initComty(options = {}) {
1085
1433
  if (mode === "off" || mode === "inspect" || session.inspect) {
1086
1434
  if (mode === "inspect" && session.inspect) return;
1087
1435
  setMotionEnabled(false);
1436
+ closePropertyPanel();
1088
1437
  colorPickerController?.abort();
1089
1438
  cancelRulerDrag();
1439
+ rulersEnabled = false;
1090
1440
  gridPanelOpen = false;
1091
1441
  toolPanel.hidden = true;
1092
1442
  measuredRegion = null;
@@ -1146,31 +1496,52 @@ export function initComty(options = {}) {
1146
1496
  return;
1147
1497
  }
1148
1498
  document.querySelector(".comty-auth-backdrop")?.remove();
1499
+ const opener = document.activeElement;
1149
1500
  const backdrop = document.createElement("div");
1150
1501
  backdrop.className = "comty-auth-backdrop";
1151
1502
  backdrop.innerHTML = `
1152
- <div class="comty-auth-dialog" role="dialog" aria-modal="true" aria-labelledby="comty-auth-title">
1503
+ <div class="comty-auth-dialog" role="dialog" aria-modal="true" aria-labelledby="comty-auth-title" aria-describedby="comty-auth-description" tabindex="-1">
1504
+ <div class="comty-auth-header">
1505
+ <span class="comty-auth-icon">${icon('<path d="M21 11.5a8.4 8.4 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.4 8.4 0 0 1-3.8-.9L3 21l1.9-5.7a8.5 8.5 0 1 1 16.1-3.8Z"/>')}</span>
1506
+ <button type="button" class="comty-auth-close" data-comty-close aria-label="Close sign-in dialog">${icon('<path d="m6 6 12 12M6 18 18 6"/>')}</button>
1507
+ </div>
1153
1508
  <h2 id="comty-auth-title">${forbidden ? "Project access required" : "Sign in to comment"}</h2>
1154
- <p>${forbidden ? "This account is not a member of this project. Ask the project owner for an invite." : "Comments are shared with the project team, so we need to know who is writing."}</p>
1509
+ <p id="comty-auth-description">${forbidden ? "This account is not a member of this project. Ask the project owner for an invite." : "Sign in to share feedback with your team and keep the conversation in one place."}</p>
1155
1510
  <div class="comty-auth-actions">
1156
- <button type="button" data-comty-close>Close</button>
1511
+ <button type="button" data-comty-close>${forbidden ? "Close" : "Cancel"}</button>
1157
1512
  ${forbidden ? "" : '<button type="button" class="comty-auth-primary" data-comty-login>Continue to sign in</button>'}
1158
1513
  </div>
1159
1514
  </div>`;
1160
- backdrop.querySelector("[data-comty-close]").onclick = () => backdrop.remove();
1515
+ const close = () => {
1516
+ backdrop.remove();
1517
+ if (opener?.isConnected) opener.focus({ preventScroll: true });
1518
+ };
1519
+ backdrop.querySelectorAll("[data-comty-close]").forEach(button => { button.onclick = close; });
1520
+ backdrop.addEventListener("keydown", event => {
1521
+ event.stopPropagation();
1522
+ if (event.key === "Escape") { event.preventDefault(); close(); }
1523
+ if (event.key === "Tab") {
1524
+ const buttons = [...backdrop.querySelectorAll("button:not(:disabled)")];
1525
+ if (!buttons.length) { event.preventDefault(); return; }
1526
+ const first = buttons[0], last = buttons.at(-1);
1527
+ if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
1528
+ else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
1529
+ }
1530
+ });
1161
1531
  const loginButton = backdrop.querySelector("[data-comty-login]");
1162
1532
  if (loginButton) {
1163
1533
  loginButton.onclick = () => {
1164
1534
  loginButton.disabled = true;
1165
1535
  loginButton.innerText = "Connecting…";
1166
- backdrop.querySelector("[data-comty-close]").disabled = true;
1536
+ backdrop.querySelectorAll("[data-comty-close]").forEach(button => { button.disabled = true; });
1167
1537
  setTimeout(() => window.location.assign(loginUrl), 50);
1168
1538
  };
1169
1539
  }
1170
1540
  backdrop.onclick = (event) => {
1171
- if (event.target === backdrop) backdrop.remove();
1541
+ if (event.target === backdrop) close();
1172
1542
  };
1173
1543
  root.appendChild(backdrop);
1544
+ (loginButton || backdrop.querySelector("[data-comty-close]")).focus({ preventScroll: true });
1174
1545
  }
1175
1546
 
1176
1547
  function formatBoxValue(value) {
@@ -1280,7 +1651,7 @@ export function initComty(options = {}) {
1280
1651
  };
1281
1652
  try {
1282
1653
  for (const sheet of [...document.styleSheets, ...document.adoptedStyleSheets]) {
1283
- if (sheet.disabled || sheet.ownerNode?.closest?.('#comty-root') || sheet.ownerNode === style) continue;
1654
+ if (sheet.disabled || sheet.ownerNode?.closest?.('#comty-root') || sheet.ownerNode === style || sheet.ownerNode?.matches?.('link[data-comty-font]')) continue;
1284
1655
  if (sheet.media?.mediaText && !matchMedia(sheet.media.mediaText).matches) continue;
1285
1656
  visit(sheet.cssRules);
1286
1657
  }
@@ -1485,7 +1856,29 @@ export function initComty(options = {}) {
1485
1856
  }
1486
1857
 
1487
1858
  function renderInspector() {
1488
- if (motion.enabled) {
1859
+ if (propertyPicking === "font") {
1860
+ if (!inspectedElement?.isConnected || isElementTreeHidden(inspectedElement)) { hideInspector(); return; }
1861
+ let box = inspectionLayer.querySelector(".comty-font-target");
1862
+ if (!box) {
1863
+ box = document.createElement("div");
1864
+ box.className = "comty-font-target";
1865
+ inspectionLayer.replaceChildren(box);
1866
+ }
1867
+ const rect = inspectedElement.getBoundingClientRect();
1868
+ // Tight line-height can leave glyphs outside the element's layout box.
1869
+ const range = document.createRange();
1870
+ range.selectNodeContents(inspectedElement);
1871
+ const text = range.getBoundingClientRect();
1872
+ const left = Math.min(rect.left, text.width ? text.left : rect.left);
1873
+ const top = Math.min(rect.top, text.height ? text.top : rect.top);
1874
+ setMeasuredRect(box, {
1875
+ left, top,
1876
+ width: Math.max(rect.right, text.width ? text.right : rect.right) - left,
1877
+ height: Math.max(rect.bottom, text.height ? text.bottom : rect.bottom) - top,
1878
+ });
1879
+ return;
1880
+ }
1881
+ if (propertyPicking || motion.enabled || (session.inspect && rulersEnabled)) {
1489
1882
  if (inspectionLayer.childNodes.length) hideInspector();
1490
1883
  return;
1491
1884
  }
@@ -1560,13 +1953,26 @@ export function initComty(options = {}) {
1560
1953
  panel.setAttribute("aria-label", "Element properties");
1561
1954
  if (selected) panel.dataset.selected = "";
1562
1955
  const copyable = (value) => `<button type="button" title="Copy ${escapeInspectorText(value)}" data-copy="${escapeInspectorText(value)}">${escapeInspectorText(value)}</button>`;
1563
- panel.innerHTML = `<div class="comty-inspector-head">${copyable(selector)}${copyable(`${formatBoxValue(rect.width)} × ${formatBoxValue(rect.height)}`)}</div><div class="comty-inspector-details">${(inspectExpanded ? rows : rows.slice(0, 6)).map(([key, value, tokens]) => `<div class="comty-inspector-row"><span>${key}</span><div class="comty-inspector-value">${tokens?.length ? `<div class="comty-inspector-tokens">${tokens.map(token => copyable(token)).join("")}</div>` : ""}${copyable(value)}</div></div>`).join("")}</div>${rows.length > 6 && selected ? `<button type="button" class="comty-inspector-expand">${inspectExpanded ? "Show less" : `Show ${rows.length - 6} more`}</button>` : ""}`;
1956
+ const featured = new Set(["background", "color", "font", "font-family"]);
1957
+ const visibleRows = inspectExpanded ? rows : selected ? [...rows.filter(([key]) => featured.has(key)), ...rows.filter(([key]) => !featured.has(key))].slice(0, 6) : rows.slice(0, 6);
1958
+ panel.innerHTML = `<div class="comty-inspector-head">${copyable(selector)}${copyable(`${formatBoxValue(rect.width)} × ${formatBoxValue(rect.height)}`)}</div><div class="comty-inspector-details">${visibleRows.map(([key, value, tokens]) => {
1959
+ const picker = key === "background" || key === "color" ? "color" : key === "font" || key === "font-family" ? "font" : "";
1960
+ const valueButton = picker && selected
1961
+ ? `<button type="button" class="comty-inspector-picker" data-picker="${picker}" data-property="${key}" aria-label="Open ${picker === "color" ? key + " colour" : "typography"} details">${picker === "color" ? '<span class="comty-inspector-swatch" aria-hidden="true"></span>' : ""}${escapeInspectorText(value)}</button>`
1962
+ : copyable(value);
1963
+ return `<div class="comty-inspector-row"><span>${key}</span><div class="comty-inspector-value">${tokens?.length ? `<div class="comty-inspector-tokens">${tokens.map(token => copyable(token)).join("")}</div>` : ""}${valueButton}</div></div>`;
1964
+ }).join("")}</div>${rows.length > 6 && selected ? `<button type="button" class="comty-inspector-expand">${inspectExpanded ? "Show less" : `Show ${rows.length - 6} more`}</button>` : ""}`;
1965
+ for (const button of panel.querySelectorAll('.comty-inspector-picker[data-picker="color"]')) {
1966
+ button.querySelector(".comty-inspector-swatch").style.backgroundColor = getComputedStyle(target)[button.dataset.property === "background" ? "backgroundColor" : "color"];
1967
+ }
1564
1968
  panel.onclick = async (event) => {
1565
1969
  const button = event.target.closest("button");
1566
1970
  if (!button) return;
1567
1971
  if (button.classList.contains("comty-inspector-expand")) {
1568
1972
  inspectExpanded = !inspectExpanded;
1569
1973
  renderInspector();
1974
+ } else if (button.dataset.picker) {
1975
+ openPropertyPanel(button.dataset.picker, target, button.dataset.property, button);
1570
1976
  } else {
1571
1977
  try {
1572
1978
  await navigator.clipboard.writeText(button.dataset.copy);
@@ -2714,7 +3120,8 @@ export function initComty(options = {}) {
2714
3120
  const failed = Boolean(task.error) || task.status === 'in_review';
2715
3121
  const state = failed ? 'error' : task.status === 'done' ? 'success' : task.status === 'cancel' ? 'cancel' : 'working';
2716
3122
  const row = node('div', 'comty-agent-row'); row.dataset.taskId = task.id;
2717
- const dot = node('span', 'comty-agent-dot'); dot.dataset.status = state; dot.setAttribute('aria-hidden', 'true');
3123
+ const dot = state === 'working' ? createWorkingIndicator() : node('span', 'comty-agent-dot');
3124
+ dot.dataset.status = state; dot.setAttribute('aria-hidden', 'true');
2718
3125
  const name = node('span', 'comty-agent-row-name', task.content);
2719
3126
  row.append(dot, name);
2720
3127
  if (state === 'working') row.appendChild(node('span', 'comty-agent-row-state', task.status === 'todo' ? 'Starting' : 'Working'));
@@ -3568,7 +3975,9 @@ export function initComty(options = {}) {
3568
3975
  outline = document.createElement("div");
3569
3976
  outline.className = "comty-agent-working";
3570
3977
  outline.setAttribute("aria-label", "Agent working on this element");
3571
- const badge = document.createElement("span"); badge.textContent = "Working";
3978
+ const badge = document.createElement("span");
3979
+ badge.className = "comty-agent-working-badge";
3980
+ badge.append(createWorkingIndicator(), "Working");
3572
3981
  outline.appendChild(badge); root.appendChild(outline);
3573
3982
  workingOutlines.set(id, outline);
3574
3983
  }
@@ -3590,6 +3999,7 @@ export function initComty(options = {}) {
3590
3999
  }
3591
4000
  if (session.inspect || session.edit || inspectionLayer.childNodes.length) renderInspector();
3592
4001
  if (session.inspect && rulersEnabled) renderRulers();
4002
+ if (session.inspect && rulersEnabled) measurements.render();
3593
4003
  }
3594
4004
 
3595
4005
  function ensureLiveTracking() {
@@ -3681,6 +4091,14 @@ export function initComty(options = {}) {
3681
4091
  document.addEventListener("pointermove", (event) => {
3682
4092
  if (!session.edit && !session.inspect) return;
3683
4093
  if (motion.enabled) return;
4094
+ if (propertyPicking) {
4095
+ const target = event.target;
4096
+ if (propertyPicking === "font" && target instanceof Element && !isComtyElement(target) && getElementOwnTextSignature(target)) {
4097
+ inspectedElement = target;
4098
+ renderInspector();
4099
+ } else hideInspector();
4100
+ return;
4101
+ }
3684
4102
  if (inspectDrag) {
3685
4103
  const dx = event.clientX - inspectDrag.x, dy = event.clientY - inspectDrag.y;
3686
4104
  if (Math.hypot(dx, dy) > 4) {
@@ -3712,7 +4130,7 @@ export function initComty(options = {}) {
3712
4130
  if ((!session.inspect && !session.edit) || !(event.target instanceof Element) || isComtyElement(event.target)) return;
3713
4131
  if (event.button !== 0) return;
3714
4132
  if (motion.enabled) return;
3715
- if (session.inspect) {
4133
+ if (session.inspect && !propertyPicking) {
3716
4134
  inspectDrag = { x: event.clientX, y: event.clientY };
3717
4135
  measuredRegion = null;
3718
4136
  }
@@ -3742,6 +4160,19 @@ export function initComty(options = {}) {
3742
4160
  e.preventDefault();
3743
4161
  e.stopImmediatePropagation();
3744
4162
  if (ignoreInspectClick) return;
4163
+ if (propertyPicking) {
4164
+ const kind = propertyPicking;
4165
+ let element = target;
4166
+ const property = getElementOwnTextSignature(element) ? "color" : "background";
4167
+ if (kind === "color" && property === "background") {
4168
+ while (element.parentElement && getComputedStyle(element).backgroundColor === "rgba(0, 0, 0, 0)") element = element.parentElement;
4169
+ }
4170
+ openPropertyPanel(kind, element, property, kind === "color" ? pipetteBtn : typographyBtn, kind === "color" ? colorMagnifier.sample() : null);
4171
+ propertyPicking = kind;
4172
+ colorMagnifier.setEnabled(kind === "color");
4173
+ document.documentElement.dataset.comtyPropertyPicking = kind;
4174
+ return;
4175
+ }
3745
4176
  measuredRegion = null;
3746
4177
  selectedElement = getAnchorElement(target);
3747
4178
  inspectExpanded = false;
@@ -3813,15 +4244,16 @@ export function initComty(options = {}) {
3813
4244
  setMotionEnabled(false); motionBtn.focus({ preventScroll: true });
3814
4245
  return;
3815
4246
  }
3816
- if (e.key === "Escape" && (!toolPanel.hidden || rulerDrag || colorPickerController)) {
4247
+ if (e.key === "Escape" && (!propertyPanel.hidden || !toolPanel.hidden || rulerDrag || colorPickerController)) {
3817
4248
  e.preventDefault();
3818
- gridPanelOpen = false; animateColorToast(true);
4249
+ closePropertyPanel(true);
4250
+ gridPanelOpen = false; toolPanel.hidden = true;
3819
4251
  gridBtn.setAttribute("aria-expanded", "false");
3820
4252
  cancelRulerDrag(); colorPickerController?.abort();
3821
4253
  return;
3822
4254
  }
3823
4255
  if (session.inspect && !isEditable && !e.repeat && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey) {
3824
- const button = { x: xrayBtn, r: rulersBtn, g: gridBtn, p: pipetteBtn, m: motionBtn }[e.key.toLowerCase()];
4256
+ const button = { x: xrayBtn, r: rulersBtn, g: gridBtn, p: pipetteBtn, t: typographyBtn, m: motionBtn }[e.key.toLowerCase()];
3825
4257
  if (button) { e.preventDefault(); button.click(); return; }
3826
4258
  }
3827
4259