tux-review 0.1.1 → 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 +100 -24
  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,20 +361,22 @@
333
361
  function clearHover() {
334
362
  if (hoverEl) { hoverEl.classList.remove('tux-hover'); hoverEl = null; }
335
363
  }
336
- // TUX's own chrome (overlay root, editor, markers) is never reviewable —
337
- // independent of any user-set [data-tux-ui] scope markers (SPC 16, 66).
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';
338
369
  function inChrome(el) {
339
- if (el.closest && el.closest('.tux-editor,.tux-marker,.tux-toast')) return true;
340
- const r = root();
341
- return !!(r && (r === el || r.contains(el)));
370
+ return !!(el.closest && el.closest(TUX_CHROME));
342
371
  }
343
- // [data-tux-ui] marks reviewable scopes. With no scope on the page the
344
- // whole document is reviewable; with scopes, only inside them.
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).
345
376
  function inScope(el) {
346
- const scopes = document.querySelectorAll('[data-tux-ui]');
377
+ const scopes = [...document.querySelectorAll('[data-tux-ui]')].filter((s) => !inChrome(s));
347
378
  if (!scopes.length) return true;
348
- for (const s of scopes) if (s.contains(el)) return true;
349
- return false;
379
+ return scopes.some((s) => s.contains(el));
350
380
  }
351
381
  function onMouseOver(e) {
352
382
  if (!mode) return;
@@ -489,23 +519,15 @@
489
519
  function renderMarkers() {
490
520
  const layer = root().querySelector('[data-tux-layer]');
491
521
  layer.querySelectorAll('.tux-marker').forEach((m) => m.remove());
492
- 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 = [];
493
524
  for (const item of items) {
494
525
  const target = locate(item.target);
495
526
  if (!target) continue; // target temporarily gone; feedback stays persisted (SPC 60)
496
527
  const host = overlayHostFor(target);
497
528
  const inTopLayer = host !== layer;
498
- const rect = target.getBoundingClientRect();
499
529
  const pin = el('button', 'tux-marker', String(item.feedback.type[0].toUpperCase()));
500
530
  pin.dataset.id = item.id;
501
- if (inTopLayer) {
502
- pin.style.position = 'fixed';
503
- pin.style.left = `${rect.left + rect.width - 12}px`;
504
- pin.style.top = `${Math.max(0, rect.top - 8)}px`;
505
- } else {
506
- pin.style.left = `${rect.left + window.scrollX + rect.width - 12}px`;
507
- pin.style.top = `${rect.top + window.scrollY - 8}px`;
508
- }
509
531
  pin.classList.toggle('tux-resolved', item.status !== 'open');
510
532
  pin.title = `${item.feedback.type}: ${item.feedback.text}`;
511
533
  pin.setAttribute('data-tux-ui', '');
@@ -514,7 +536,61 @@
514
536
  openEditor({ el: target, mode: 'edit', item });
515
537
  });
516
538
  host.appendChild(pin);
539
+ pinRegistry.push({ target, pin, inTopLayer, lastKey: '' });
517
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();
518
594
  }
519
595
 
520
596
  async function refresh() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tux-review",
3
- "version": "0.1.1",
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": {