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.
- package/client/tux-review.js +100 -24
- package/package.json +1 -1
package/client/tux-review.js
CHANGED
|
@@ -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
|
|
145
|
-
if (
|
|
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
|
|
151
|
-
if (
|
|
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,
|
|
337
|
-
// independent of any user-set [data-tux-ui]
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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": {
|