tux-review 0.1.1 → 0.1.3

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 +127 -37
  2. package/package.json +1 -1
@@ -131,27 +131,69 @@
131
131
  }
132
132
  function locate(target) {
133
133
  if (!target) return null;
134
- if (target.tux_id) {
135
- const el = document.querySelector(`[data-tux-id="${cssEscape(target.tux_id)}"]`);
136
- if (el) return el;
134
+ // Fingerprint validation: when a resolution strategy yields several
135
+ // candidates (nth-child paths drift after DOM changes), pick the one
136
+ // that best matches the stored fingerprint — component/instance scope,
137
+ // captured text, role. Score 0 keeps the first candidate (SPC 16).
138
+ function scoreCandidate(el) {
139
+ let score = 0;
140
+ if (target.component && el.closest(`[data-tux-component="${cssEscape(target.component)}"]`)) score += 4;
141
+ if (target.component_instance && el.closest(`[data-tux-instance="${cssEscape(target.component_instance)}"]`)) score += 3;
142
+ if (target.text) {
143
+ const t = (el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 80);
144
+ if (t === target.text) score += 2;
145
+ else if (target.text.length > 12 && t.includes(target.text.slice(0, 40))) score += 1;
146
+ }
147
+ if (target.role && el.getAttribute('role') === target.role) score += 1;
148
+ return score;
137
149
  }
138
- if (target.test_id) {
139
- const el = document.querySelector(`[data-testid="${cssEscape(target.test_id)}"], [data-test-id="${cssEscape(target.test_id)}"]`);
140
- if (el) return el;
150
+ function pickBest(nodes) {
151
+ let best = null, bestScore = -1;
152
+ for (const n of nodes) {
153
+ const s = scoreCandidate(n);
154
+ if (s > bestScore) { bestScore = s; best = n; }
155
+ }
156
+ return best;
141
157
  }
142
- if (target.css_selector) {
143
- try {
144
- const el = document.querySelector(target.css_selector);
145
- if (el) return el;
146
- } catch (e) { /* invalid selector */ }
158
+ function resolveByPath() {
159
+ if (target.css_selector) {
160
+ try {
161
+ const nodes = document.querySelectorAll(target.css_selector);
162
+ if (nodes.length) return pickBest(nodes);
163
+ } catch (e) { /* invalid selector */ }
164
+ }
165
+ if (target.dom_path) {
166
+ try {
167
+ const snapshot = document.evaluate(target.dom_path, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
168
+ if (snapshot.snapshotLength) {
169
+ const nodes = [];
170
+ for (let i = 0; i < snapshot.snapshotLength; i++) nodes.push(snapshot.snapshotItem(i));
171
+ return pickBest(nodes);
172
+ }
173
+ } catch (e) { /* invalid path */ }
174
+ }
175
+ return null;
147
176
  }
148
- if (target.dom_path) {
149
- try {
150
- const el = document.evaluate(target.dom_path, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
151
- if (el) return el;
152
- } catch (e) { /* invalid path */ }
177
+
178
+ const byTest = target.test_id
179
+ ? document.querySelector(`[data-testid="${cssEscape(target.test_id)}"], [data-test-id="${cssEscape(target.test_id)}"]`)
180
+ : null;
181
+ if (byTest) return byTest; // explicit test hook: exact element (SPC 16)
182
+
183
+ const byPath = resolveByPath();
184
+ const byId = target.tux_id
185
+ ? document.querySelector(`[data-tux-id="${cssEscape(target.tux_id)}"]`)
186
+ : null;
187
+
188
+ // A tux_id on a CONTAINER must not coarsen the target: when the path
189
+ // resolves to an element inside the id element, the path is the finer,
190
+ // correct target. Only trust the id over a path that drifted outside
191
+ // of it (SPC 16: multiple independent signals, finest wins).
192
+ if (byId) {
193
+ if (byPath && (byId === byPath || byId.contains(byPath))) return byPath;
194
+ return byId;
153
195
  }
154
- return null;
196
+ return byPath;
155
197
  }
156
198
 
157
199
  // ─── route + state tracking (SPC 13–15, 59) ───
@@ -333,20 +375,22 @@
333
375
  function clearHover() {
334
376
  if (hoverEl) { hoverEl.classList.remove('tux-hover'); hoverEl = null; }
335
377
  }
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).
378
+ // TUX's own chrome (overlay root, float, sidebar, layer, toast, editor,
379
+ // markers) is never reviewable — independent of any user-set [data-tux-ui]
380
+ // scope markers (SPC 16, 66). The attribute selectors also match while the
381
+ // float/sidebar/toast reparent into a dialog's top layer.
382
+ const TUX_CHROME = '#tux-root, [data-tux-float], [data-tux-sidebar], [data-tux-toast], [data-tux-layer], .tux-editor, .tux-marker';
338
383
  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)));
384
+ return !!(el.closest && el.closest(TUX_CHROME));
342
385
  }
343
- // [data-tux-ui] marks reviewable scopes. With no scope on the page the
344
- // whole document is reviewable; with scopes, only inside them.
386
+ // [data-tux-ui] marks reviewable scopes. With no user-set scope on the
387
+ // page the whole document is reviewable; with scopes, only inside them.
388
+ // TUX's own chrome never counts as a scope — it carries data-tux-ui too
389
+ // and would otherwise make every page element unreachable (SPC 16, 66).
345
390
  function inScope(el) {
346
- const scopes = document.querySelectorAll('[data-tux-ui]');
391
+ const scopes = [...document.querySelectorAll('[data-tux-ui]')].filter((s) => !inChrome(s));
347
392
  if (!scopes.length) return true;
348
- for (const s of scopes) if (s.contains(el)) return true;
349
- return false;
393
+ return scopes.some((s) => s.contains(el));
350
394
  }
351
395
  function onMouseOver(e) {
352
396
  if (!mode) return;
@@ -489,23 +533,15 @@
489
533
  function renderMarkers() {
490
534
  const layer = root().querySelector('[data-tux-layer]');
491
535
  layer.querySelectorAll('.tux-marker').forEach((m) => m.remove());
492
- document.querySelectorAll('dialog[open] > .tux-marker, [role="dialog"] > .tux-marker').forEach((m) => m.remove());
536
+ document.querySelectorAll('dialog .tux-marker, [role="dialog"] .tux-marker').forEach((m) => m.remove());
537
+ pinRegistry = [];
493
538
  for (const item of items) {
494
539
  const target = locate(item.target);
495
540
  if (!target) continue; // target temporarily gone; feedback stays persisted (SPC 60)
496
541
  const host = overlayHostFor(target);
497
542
  const inTopLayer = host !== layer;
498
- const rect = target.getBoundingClientRect();
499
543
  const pin = el('button', 'tux-marker', String(item.feedback.type[0].toUpperCase()));
500
544
  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
545
  pin.classList.toggle('tux-resolved', item.status !== 'open');
510
546
  pin.title = `${item.feedback.type}: ${item.feedback.text}`;
511
547
  pin.setAttribute('data-tux-ui', '');
@@ -514,7 +550,61 @@
514
550
  openEditor({ el: target, mode: 'edit', item });
515
551
  });
516
552
  host.appendChild(pin);
553
+ pinRegistry.push({ target, pin, inTopLayer, lastKey: '' });
517
554
  }
555
+ positionPins();
556
+ bindReposition();
557
+ }
558
+
559
+ // Markers are anchored to the element's CURRENT rect — never to
560
+ // coordinates captured at creation time. positionPins() runs on
561
+ // resize/scroll/DOM mutations (rAF-throttled) so markers follow layout
562
+ // changes: splitter drag, carousel transforms, dialogs opening and
563
+ // closing, route-level reflows. Invisible targets hide their pin and
564
+ // reveal it again when the element becomes visible (SPC 60).
565
+ let pinRegistry = [];
566
+ function positionPins() {
567
+ for (const entry of pinRegistry) {
568
+ const { pin, target, inTopLayer } = entry;
569
+ const rects = target.getClientRects();
570
+ const rect = target.getBoundingClientRect();
571
+ const visible = rects.length > 0 && rect.width > 0 && rect.height > 0;
572
+ const key = visible
573
+ ? `${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)}`
574
+ : 'hidden';
575
+ if (entry.lastKey === key) continue;
576
+ entry.lastKey = key;
577
+ if (!visible) { pin.style.display = 'none'; continue; }
578
+ pin.style.display = '';
579
+ if (inTopLayer) {
580
+ pin.style.position = 'fixed';
581
+ pin.style.left = `${rect.left + rect.width - 12}px`;
582
+ pin.style.top = `${Math.max(0, rect.top - 8)}px`;
583
+ } else {
584
+ pin.style.left = `${rect.left + window.scrollX + rect.width - 12}px`;
585
+ pin.style.top = `${rect.top + window.scrollY - 8}px`;
586
+ }
587
+ }
588
+ }
589
+ let rafPending = false;
590
+ function scheduleReposition() {
591
+ if (rafPending) return;
592
+ rafPending = true;
593
+ requestAnimationFrame(() => { rafPending = false; positionPins(); });
594
+ }
595
+ let repositionBound = false;
596
+ function bindReposition() {
597
+ if (repositionBound) return;
598
+ repositionBound = true;
599
+ window.addEventListener('resize', scheduleReposition);
600
+ document.addEventListener('scroll', scheduleReposition, true);
601
+ const start = () => {
602
+ new MutationObserver(scheduleReposition).observe(document.documentElement, {
603
+ childList: true, subtree: true, attributes: true,
604
+ });
605
+ };
606
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start);
607
+ else start();
518
608
  }
519
609
 
520
610
  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.3",
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": {