tux-review 0.1.0
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/README.md +67 -0
- package/bin/tux.js +17 -0
- package/client/tux-review.css +155 -0
- package/client/tux-review.js +586 -0
- package/package.json +59 -0
- package/skills/tux-design-create.md +50 -0
- package/skills/tux-design-incorporate.md +61 -0
- package/skills/tux-design-install.md +56 -0
- package/skills/tux-design-start-review.md +51 -0
- package/skills/tux-design-stop-review.md +44 -0
- package/skills/tux-feedback-delete.md +45 -0
- package/skills/tux-feedback-export.md +47 -0
- package/skills/tux-feedback-show.md +55 -0
- package/skills/tux-live-create.md +50 -0
- package/skills/tux-live-incorporate.md +62 -0
- package/skills/tux-live-install.md +63 -0
- package/skills/tux-live-start-review.md +47 -0
- package/skills/tux-live-stop-review.md +46 -0
- package/src/canonical.js +28 -0
- package/src/cli.js +229 -0
- package/src/config.js +72 -0
- package/src/design.js +140 -0
- package/src/errors.js +28 -0
- package/src/feedback.js +289 -0
- package/src/ids.js +57 -0
- package/src/index.js +17 -0
- package/src/live.js +261 -0
- package/src/schema.js +77 -0
- package/src/serve-main.js +42 -0
- package/src/server.js +347 -0
- package/src/store.js +72 -0
- package/src/templates.js +44 -0
- package/templates/angular/README.md +19 -0
- package/templates/angular/angular.json +49 -0
- package/templates/angular/package.json +25 -0
- package/templates/angular/src/app/app.component.html +40 -0
- package/templates/angular/src/app/app.component.ts +30 -0
- package/templates/angular/src/index.html +11 -0
- package/templates/angular/src/main.ts +4 -0
- package/templates/angular/src/styles.css +11 -0
- package/templates/angular/tsconfig.app.json +8 -0
- package/templates/angular/tsconfig.json +11 -0
- package/templates/react/README.md +18 -0
- package/templates/react/index.html +12 -0
- package/templates/react/package.json +18 -0
- package/templates/react/src/App.jsx +80 -0
- package/templates/react/src/main.jsx +6 -0
- package/templates/react/src/styles.css +11 -0
- package/templates/vanilla/README.md +22 -0
- package/templates/vanilla/index.html +18 -0
- package/templates/vanilla/package.json +6 -0
- package/templates/vanilla/src/app.js +79 -0
- package/templates/vanilla/src/styles.css +11 -0
- package/templates/vue/README.md +18 -0
- package/templates/vue/index.html +12 -0
- package/templates/vue/package.json +17 -0
- package/templates/vue/src/App.vue +61 -0
- package/templates/vue/src/main.js +5 -0
- package/templates/vue/src/styles.css +11 -0
- package/templates/vue/vite.config.js +6 -0
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* TUX Review Client — framework-agnostic browser overlay (vanilla, zero deps).
|
|
3
|
+
*
|
|
4
|
+
* Injected by the TUX server (or included manually). Requires the TUX
|
|
5
|
+
* bootstrap on window.__TUX__ (see server.js / server.py). The running UI
|
|
6
|
+
* is the review artifact: reviewers select elements directly on the page,
|
|
7
|
+
* leave structured feedback, and markers restore across reloads and
|
|
8
|
+
* client-side navigation.
|
|
9
|
+
*
|
|
10
|
+
* Layers of control (SPC sections 62–70):
|
|
11
|
+
* build inclusion → window.__TUX__.enabled (startup config)
|
|
12
|
+
* → URL override ?tux=on|?tux=off (runtime, sticky per tab)
|
|
13
|
+
*/
|
|
14
|
+
(() => {
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
const OVERRIDE_KEY = 'tux:override';
|
|
18
|
+
const apiBase = (window.__TUX__ && window.__TUX__.apiBase) || '/api/tux';
|
|
19
|
+
const identity = {
|
|
20
|
+
user_id: (window.__TUX__ && window.__TUX__.user) || 'anonymous',
|
|
21
|
+
display_name: (window.__TUX__ && window.__TUX__.displayName) || 'Anonymous',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// ─── activation (SPC 64–69) ───
|
|
25
|
+
function urlOverride() {
|
|
26
|
+
try {
|
|
27
|
+
const v = new URLSearchParams(location.search).get('tux');
|
|
28
|
+
if (v === 'on' || v === 'off') {
|
|
29
|
+
try { sessionStorage.setItem(OVERRIDE_KEY, v); } catch (e) { /* ignore */ }
|
|
30
|
+
return v;
|
|
31
|
+
}
|
|
32
|
+
if (v !== null) {
|
|
33
|
+
console.warn(`[tux] unknown ?tux value ignored: ${v} (expected on or off)`);
|
|
34
|
+
}
|
|
35
|
+
} catch (e) { /* ignore */ }
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
function stickyOverride() {
|
|
39
|
+
try { return sessionStorage.getItem(OVERRIDE_KEY); } catch (e) { return null; }
|
|
40
|
+
}
|
|
41
|
+
function resolveEnabled() {
|
|
42
|
+
const override = urlOverride() ?? stickyOverride();
|
|
43
|
+
if (override === 'on') return true;
|
|
44
|
+
if (override === 'off') return false;
|
|
45
|
+
return !!(window.__TUX__ && window.__TUX__.enabled);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ─── api ───
|
|
49
|
+
async function api(path, options = {}) {
|
|
50
|
+
const res = await fetch(apiBase + path, {
|
|
51
|
+
...options,
|
|
52
|
+
headers: {
|
|
53
|
+
'Content-Type': 'application/json',
|
|
54
|
+
'X-TUX-User-Id': identity.user_id,
|
|
55
|
+
'X-TUX-Display-Name': identity.display_name,
|
|
56
|
+
...(options.headers || {}),
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
const data = await res.json().catch(() => ({}));
|
|
60
|
+
if (!res.ok) throw new Error((data.error && data.error.message) || `HTTP ${res.status}`);
|
|
61
|
+
return data;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ─── target identification (SPC 16) ───
|
|
65
|
+
function cssEscape(value) {
|
|
66
|
+
return (window.CSS && CSS.escape) ? CSS.escape(value) : String(value).replace(/"/g, '\\"');
|
|
67
|
+
}
|
|
68
|
+
function accessibleName(el) {
|
|
69
|
+
return el.getAttribute('aria-label')
|
|
70
|
+
|| (el.getAttribute('aria-labelledby') && document.getElementById(el.getAttribute('aria-labelledby'))?.textContent.trim())
|
|
71
|
+
|| (el.tagName === 'IMG' && el.getAttribute('alt'))
|
|
72
|
+
|| null;
|
|
73
|
+
}
|
|
74
|
+
function domPath(el) {
|
|
75
|
+
const parts = [];
|
|
76
|
+
let node = el;
|
|
77
|
+
while (node && node.nodeType === 1 && parts.length < 25) {
|
|
78
|
+
let seg = node.tagName.toLowerCase();
|
|
79
|
+
if (node.id) seg += `#${node.id}`;
|
|
80
|
+
else if (node.parentElement) {
|
|
81
|
+
const same = Array.from(node.parentElement.children).filter((c) => c.tagName === node.tagName);
|
|
82
|
+
if (same.length > 1) seg += `:nth-of-type(${same.indexOf(node) + 1})`;
|
|
83
|
+
}
|
|
84
|
+
parts.unshift(seg);
|
|
85
|
+
node = node.parentElement;
|
|
86
|
+
}
|
|
87
|
+
return parts.join(' > ');
|
|
88
|
+
}
|
|
89
|
+
function cssSelectorOf(el) {
|
|
90
|
+
if (el.id) return `#${cssEscape(el.id)}`;
|
|
91
|
+
const tag = el.tagName.toLowerCase();
|
|
92
|
+
const classes = Array.from(el.classList || []).filter((c) => !c.startsWith('tux-'));
|
|
93
|
+
let sel = tag + (classes.length ? '.' + classes.map(cssEscape).join('.') : '');
|
|
94
|
+
const parent = el.parentElement;
|
|
95
|
+
if (parent) {
|
|
96
|
+
const same = Array.from(parent.children).filter((c) => c.tagName === el.tagName);
|
|
97
|
+
if (same.length > 1) sel += `:nth-of-type(${same.indexOf(el) + 1})`;
|
|
98
|
+
}
|
|
99
|
+
return sel;
|
|
100
|
+
}
|
|
101
|
+
function fingerprint(el) {
|
|
102
|
+
const anchorComponent = el.closest('[data-tux-component]');
|
|
103
|
+
const anchorInstance = el.closest('[data-tux-instance]');
|
|
104
|
+
const rect = el.getBoundingClientRect();
|
|
105
|
+
const target = {};
|
|
106
|
+
const tuxId = el.closest('[data-tux-id]');
|
|
107
|
+
const testId = el.closest('[data-testid]') || el.closest('[data-test-id]');
|
|
108
|
+
if (tuxId) target.tux_id = tuxId.getAttribute('data-tux-id');
|
|
109
|
+
if (testId) target.test_id = testId.getAttribute('data-testid') || testId.getAttribute('data-test-id');
|
|
110
|
+
if (anchorComponent) target.component = anchorComponent.getAttribute('data-tux-component');
|
|
111
|
+
if (anchorInstance && anchorInstance !== anchorComponent) {
|
|
112
|
+
target.component_instance = anchorInstance.getAttribute('data-tux-instance');
|
|
113
|
+
} else if (anchorComponent && anchorComponent.getAttribute('data-tux-instance')) {
|
|
114
|
+
target.component_instance = anchorComponent.getAttribute('data-tux-instance');
|
|
115
|
+
}
|
|
116
|
+
const role = el.getAttribute('role');
|
|
117
|
+
if (role) target.role = role;
|
|
118
|
+
const aname = accessibleName(el);
|
|
119
|
+
if (aname) target.accessible_name = aname;
|
|
120
|
+
const text = (el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 80);
|
|
121
|
+
if (text) target.text = text;
|
|
122
|
+
target.css_selector = cssSelectorOf(el);
|
|
123
|
+
target.dom_path = domPath(el);
|
|
124
|
+
target.bounding_box = {
|
|
125
|
+
x: Math.round(rect.left + window.scrollX),
|
|
126
|
+
y: Math.round(rect.top + window.scrollY),
|
|
127
|
+
width: Math.round(rect.width),
|
|
128
|
+
height: Math.round(rect.height),
|
|
129
|
+
};
|
|
130
|
+
return target;
|
|
131
|
+
}
|
|
132
|
+
function locate(target) {
|
|
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;
|
|
137
|
+
}
|
|
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;
|
|
141
|
+
}
|
|
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 */ }
|
|
147
|
+
}
|
|
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 */ }
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ─── route + state tracking (SPC 13–15, 59) ───
|
|
158
|
+
function currentRoute() {
|
|
159
|
+
return location.pathname;
|
|
160
|
+
}
|
|
161
|
+
function uiState() {
|
|
162
|
+
const state = {};
|
|
163
|
+
document.querySelectorAll('[data-tux-state]').forEach((el) => {
|
|
164
|
+
for (const pair of (el.getAttribute('data-tux-state') || '').split(';')) {
|
|
165
|
+
const idx = pair.indexOf('=');
|
|
166
|
+
if (idx > 0) state[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim();
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
const tab = document.querySelector('[role="tab"][aria-selected="true"]');
|
|
170
|
+
if (tab) state.tab = (tab.getAttribute('data-tux-tab') || tab.textContent || '').trim().toLowerCase().replace(/\s+/g, '-');
|
|
171
|
+
const dialog = document.querySelector('dialog[open], [role="dialog"][aria-modal="true"]');
|
|
172
|
+
if (dialog) {
|
|
173
|
+
state.modal = dialog.getAttribute('data-tux-id') || dialog.id
|
|
174
|
+
|| (dialog.getAttribute('aria-label') || 'dialog').trim().toLowerCase().replace(/\s+/g, '-');
|
|
175
|
+
}
|
|
176
|
+
return state;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
let routeListenersBound = false;
|
|
180
|
+
let onRouteChange = () => {};
|
|
181
|
+
function trackRoute(cb) {
|
|
182
|
+
onRouteChange = cb;
|
|
183
|
+
if (routeListenersBound) return;
|
|
184
|
+
routeListenersBound = true;
|
|
185
|
+
const wrap = (orig) => function (...args) {
|
|
186
|
+
const r = orig.apply(this, args);
|
|
187
|
+
onRouteChange();
|
|
188
|
+
return r;
|
|
189
|
+
};
|
|
190
|
+
history.pushState = wrap(history.pushState);
|
|
191
|
+
history.replaceState = wrap(history.replaceState);
|
|
192
|
+
window.addEventListener('popstate', () => onRouteChange());
|
|
193
|
+
window.addEventListener('hashchange', () => onRouteChange());
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ─── UI shell ───
|
|
197
|
+
let mode = false;
|
|
198
|
+
let items = []; // feedback for current route
|
|
199
|
+
const root = () => document.getElementById('tux-root');
|
|
200
|
+
|
|
201
|
+
function ensureRoot() {
|
|
202
|
+
let r = root();
|
|
203
|
+
if (!r) {
|
|
204
|
+
r = document.createElement('div');
|
|
205
|
+
r.id = 'tux-root';
|
|
206
|
+
document.body.appendChild(r);
|
|
207
|
+
}
|
|
208
|
+
return r;
|
|
209
|
+
}
|
|
210
|
+
function el(tag, cls, html) {
|
|
211
|
+
const e = document.createElement(tag);
|
|
212
|
+
if (cls) e.className = cls;
|
|
213
|
+
if (html !== undefined) e.innerHTML = html;
|
|
214
|
+
return e;
|
|
215
|
+
}
|
|
216
|
+
function esc(s) {
|
|
217
|
+
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function buildShell() {
|
|
221
|
+
ensureRoot().innerHTML = `
|
|
222
|
+
<div class="tux-float" data-tux-float data-tux-ui>
|
|
223
|
+
<button class="tux-launcher" data-tux-launcher data-tux-ui>⬢</button>
|
|
224
|
+
<span class="tux-launcher-hint" data-tux-hint data-tux-ui></span>
|
|
225
|
+
</div>
|
|
226
|
+
<aside class="tux-sidebar" data-tux-sidebar data-tux-ui>
|
|
227
|
+
<div class="tux-sb-head"><span>TUX Review</span><button class="tux-sb-close" data-tux-sb-close>✕</button></div>
|
|
228
|
+
<div class="tux-sb-meta" data-tux-sb-meta></div>
|
|
229
|
+
<div class="tux-sb-list" data-tux-sb-list></div>
|
|
230
|
+
</aside>
|
|
231
|
+
<div class="tux-layer" data-tux-layer data-tux-ui></div>
|
|
232
|
+
<div class="tux-toast" data-tux-toast data-tux-ui></div>`;
|
|
233
|
+
const launcher = document.querySelector('[data-tux-launcher]');
|
|
234
|
+
launcher.addEventListener('click', () => setMode(!mode));
|
|
235
|
+
root().querySelector('[data-tux-sb-close]').addEventListener('click', closeSidebar);
|
|
236
|
+
document.addEventListener('keydown', (e) => {
|
|
237
|
+
if (e.key === 'Escape') { setMode(false); closeEditor(); }
|
|
238
|
+
if (e.altKey && !e.ctrlKey && !e.metaKey && (e.key === 't' || e.key === 'T')) {
|
|
239
|
+
e.preventDefault();
|
|
240
|
+
setMode(!mode); // works even when a native <dialog> holds the top layer
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ─── top layer: keep the launcher clickable above modal backdrops ───
|
|
246
|
+
// While a native <dialog> is open, everything outside the top layer sits
|
|
247
|
+
// below its backdrop. The floating activation surface therefore moves
|
|
248
|
+
// into the open dialog and returns when it closes (same wrapping
|
|
249
|
+
// technique as the route tracking above).
|
|
250
|
+
let topLayerBound = false;
|
|
251
|
+
function bindTopLayer() {
|
|
252
|
+
if (topLayerBound) return;
|
|
253
|
+
topLayerBound = true;
|
|
254
|
+
// Sidebar and toast move together with the float: while a modal is
|
|
255
|
+
// open they would otherwise sit behind the backdrop.
|
|
256
|
+
const MOVED = ['[data-tux-float]', '[data-tux-sidebar]', '[data-tux-toast]'];
|
|
257
|
+
const enter = (dialog) => {
|
|
258
|
+
for (const sel of MOVED) {
|
|
259
|
+
const n = document.querySelector(sel);
|
|
260
|
+
if (n && n.parentElement !== dialog) dialog.appendChild(n);
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
const exit = () => {
|
|
264
|
+
const r = root();
|
|
265
|
+
if (!r) return;
|
|
266
|
+
for (const sel of MOVED) {
|
|
267
|
+
const n = document.querySelector(sel);
|
|
268
|
+
if (n && n.parentElement !== r) r.appendChild(n);
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
const proto = window.HTMLDialogElement && HTMLDialogElement.prototype;
|
|
272
|
+
if (!proto) return;
|
|
273
|
+
for (const name of ['showModal', 'close']) {
|
|
274
|
+
const orig = proto[name];
|
|
275
|
+
proto[name] = function (...args) {
|
|
276
|
+
const r = orig.apply(this, args);
|
|
277
|
+
if (name === 'showModal') enter(this); else exit();
|
|
278
|
+
return r;
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Launcher state: boot invite → persistent "commenting on/off" label
|
|
284
|
+
// plus a hover tooltip on the collapsed button.
|
|
285
|
+
let interacted = false;
|
|
286
|
+
function updateLauncherHint() {
|
|
287
|
+
const launcher = document.querySelector('[data-tux-launcher]');
|
|
288
|
+
const hint = document.querySelector('[data-tux-hint]');
|
|
289
|
+
if (!launcher || !hint) return;
|
|
290
|
+
let text;
|
|
291
|
+
if (!interacted) {
|
|
292
|
+
text = 'To activate TUX review, click here';
|
|
293
|
+
hint.className = 'tux-launcher-hint tux-hint-invite';
|
|
294
|
+
launcher.classList.add('tux-invite');
|
|
295
|
+
} else if (mode) {
|
|
296
|
+
text = 'Commenting on';
|
|
297
|
+
hint.className = 'tux-launcher-hint tux-hint-on';
|
|
298
|
+
launcher.classList.remove('tux-invite');
|
|
299
|
+
} else {
|
|
300
|
+
text = 'Commenting off';
|
|
301
|
+
hint.className = 'tux-launcher-hint tux-hint-off';
|
|
302
|
+
launcher.classList.remove('tux-invite');
|
|
303
|
+
}
|
|
304
|
+
hint.textContent = text;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function toast(msg) {
|
|
308
|
+
const t = document.querySelector('[data-tux-toast]');
|
|
309
|
+
if (!t) return;
|
|
310
|
+
t.textContent = msg;
|
|
311
|
+
t.classList.add('tux-visible');
|
|
312
|
+
clearTimeout(t._tm);
|
|
313
|
+
t._tm = setTimeout(() => t.classList.remove('tux-visible'), 2200);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function setMode(v) {
|
|
317
|
+
mode = !!v;
|
|
318
|
+
document.body.classList.toggle('tux-mode', mode);
|
|
319
|
+
document.querySelector('[data-tux-launcher]').classList.toggle('tux-active', mode);
|
|
320
|
+
if (!interacted) interacted = true;
|
|
321
|
+
updateLauncherHint();
|
|
322
|
+
if (mode) {
|
|
323
|
+
openSidebar();
|
|
324
|
+
toast('Review mode on — click an element');
|
|
325
|
+
} else {
|
|
326
|
+
clearHover();
|
|
327
|
+
closeEditor();
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ─── selection ───
|
|
332
|
+
let hoverEl = null;
|
|
333
|
+
function clearHover() {
|
|
334
|
+
if (hoverEl) { hoverEl.classList.remove('tux-hover'); hoverEl = null; }
|
|
335
|
+
}
|
|
336
|
+
function onMouseOver(e) {
|
|
337
|
+
if (!mode) return;
|
|
338
|
+
clearHover();
|
|
339
|
+
const t = e.target.closest('body *');
|
|
340
|
+
if (t && !t.closest('[data-tux-ui]')) {
|
|
341
|
+
t.classList.add('tux-hover');
|
|
342
|
+
hoverEl = t;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
function onClick(e) {
|
|
346
|
+
if (!mode) return;
|
|
347
|
+
if (e.target.closest('[data-tux-ui]')) return;
|
|
348
|
+
e.preventDefault();
|
|
349
|
+
e.stopPropagation();
|
|
350
|
+
const target = e.target.closest('body *');
|
|
351
|
+
if (target) openEditor({ el: target, mode: 'create' });
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ─── editor ───
|
|
355
|
+
let editorState = null;
|
|
356
|
+
function overlayHostFor(target) {
|
|
357
|
+
// Targets inside a native <dialog> live in the browser top layer —
|
|
358
|
+
// overlay UI must be portaled into the dialog or the backdrop covers it.
|
|
359
|
+
return target.closest('dialog[open], [role="dialog"][aria-modal="true"]') || root().querySelector('[data-tux-layer]');
|
|
360
|
+
}
|
|
361
|
+
function closeEditor() {
|
|
362
|
+
document.querySelectorAll('.tux-editing').forEach((n) => n.classList.remove('tux-editing'));
|
|
363
|
+
const layer = root() && root().querySelector('[data-tux-layer]');
|
|
364
|
+
if (layer) layer.querySelectorAll('.tux-editor').forEach((n) => n.remove());
|
|
365
|
+
document.querySelectorAll('dialog[open] .tux-editor, [role="dialog"] .tux-editor').forEach((n) => n.remove());
|
|
366
|
+
editorState = null;
|
|
367
|
+
}
|
|
368
|
+
function openEditor(state) {
|
|
369
|
+
closeEditor();
|
|
370
|
+
editorState = state;
|
|
371
|
+
// keep the edit target visually marked for as long as its editor is
|
|
372
|
+
// open — same treatment as the hover outline
|
|
373
|
+
if (state.mode === 'edit' && state.el) state.el.classList.add('tux-editing');
|
|
374
|
+
const host = overlayHostFor(state.el);
|
|
375
|
+
const inTopLayer = host !== root().querySelector('[data-tux-layer]');
|
|
376
|
+
const rect = state.el.getBoundingClientRect();
|
|
377
|
+
const isCreate = state.mode === 'create';
|
|
378
|
+
const box = el('div', 'tux-editor tux-open', `
|
|
379
|
+
<div class="tux-ed-head"><span>${isCreate ? 'New feedback' : 'Edit feedback'}</span>
|
|
380
|
+
<button class="tux-x" data-act="cancel">✕</button></div>
|
|
381
|
+
<div class="tux-ed-target">${esc(describe(state.el))}</div>
|
|
382
|
+
<label class="tux-ed-row"><span>Type</span>
|
|
383
|
+
<select data-ed-type>
|
|
384
|
+
<option value="change">change</option>
|
|
385
|
+
<option value="issue">issue</option>
|
|
386
|
+
<option value="question">question</option>
|
|
387
|
+
<option value="approval">approval</option>
|
|
388
|
+
</select></label>
|
|
389
|
+
<textarea data-ed-text placeholder="What should change?"></textarea>
|
|
390
|
+
<div class="tux-ed-actions">
|
|
391
|
+
${!isCreate ? '<button class="tux-btn-danger" data-act="delete">Delete</button>' : '<span></span>'}
|
|
392
|
+
<button class="tux-btn-ghost" data-act="cancel">Cancel</button>
|
|
393
|
+
<button class="tux-btn-primary" data-act="save">${isCreate ? 'Create' : 'Save'}</button>
|
|
394
|
+
</div>`);
|
|
395
|
+
host.appendChild(box);
|
|
396
|
+
box.setAttribute('data-tux-ui', '');
|
|
397
|
+
box.querySelector('[data-ed-type]').value = state.item ? state.item.feedback.type : 'change';
|
|
398
|
+
box.querySelector('[data-ed-text]').value = state.item ? state.item.feedback.text : '';
|
|
399
|
+
const place = () => {
|
|
400
|
+
const b = box.getBoundingClientRect();
|
|
401
|
+
let left;
|
|
402
|
+
let top;
|
|
403
|
+
if (inTopLayer) {
|
|
404
|
+
left = Math.min(rect.left, window.innerWidth - b.width - 12);
|
|
405
|
+
top = rect.bottom + 8;
|
|
406
|
+
if (top + b.height > window.innerHeight - 8) top = rect.top - b.height - 8;
|
|
407
|
+
} else {
|
|
408
|
+
left = Math.min(rect.left + window.scrollX, window.innerWidth - b.width - 12);
|
|
409
|
+
top = rect.bottom + window.scrollY + 8;
|
|
410
|
+
if (top + b.height > window.scrollY + window.innerHeight) top = rect.top + window.scrollY - b.height - 8;
|
|
411
|
+
}
|
|
412
|
+
box.style.position = inTopLayer ? 'fixed' : 'absolute';
|
|
413
|
+
box.style.left = `${Math.max(8, left)}px`;
|
|
414
|
+
box.style.top = `${Math.max(8, top)}px`;
|
|
415
|
+
};
|
|
416
|
+
place();
|
|
417
|
+
box.querySelectorAll('[data-act]').forEach((btn) => {
|
|
418
|
+
btn.addEventListener('click', async (e) => {
|
|
419
|
+
e.stopPropagation();
|
|
420
|
+
const act = btn.dataset.act;
|
|
421
|
+
if (act === 'cancel') return closeEditor();
|
|
422
|
+
const type = box.querySelector('[data-ed-type]').value;
|
|
423
|
+
const text = box.querySelector('[data-ed-text]').value.trim();
|
|
424
|
+
try {
|
|
425
|
+
if (act === 'save' && isCreate) {
|
|
426
|
+
const fp = fingerprint(state.el);
|
|
427
|
+
const { component_instance: instance, ...target } = fp;
|
|
428
|
+
await api('/feedback', {
|
|
429
|
+
method: 'POST',
|
|
430
|
+
body: JSON.stringify({
|
|
431
|
+
type, text,
|
|
432
|
+
location: {
|
|
433
|
+
route: currentRoute(),
|
|
434
|
+
...(fp.component ? { component: fp.component } : {}),
|
|
435
|
+
...(instance ? { component_instance: instance } : {}),
|
|
436
|
+
},
|
|
437
|
+
target,
|
|
438
|
+
ui_state: uiState(),
|
|
439
|
+
}),
|
|
440
|
+
});
|
|
441
|
+
toast('Feedback created');
|
|
442
|
+
closeEditor();
|
|
443
|
+
await refresh();
|
|
444
|
+
} else if (act === 'save') {
|
|
445
|
+
await api(`/feedback/${encodeURIComponent(state.item.id)}`, {
|
|
446
|
+
method: 'PATCH',
|
|
447
|
+
body: JSON.stringify({ type, text }),
|
|
448
|
+
});
|
|
449
|
+
toast('Feedback updated');
|
|
450
|
+
closeEditor();
|
|
451
|
+
await refresh();
|
|
452
|
+
} else if (act === 'delete') {
|
|
453
|
+
await api(`/feedback/${encodeURIComponent(state.item.id)}`, { method: 'DELETE' });
|
|
454
|
+
toast('Feedback deleted');
|
|
455
|
+
closeEditor();
|
|
456
|
+
await refresh();
|
|
457
|
+
}
|
|
458
|
+
} catch (err) {
|
|
459
|
+
toast(`✗ ${err.message}`);
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
box.querySelector('[data-ed-text]').focus();
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function describe(elm) {
|
|
467
|
+
const cls = Array.from(elm.classList || []).filter((c) => !c.startsWith('tux-')).join('.');
|
|
468
|
+
const txt = (elm.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 40);
|
|
469
|
+
const name = elm.getAttribute('data-tux-id');
|
|
470
|
+
return name ? `#${name}` : elm.tagName.toLowerCase() + (cls ? `.${cls}` : '') + (txt ? ` — ${txt}` : '');
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// ─── markers ───
|
|
474
|
+
function renderMarkers() {
|
|
475
|
+
const layer = root().querySelector('[data-tux-layer]');
|
|
476
|
+
layer.querySelectorAll('.tux-marker').forEach((m) => m.remove());
|
|
477
|
+
document.querySelectorAll('dialog[open] > .tux-marker, [role="dialog"] > .tux-marker').forEach((m) => m.remove());
|
|
478
|
+
for (const item of items) {
|
|
479
|
+
const target = locate(item.target);
|
|
480
|
+
if (!target) continue; // target temporarily gone; feedback stays persisted (SPC 60)
|
|
481
|
+
const host = overlayHostFor(target);
|
|
482
|
+
const inTopLayer = host !== layer;
|
|
483
|
+
const rect = target.getBoundingClientRect();
|
|
484
|
+
const pin = el('button', 'tux-marker', String(item.feedback.type[0].toUpperCase()));
|
|
485
|
+
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
|
+
pin.classList.toggle('tux-resolved', item.status !== 'open');
|
|
495
|
+
pin.title = `${item.feedback.type}: ${item.feedback.text}`;
|
|
496
|
+
pin.setAttribute('data-tux-ui', '');
|
|
497
|
+
pin.addEventListener('click', (e) => {
|
|
498
|
+
e.stopPropagation();
|
|
499
|
+
openEditor({ el: target, mode: 'edit', item });
|
|
500
|
+
});
|
|
501
|
+
host.appendChild(pin);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
async function refresh() {
|
|
506
|
+
const route = currentRoute();
|
|
507
|
+
const q = new URLSearchParams({ route });
|
|
508
|
+
const payload = await api(`/feedback?${q}`).catch(() => null);
|
|
509
|
+
items = payload ? payload.feedback : [];
|
|
510
|
+
renderMarkers();
|
|
511
|
+
renderSidebar();
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// ─── sidebar ───
|
|
515
|
+
function openSidebar() {
|
|
516
|
+
document.querySelector('[data-tux-sidebar]').classList.add('tux-open');
|
|
517
|
+
renderSidebar();
|
|
518
|
+
}
|
|
519
|
+
function closeSidebar() {
|
|
520
|
+
document.querySelector('[data-tux-sidebar]').classList.remove('tux-open');
|
|
521
|
+
}
|
|
522
|
+
function renderSidebar() {
|
|
523
|
+
const sb = document.querySelector('[data-tux-sidebar]');
|
|
524
|
+
if (!sb) return;
|
|
525
|
+
const open = items.filter((i) => i.status === 'open').length;
|
|
526
|
+
sb.querySelector('[data-tux-sb-meta]').textContent =
|
|
527
|
+
`${open} open · ${items.length - open} done · ${identity.display_name}`;
|
|
528
|
+
const list = sb.querySelector('[data-tux-sb-list]');
|
|
529
|
+
list.innerHTML = items.length
|
|
530
|
+
? items.map((i) => `
|
|
531
|
+
<div class="tux-sb-item${i.status !== 'open' ? ' tux-done' : ''}" data-id="${esc(i.id)}">
|
|
532
|
+
<span class="tux-sb-type tux-type-${esc(i.feedback.type)}">${esc(i.feedback.type)}</span>
|
|
533
|
+
<span class="tux-sb-text">${esc(i.feedback.text)}</span>
|
|
534
|
+
<span class="tux-sb-route">${esc((i.location && i.location.route) || '')}</span>
|
|
535
|
+
</div>`).join('')
|
|
536
|
+
: '<div class="tux-sb-empty">No feedback on this route.</div>';
|
|
537
|
+
list.querySelectorAll('.tux-sb-item').forEach((node) => {
|
|
538
|
+
node.addEventListener('click', () => {
|
|
539
|
+
const item = items.find((i) => i.id === node.dataset.id);
|
|
540
|
+
if (!item) return;
|
|
541
|
+
const target = locate(item.target);
|
|
542
|
+
if (target) {
|
|
543
|
+
target.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
544
|
+
openEditor({ el: target, mode: 'edit', item });
|
|
545
|
+
} else {
|
|
546
|
+
toast('Target not on this page — feedback kept');
|
|
547
|
+
}
|
|
548
|
+
});
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// ─── boot ───
|
|
553
|
+
function ensureStyles() {
|
|
554
|
+
if (document.querySelector('link[data-tux-styles]')) return;
|
|
555
|
+
const link = document.createElement('link');
|
|
556
|
+
link.rel = 'stylesheet';
|
|
557
|
+
link.href = '/__tux__/client.css';
|
|
558
|
+
link.setAttribute('data-tux-styles', '');
|
|
559
|
+
document.head.appendChild(link);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function boot() {
|
|
563
|
+
if (!resolveEnabled()) return; // ?tux=off or config disabled → stay inert (SPC 66–68)
|
|
564
|
+
ensureStyles();
|
|
565
|
+
buildShell();
|
|
566
|
+
updateLauncherHint();
|
|
567
|
+
bindTopLayer();
|
|
568
|
+
document.addEventListener('mouseover', onMouseOver, true);
|
|
569
|
+
document.addEventListener('click', onClick, true);
|
|
570
|
+
trackRoute(() => { refresh(); });
|
|
571
|
+
refresh();
|
|
572
|
+
window.__TUX_READY__ = true;
|
|
573
|
+
document.dispatchEvent(new CustomEvent('tux:ready'));
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
if (document.readyState === 'loading') {
|
|
577
|
+
document.addEventListener('DOMContentLoaded', boot);
|
|
578
|
+
} else {
|
|
579
|
+
boot();
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
window.TUXReview = {
|
|
583
|
+
setMode, refresh, uiState, fingerprint, resolveEnabled,
|
|
584
|
+
get mode() { return mode; },
|
|
585
|
+
};
|
|
586
|
+
})();
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tux-review",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"license": "MIT",
|
|
6
|
+
"author": {
|
|
7
|
+
"name": "Lorenz Sommer",
|
|
8
|
+
"url": "https://www.linkedin.com/in/sommerlorenz/"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/sommelo1/tux#readme",
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/sommelo1/tux.git"
|
|
14
|
+
},
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/sommelo1/tux/issues"
|
|
17
|
+
},
|
|
18
|
+
"type": "module",
|
|
19
|
+
"bin": {
|
|
20
|
+
"tux": "bin/tux.js"
|
|
21
|
+
},
|
|
22
|
+
"exports": {
|
|
23
|
+
".": "./src/index.js",
|
|
24
|
+
"./client": "./client/tux-review.js"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"bin",
|
|
28
|
+
"src",
|
|
29
|
+
"client",
|
|
30
|
+
"templates",
|
|
31
|
+
"skills",
|
|
32
|
+
"README.md"
|
|
33
|
+
],
|
|
34
|
+
"keywords": [
|
|
35
|
+
"ui",
|
|
36
|
+
"ux",
|
|
37
|
+
"review",
|
|
38
|
+
"feedback",
|
|
39
|
+
"clickable-design",
|
|
40
|
+
"llm",
|
|
41
|
+
"ai",
|
|
42
|
+
"agents"
|
|
43
|
+
],
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=20"
|
|
46
|
+
},
|
|
47
|
+
"scripts": {
|
|
48
|
+
"test": "node test/conformance.test.js && node --test test/api.test.js && node --test test/live.test.js && node test/skills.test.js",
|
|
49
|
+
"test:watch": "node --watch-path=../conformance test/conformance.test.js",
|
|
50
|
+
"e2e": "node e2e/run.mjs",
|
|
51
|
+
"package:js": "node ../tools/package-js.mjs",
|
|
52
|
+
"package:py": "node ../tools/package-py.mjs",
|
|
53
|
+
"install-test": "node ../tools/install-test.mjs",
|
|
54
|
+
"release": "node ../tools/release.mjs"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@playwright/test": "^1.62.1"
|
|
58
|
+
}
|
|
59
|
+
}
|