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.
- package/client/tux-review.js +127 -37
- package/package.json +1 -1
package/client/tux-review.js
CHANGED
|
@@ -131,27 +131,69 @@
|
|
|
131
131
|
}
|
|
132
132
|
function locate(target) {
|
|
133
133
|
if (!target) return null;
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
|
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,
|
|
337
|
-
// independent of any user-set [data-tux-ui]
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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": {
|