tux-review 0.1.0 → 0.1.2

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.
Files changed (2) hide show
  1. package/client/tux-review.js +107 -16
  2. package/package.json +1 -1
@@ -139,16 +139,44 @@
139
139
  const el = document.querySelector(`[data-testid="${cssEscape(target.test_id)}"], [data-test-id="${cssEscape(target.test_id)}"]`);
140
140
  if (el) return el;
141
141
  }
142
+ // Fingerprint validation: when a resolution strategy yields several
143
+ // candidates (nth-child paths drift after DOM changes), pick the one
144
+ // that best matches the stored fingerprint — component/instance scope,
145
+ // captured text, role. Score 0 keeps the first candidate (SPC 16).
146
+ function scoreCandidate(el) {
147
+ let score = 0;
148
+ if (target.component && el.closest(`[data-tux-component="${cssEscape(target.component)}"]`)) score += 4;
149
+ if (target.component_instance && el.closest(`[data-tux-instance="${cssEscape(target.component_instance)}"]`)) score += 3;
150
+ if (target.text) {
151
+ const t = (el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 80);
152
+ if (t === target.text) score += 2;
153
+ else if (target.text.length > 12 && t.includes(target.text.slice(0, 40))) score += 1;
154
+ }
155
+ if (target.role && el.getAttribute('role') === target.role) score += 1;
156
+ return score;
157
+ }
158
+ function pickBest(nodes) {
159
+ let best = null, bestScore = -1;
160
+ for (const n of nodes) {
161
+ const s = scoreCandidate(n);
162
+ if (s > bestScore) { bestScore = s; best = n; }
163
+ }
164
+ return best;
165
+ }
142
166
  if (target.css_selector) {
143
167
  try {
144
- const el = document.querySelector(target.css_selector);
145
- if (el) return el;
168
+ const nodes = document.querySelectorAll(target.css_selector);
169
+ if (nodes.length) return pickBest(nodes);
146
170
  } catch (e) { /* invalid selector */ }
147
171
  }
148
172
  if (target.dom_path) {
149
173
  try {
150
- const el = document.evaluate(target.dom_path, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
151
- if (el) return el;
174
+ const snapshot = document.evaluate(target.dom_path, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
175
+ if (snapshot.snapshotLength) {
176
+ const nodes = [];
177
+ for (let i = 0; i < snapshot.snapshotLength; i++) nodes.push(snapshot.snapshotItem(i));
178
+ return pickBest(nodes);
179
+ }
152
180
  } catch (e) { /* invalid path */ }
153
181
  }
154
182
  return null;
@@ -333,18 +361,35 @@
333
361
  function clearHover() {
334
362
  if (hoverEl) { hoverEl.classList.remove('tux-hover'); hoverEl = null; }
335
363
  }
364
+ // TUX's own chrome (overlay root, float, sidebar, layer, toast, editor,
365
+ // markers) is never reviewable — independent of any user-set [data-tux-ui]
366
+ // scope markers (SPC 16, 66). The attribute selectors also match while the
367
+ // float/sidebar/toast reparent into a dialog's top layer.
368
+ const TUX_CHROME = '#tux-root, [data-tux-float], [data-tux-sidebar], [data-tux-toast], [data-tux-layer], .tux-editor, .tux-marker';
369
+ function inChrome(el) {
370
+ return !!(el.closest && el.closest(TUX_CHROME));
371
+ }
372
+ // [data-tux-ui] marks reviewable scopes. With no user-set scope on the
373
+ // page the whole document is reviewable; with scopes, only inside them.
374
+ // TUX's own chrome never counts as a scope — it carries data-tux-ui too
375
+ // and would otherwise make every page element unreachable (SPC 16, 66).
376
+ function inScope(el) {
377
+ const scopes = [...document.querySelectorAll('[data-tux-ui]')].filter((s) => !inChrome(s));
378
+ if (!scopes.length) return true;
379
+ return scopes.some((s) => s.contains(el));
380
+ }
336
381
  function onMouseOver(e) {
337
382
  if (!mode) return;
338
383
  clearHover();
339
384
  const t = e.target.closest('body *');
340
- if (t && !t.closest('[data-tux-ui]')) {
385
+ if (t && !inChrome(t) && inScope(t)) {
341
386
  t.classList.add('tux-hover');
342
387
  hoverEl = t;
343
388
  }
344
389
  }
345
390
  function onClick(e) {
346
391
  if (!mode) return;
347
- if (e.target.closest('[data-tux-ui]')) return;
392
+ if (inChrome(e.target) || !inScope(e.target)) return;
348
393
  e.preventDefault();
349
394
  e.stopPropagation();
350
395
  const target = e.target.closest('body *');
@@ -474,23 +519,15 @@
474
519
  function renderMarkers() {
475
520
  const layer = root().querySelector('[data-tux-layer]');
476
521
  layer.querySelectorAll('.tux-marker').forEach((m) => m.remove());
477
- document.querySelectorAll('dialog[open] > .tux-marker, [role="dialog"] > .tux-marker').forEach((m) => m.remove());
522
+ document.querySelectorAll('dialog .tux-marker, [role="dialog"] .tux-marker').forEach((m) => m.remove());
523
+ pinRegistry = [];
478
524
  for (const item of items) {
479
525
  const target = locate(item.target);
480
526
  if (!target) continue; // target temporarily gone; feedback stays persisted (SPC 60)
481
527
  const host = overlayHostFor(target);
482
528
  const inTopLayer = host !== layer;
483
- const rect = target.getBoundingClientRect();
484
529
  const pin = el('button', 'tux-marker', String(item.feedback.type[0].toUpperCase()));
485
530
  pin.dataset.id = item.id;
486
- if (inTopLayer) {
487
- pin.style.position = 'fixed';
488
- pin.style.left = `${rect.left + rect.width - 12}px`;
489
- pin.style.top = `${Math.max(0, rect.top - 8)}px`;
490
- } else {
491
- pin.style.left = `${rect.left + window.scrollX + rect.width - 12}px`;
492
- pin.style.top = `${rect.top + window.scrollY - 8}px`;
493
- }
494
531
  pin.classList.toggle('tux-resolved', item.status !== 'open');
495
532
  pin.title = `${item.feedback.type}: ${item.feedback.text}`;
496
533
  pin.setAttribute('data-tux-ui', '');
@@ -499,7 +536,61 @@
499
536
  openEditor({ el: target, mode: 'edit', item });
500
537
  });
501
538
  host.appendChild(pin);
539
+ pinRegistry.push({ target, pin, inTopLayer, lastKey: '' });
502
540
  }
541
+ positionPins();
542
+ bindReposition();
543
+ }
544
+
545
+ // Markers are anchored to the element's CURRENT rect — never to
546
+ // coordinates captured at creation time. positionPins() runs on
547
+ // resize/scroll/DOM mutations (rAF-throttled) so markers follow layout
548
+ // changes: splitter drag, carousel transforms, dialogs opening and
549
+ // closing, route-level reflows. Invisible targets hide their pin and
550
+ // reveal it again when the element becomes visible (SPC 60).
551
+ let pinRegistry = [];
552
+ function positionPins() {
553
+ for (const entry of pinRegistry) {
554
+ const { pin, target, inTopLayer } = entry;
555
+ const rects = target.getClientRects();
556
+ const rect = target.getBoundingClientRect();
557
+ const visible = rects.length > 0 && rect.width > 0 && rect.height > 0;
558
+ const key = visible
559
+ ? `${Math.round(rect.left)},${Math.round(rect.top)},${Math.round(rect.width)}x${Math.round(rect.height)}|${Math.round(window.scrollX)},${Math.round(window.scrollY)}`
560
+ : 'hidden';
561
+ if (entry.lastKey === key) continue;
562
+ entry.lastKey = key;
563
+ if (!visible) { pin.style.display = 'none'; continue; }
564
+ pin.style.display = '';
565
+ if (inTopLayer) {
566
+ pin.style.position = 'fixed';
567
+ pin.style.left = `${rect.left + rect.width - 12}px`;
568
+ pin.style.top = `${Math.max(0, rect.top - 8)}px`;
569
+ } else {
570
+ pin.style.left = `${rect.left + window.scrollX + rect.width - 12}px`;
571
+ pin.style.top = `${rect.top + window.scrollY - 8}px`;
572
+ }
573
+ }
574
+ }
575
+ let rafPending = false;
576
+ function scheduleReposition() {
577
+ if (rafPending) return;
578
+ rafPending = true;
579
+ requestAnimationFrame(() => { rafPending = false; positionPins(); });
580
+ }
581
+ let repositionBound = false;
582
+ function bindReposition() {
583
+ if (repositionBound) return;
584
+ repositionBound = true;
585
+ window.addEventListener('resize', scheduleReposition);
586
+ document.addEventListener('scroll', scheduleReposition, true);
587
+ const start = () => {
588
+ new MutationObserver(scheduleReposition).observe(document.documentElement, {
589
+ childList: true, subtree: true, attributes: true,
590
+ });
591
+ };
592
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start);
593
+ else start();
503
594
  }
504
595
 
505
596
  async function refresh() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tux-review",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "TUX - TUX UIX review system: attach structured, machine-readable feedback directly to running web UIs (pages, routes, components, elements, states) with a deterministic CLI, canonical JSON and LLM-friendly skills.",
5
5
  "license": "MIT",
6
6
  "author": {