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.
- package/client/tux-review.js +107 -16
- 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,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
|
|
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.
|
|
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
|
|
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.
|
|
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": {
|