liteagents 2.5.3 → 2.6.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/CHANGELOG.md +31 -0
- package/README.md +51 -45
- package/installer/cli.js +1 -1
- package/installer/installation-engine.js +82 -21
- package/installer/package-manager.js +34 -3
- package/package.json +3 -2
- package/packages/ampcode/AGENT.md +1 -0
- package/packages/ampcode/commands/friction/friction.js +19 -4
- package/packages/ampcode/commands/live-canvas/DESIGN_PRINCIPLES.md +2140 -0
- package/packages/ampcode/commands/live-canvas/README.md +273 -0
- package/packages/ampcode/commands/live-canvas/templates/.claude/settings.local.json +8 -0
- package/packages/ampcode/commands/live-canvas/templates/demo/post-variants.html +205 -0
- package/packages/ampcode/commands/live-canvas/templates/feedback-react/FeedbackOverlay.tsx +1299 -0
- package/packages/ampcode/commands/live-canvas/templates/feedback-react/format-utils.ts +283 -0
- package/packages/ampcode/commands/live-canvas/templates/feedback-react/index.ts +62 -0
- package/packages/ampcode/commands/live-canvas/templates/feedback-react/selector-utils.ts +423 -0
- package/packages/ampcode/commands/live-canvas/templates/feedback-react/types.ts +118 -0
- package/packages/ampcode/commands/live-canvas/templates/overlay-vanilla.js +477 -0
- package/packages/ampcode/commands/live-canvas.md +1104 -0
- package/packages/claude/CLAUDE.md +1 -0
- package/packages/claude/commands/friction/friction.js +19 -4
- package/packages/claude/plugins/live-canvas-marketplace/.claude-plugin/marketplace.json +14 -0
- package/packages/claude/plugins/live-canvas-marketplace/plugins/live-canvas-channel/.claude-plugin/plugin.json +18 -0
- package/packages/claude/plugins/live-canvas-marketplace/plugins/live-canvas-channel/README.md +89 -0
- package/packages/claude/plugins/live-canvas-marketplace/plugins/live-canvas-channel/package-lock.json +1142 -0
- package/packages/claude/plugins/live-canvas-marketplace/plugins/live-canvas-channel/package.json +17 -0
- package/packages/claude/plugins/live-canvas-marketplace/plugins/live-canvas-channel/schema.json +37 -0
- package/packages/claude/plugins/live-canvas-marketplace/plugins/live-canvas-channel/server.js +179 -0
- package/packages/claude/plugins/live-canvas-marketplace/setup.sh +61 -0
- package/packages/claude/skills/live-canvas/DESIGN_PRINCIPLES.md +2140 -0
- package/packages/claude/skills/live-canvas/INTEGRATION_NOTES.md +210 -0
- package/packages/claude/skills/live-canvas/README.md +273 -0
- package/packages/claude/skills/live-canvas/SKILL.md +1119 -0
- package/packages/claude/skills/live-canvas/templates/.claude/settings.local.json +8 -0
- package/packages/claude/skills/live-canvas/templates/demo/post-variants.html +205 -0
- package/packages/claude/skills/live-canvas/templates/feedback-react/FeedbackOverlay.tsx +1299 -0
- package/packages/claude/skills/live-canvas/templates/feedback-react/format-utils.ts +283 -0
- package/packages/claude/skills/live-canvas/templates/feedback-react/index.ts +62 -0
- package/packages/claude/skills/live-canvas/templates/feedback-react/selector-utils.ts +423 -0
- package/packages/claude/skills/live-canvas/templates/feedback-react/types.ts +118 -0
- package/packages/claude/skills/live-canvas/templates/overlay-vanilla.js +477 -0
- package/packages/claude/variants.json +2 -1
- package/packages/droid/AGENTS.md +1 -0
- package/packages/droid/commands/friction/friction.js +19 -4
- package/packages/droid/commands/live-canvas/DESIGN_PRINCIPLES.md +2140 -0
- package/packages/droid/commands/live-canvas/README.md +273 -0
- package/packages/droid/commands/live-canvas/templates/.claude/settings.local.json +8 -0
- package/packages/droid/commands/live-canvas/templates/demo/post-variants.html +205 -0
- package/packages/droid/commands/live-canvas/templates/feedback-react/FeedbackOverlay.tsx +1299 -0
- package/packages/droid/commands/live-canvas/templates/feedback-react/format-utils.ts +283 -0
- package/packages/droid/commands/live-canvas/templates/feedback-react/index.ts +62 -0
- package/packages/droid/commands/live-canvas/templates/feedback-react/selector-utils.ts +423 -0
- package/packages/droid/commands/live-canvas/templates/feedback-react/types.ts +118 -0
- package/packages/droid/commands/live-canvas/templates/overlay-vanilla.js +477 -0
- package/packages/droid/commands/live-canvas.md +1104 -0
- package/packages/opencode/AGENTS.md +1 -0
- package/packages/opencode/command/friction/friction.js +19 -4
- package/packages/opencode/command/live-canvas/DESIGN_PRINCIPLES.md +2140 -0
- package/packages/opencode/command/live-canvas/README.md +273 -0
- package/packages/opencode/command/live-canvas/templates/.claude/settings.local.json +8 -0
- package/packages/opencode/command/live-canvas/templates/demo/post-variants.html +205 -0
- package/packages/opencode/command/live-canvas/templates/feedback-react/FeedbackOverlay.tsx +1299 -0
- package/packages/opencode/command/live-canvas/templates/feedback-react/format-utils.ts +283 -0
- package/packages/opencode/command/live-canvas/templates/feedback-react/index.ts +62 -0
- package/packages/opencode/command/live-canvas/templates/feedback-react/selector-utils.ts +423 -0
- package/packages/opencode/command/live-canvas/templates/feedback-react/types.ts +118 -0
- package/packages/opencode/command/live-canvas/templates/overlay-vanilla.js +477 -0
- package/packages/opencode/command/live-canvas.md +1104 -0
- package/packages/opencode/opencode.jsonc +4 -0
- package/packages/subagentic-manual.md +15 -12
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live Canvas — vanilla overlay
|
|
3
|
+
*
|
|
4
|
+
* Framework-free click-to-annotate HUD for UI variant review.
|
|
5
|
+
* Works with any server-rendered page: Node, Python, Ruby, Go, PHP, static HTML.
|
|
6
|
+
*
|
|
7
|
+
* Requirements on the host page:
|
|
8
|
+
* - Variant containers carry `data-variant="A"` (or B/C/D/E/F)
|
|
9
|
+
* - This script loaded once, after the variant DOM exists
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* <script src="/static/overlay-vanilla.js"></script>
|
|
13
|
+
* <script>
|
|
14
|
+
* LiveCanvas.init({
|
|
15
|
+
* target: 'CheckoutSummary', // component/page name
|
|
16
|
+
* channelUrl: 'http://localhost:8788', // optional; omit to force batch
|
|
17
|
+
* batchEndpoint: '/__live_canvas/feedback', // where batch POSTs go
|
|
18
|
+
* });
|
|
19
|
+
* </script>
|
|
20
|
+
*
|
|
21
|
+
* Modes (auto-selected):
|
|
22
|
+
* Live — each Save POSTs to {channelUrl}/feedback, streams into Claude session
|
|
23
|
+
* Batch — saves accumulate locally; Submit All POSTs JSONL to batchEndpoint
|
|
24
|
+
* (or downloads the file if no endpoint configured)
|
|
25
|
+
*
|
|
26
|
+
* Payload schema: v1.0, compatible with the upstream React FeedbackOverlay.
|
|
27
|
+
*/
|
|
28
|
+
(function () {
|
|
29
|
+
'use strict';
|
|
30
|
+
|
|
31
|
+
const PFX = 'lc'; // class prefix
|
|
32
|
+
const DATA_OVERLAY = 'data-lc-overlay'; // excludes overlay elements from picker
|
|
33
|
+
|
|
34
|
+
const state = {
|
|
35
|
+
target: 'Component',
|
|
36
|
+
mode: 'batch', // 'live' | 'batch'
|
|
37
|
+
channelUrl: null,
|
|
38
|
+
batchEndpoint: null,
|
|
39
|
+
active: false,
|
|
40
|
+
picking: false,
|
|
41
|
+
comments: [], // Comment[] — only items pending batch submit
|
|
42
|
+
overall: '',
|
|
43
|
+
pinCounts: Object.create(null), // `${variant}|${selector}` → placed-pin count
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// ---------- utilities ----------
|
|
47
|
+
|
|
48
|
+
const uid = () => `c-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
|
49
|
+
const el = (tag, attrs, children) => {
|
|
50
|
+
const node = document.createElement(tag);
|
|
51
|
+
if (attrs) for (const k in attrs) {
|
|
52
|
+
if (k === 'style') Object.assign(node.style, attrs[k]);
|
|
53
|
+
else if (k === 'on') for (const ev in attrs.on) node.addEventListener(ev, attrs.on[ev]);
|
|
54
|
+
else if (k === 'html') node.innerHTML = attrs[k];
|
|
55
|
+
else node.setAttribute(k, attrs[k]);
|
|
56
|
+
}
|
|
57
|
+
if (children) for (const c of [].concat(children)) {
|
|
58
|
+
if (c != null) node.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
|
|
59
|
+
}
|
|
60
|
+
node.setAttribute(DATA_OVERLAY, '');
|
|
61
|
+
return node;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const isOverlayNode = (n) => !!n.closest(`[${DATA_OVERLAY}]`);
|
|
65
|
+
|
|
66
|
+
const findVariant = (n) => {
|
|
67
|
+
const root = n.closest('[data-variant]');
|
|
68
|
+
return root ? root.getAttribute('data-variant') : null;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// Selector strategy: data-testid > id > tag + class chain (max 3 levels).
|
|
72
|
+
// Skips hashed CSS-in-JS class names (matches /^[a-z]+-[a-z0-9]{5,}$/).
|
|
73
|
+
const hashed = /^[a-z]+-[a-z0-9]{5,}$/i;
|
|
74
|
+
const cleanClasses = (cn) => (cn || '')
|
|
75
|
+
.split(/\s+/).filter(c => c && !hashed.test(c) && !c.startsWith(PFX + '-'));
|
|
76
|
+
|
|
77
|
+
const buildSelector = (n) => {
|
|
78
|
+
if (n.dataset && n.dataset.testid) return `[data-testid='${n.dataset.testid}']`;
|
|
79
|
+
if (n.id) return `#${n.id}`;
|
|
80
|
+
const parts = [];
|
|
81
|
+
let cur = n;
|
|
82
|
+
for (let depth = 0; depth < 3 && cur && cur.tagName; depth++) {
|
|
83
|
+
const classes = cleanClasses(cur.className);
|
|
84
|
+
let part = cur.tagName.toLowerCase();
|
|
85
|
+
if (classes.length) part += '.' + classes.slice(0, 2).join('.');
|
|
86
|
+
parts.unshift(part);
|
|
87
|
+
if (cur.id) { parts[0] = '#' + cur.id; break; }
|
|
88
|
+
cur = cur.parentElement;
|
|
89
|
+
if (cur && cur.hasAttribute('data-variant')) break;
|
|
90
|
+
}
|
|
91
|
+
return parts.join(' > ');
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const buildReadablePath = (n) => {
|
|
95
|
+
const parts = [];
|
|
96
|
+
let cur = n;
|
|
97
|
+
while (cur && cur.tagName && !cur.hasAttribute('data-variant')) {
|
|
98
|
+
parts.unshift(cur.tagName.toLowerCase());
|
|
99
|
+
cur = cur.parentElement;
|
|
100
|
+
}
|
|
101
|
+
return parts.join(' > ');
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const identify = (n) => {
|
|
105
|
+
const attrs = {};
|
|
106
|
+
for (const a of n.attributes || []) {
|
|
107
|
+
if (a.name.startsWith('data-') || a.name.startsWith('aria-') || a.name === 'id') {
|
|
108
|
+
if (a.name !== DATA_OVERLAY) attrs[a.name] = a.value;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
selector: buildSelector(n),
|
|
113
|
+
readablePath: buildReadablePath(n),
|
|
114
|
+
tagName: n.tagName.toLowerCase(),
|
|
115
|
+
textContent: (n.textContent || '').trim().slice(0, 50),
|
|
116
|
+
className: cleanClasses(n.className).join(' '),
|
|
117
|
+
attributes: attrs,
|
|
118
|
+
};
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const coordsWithin = (n, clickX, clickY) => {
|
|
122
|
+
const root = n.closest('[data-variant]');
|
|
123
|
+
if (!root) return { x: 0, y: 0 };
|
|
124
|
+
const r = root.getBoundingClientRect();
|
|
125
|
+
return {
|
|
126
|
+
x: Math.round(((clickX - r.left) / r.width) * 100),
|
|
127
|
+
y: Math.round(((clickY - r.top) / r.height) * 100),
|
|
128
|
+
};
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
// ---------- styles (scoped via prefix) ----------
|
|
132
|
+
|
|
133
|
+
const injectStyles = () => {
|
|
134
|
+
const css = `
|
|
135
|
+
[${DATA_OVERLAY}] { font-family: system-ui, sans-serif; box-sizing: border-box; }
|
|
136
|
+
.${PFX}-bar { position: fixed; right: 16px; bottom: 16px; z-index: 2147483646;
|
|
137
|
+
display: flex; gap: 8px; }
|
|
138
|
+
.${PFX}-btn { border: 0; border-radius: 999px; padding: 10px 16px;
|
|
139
|
+
font-weight: 600; font-size: 13px; cursor: pointer;
|
|
140
|
+
box-shadow: 0 4px 12px rgba(0,0,0,.25); font-family: inherit; }
|
|
141
|
+
.${PFX}-toggle { background: #111; color: #fff; }
|
|
142
|
+
.${PFX}-toggle[data-active='1'] { background: #d946ef; }
|
|
143
|
+
.${PFX}-submit { background: #fff; color: #111; border: 1px solid #e4e4e7; }
|
|
144
|
+
.${PFX}-submit:disabled { opacity: .5; cursor: not-allowed; }
|
|
145
|
+
.${PFX}-submit:not(:disabled):hover { background: #fafafa; }
|
|
146
|
+
.${PFX}-submit .${PFX}-count { display: inline-block; background: #d946ef; color: #fff;
|
|
147
|
+
border-radius: 999px; min-width: 18px; padding: 0 6px; margin-left: 6px;
|
|
148
|
+
font-size: 11px; line-height: 18px; text-align: center; }
|
|
149
|
+
.${PFX}-mode { position: fixed; right: 16px; bottom: 60px; z-index: 2147483646;
|
|
150
|
+
background: rgba(0,0,0,.75); color: #fff; font-size: 11px;
|
|
151
|
+
padding: 4px 8px; border-radius: 4px; pointer-events: none; }
|
|
152
|
+
.${PFX}-picking { cursor: crosshair !important; }
|
|
153
|
+
.${PFX}-hover-outline { outline: 2px solid #d946ef !important; outline-offset: 2px; }
|
|
154
|
+
.${PFX}-pin { position: absolute; z-index: 2147483645; width: 24px; height: 24px;
|
|
155
|
+
border-radius: 50%; background: #d946ef; color: #fff; font-weight: 700;
|
|
156
|
+
font-size: 12px; display: flex; align-items: center; justify-content: center;
|
|
157
|
+
cursor: pointer; box-shadow: 0 2px 6px rgba(0,0,0,.3); transform: translate(-50%,-50%); }
|
|
158
|
+
.${PFX}-popup { position: fixed; z-index: 2147483647; width: 320px;
|
|
159
|
+
background: #fff; border: 1px solid #ddd; border-radius: 8px;
|
|
160
|
+
box-shadow: 0 8px 24px rgba(0,0,0,.18); padding: 12px; }
|
|
161
|
+
.${PFX}-popup h4 { margin: 0 0 4px; font-size: 13px; color: #111; }
|
|
162
|
+
.${PFX}-popup code { font-size: 11px; color: #555; word-break: break-all; display: block; margin-bottom: 8px; }
|
|
163
|
+
.${PFX}-popup textarea { width: 100%; min-height: 72px; font-size: 13px;
|
|
164
|
+
padding: 6px; border: 1px solid #ccc; border-radius: 4px; resize: vertical; }
|
|
165
|
+
.${PFX}-popup .${PFX}-row { display: flex; gap: 6px; justify-content: flex-end; margin-top: 8px; }
|
|
166
|
+
.${PFX}-popup button { padding: 6px 12px; font-size: 12px; border: 0; border-radius: 4px; cursor: pointer; }
|
|
167
|
+
.${PFX}-btn-primary { background: #111; color: #fff; }
|
|
168
|
+
.${PFX}-btn-ghost { background: transparent; color: #555; }
|
|
169
|
+
.${PFX}-submit-modal { position: fixed; inset: 0; z-index: 2147483647;
|
|
170
|
+
background: rgba(0,0,0,.45); display: flex; align-items: center; justify-content: center; }
|
|
171
|
+
.${PFX}-submit-card { width: 480px; max-width: 90vw; background: #fff;
|
|
172
|
+
border-radius: 12px; padding: 20px; }
|
|
173
|
+
.${PFX}-submit-card h3 { margin: 0 0 8px; }
|
|
174
|
+
.${PFX}-submit-card textarea { width: 100%; min-height: 96px;
|
|
175
|
+
padding: 8px; border: 1px solid #ccc; border-radius: 6px; font-size: 13px; }
|
|
176
|
+
.${PFX}-toast { position: fixed; bottom: 70px; right: 16px; z-index: 2147483647;
|
|
177
|
+
background: #111; color: #fff; padding: 8px 12px; border-radius: 6px;
|
|
178
|
+
font-size: 12px; opacity: 0; transition: opacity .2s; pointer-events: none; }
|
|
179
|
+
.${PFX}-toast[data-show='1'] { opacity: 1; }
|
|
180
|
+
`;
|
|
181
|
+
const style = el('style', { html: css });
|
|
182
|
+
document.head.appendChild(style);
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
// ---------- transport ----------
|
|
186
|
+
|
|
187
|
+
const probeChannel = async (url) => {
|
|
188
|
+
try {
|
|
189
|
+
const res = await fetch(url + '/health', { method: 'GET', mode: 'cors' });
|
|
190
|
+
return res.ok;
|
|
191
|
+
} catch { return false; }
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const pushLive = async (comment) => {
|
|
195
|
+
try {
|
|
196
|
+
const res = await fetch(state.channelUrl + '/feedback', {
|
|
197
|
+
method: 'POST',
|
|
198
|
+
headers: { 'content-type': 'application/json' },
|
|
199
|
+
body: JSON.stringify({ version: '1.0', target: state.target, comment }),
|
|
200
|
+
});
|
|
201
|
+
return res.ok;
|
|
202
|
+
} catch { return false; }
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const submitBatch = async () => {
|
|
206
|
+
// Only send comments that haven't already been delivered over the channel.
|
|
207
|
+
// In live mode after successful pushes, this is usually empty — the submit
|
|
208
|
+
// effectively carries only the overall direction.
|
|
209
|
+
const undelivered = state.comments.filter(c => !c.delivered);
|
|
210
|
+
const payload = {
|
|
211
|
+
version: '1.0',
|
|
212
|
+
target: state.target,
|
|
213
|
+
timestamp: new Date().toISOString(),
|
|
214
|
+
comments: undelivered,
|
|
215
|
+
overall: state.overall,
|
|
216
|
+
};
|
|
217
|
+
if (state.batchEndpoint) {
|
|
218
|
+
try {
|
|
219
|
+
const res = await fetch(state.batchEndpoint, {
|
|
220
|
+
method: 'POST',
|
|
221
|
+
headers: { 'content-type': 'application/json' },
|
|
222
|
+
body: JSON.stringify(payload),
|
|
223
|
+
});
|
|
224
|
+
return res.ok;
|
|
225
|
+
} catch { /* fall through to download */ }
|
|
226
|
+
}
|
|
227
|
+
// No endpoint or POST failed: download JSON so user can hand it to the CLI.
|
|
228
|
+
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
|
|
229
|
+
const url = URL.createObjectURL(blob);
|
|
230
|
+
const link = el('a', { href: url, download: 'live-canvas-feedback.json' });
|
|
231
|
+
document.body.appendChild(link); link.click(); document.body.removeChild(link);
|
|
232
|
+
setTimeout(() => URL.revokeObjectURL(url), 100);
|
|
233
|
+
return true;
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
// ---------- UI ----------
|
|
237
|
+
|
|
238
|
+
let toggleBtn, submitBtn, modeBadge, toast, popupNode;
|
|
239
|
+
let hoverEl = null;
|
|
240
|
+
|
|
241
|
+
const pendingCount = () => state.comments.filter(c => !c.delivered).length;
|
|
242
|
+
|
|
243
|
+
const refreshSubmitBtn = () => {
|
|
244
|
+
if (!submitBtn) return;
|
|
245
|
+
const n = pendingCount();
|
|
246
|
+
const label = state.mode === 'live' ? 'Finish' : 'Submit';
|
|
247
|
+
submitBtn.innerHTML = `${label} <span class="${PFX}-count">${n}</span>`;
|
|
248
|
+
submitBtn.setAttribute(DATA_OVERLAY, '');
|
|
249
|
+
// In live mode, the Finish button is always enabled so the user can send
|
|
250
|
+
// an overall direction even after all per-element comments delivered.
|
|
251
|
+
submitBtn.disabled = state.mode === 'batch' && n === 0;
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const showToast = (msg) => {
|
|
255
|
+
toast.textContent = msg;
|
|
256
|
+
toast.setAttribute('data-show', '1');
|
|
257
|
+
setTimeout(() => toast.removeAttribute('data-show'), 1800);
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const setPickingCursor = (on) => {
|
|
261
|
+
document.body.classList.toggle(PFX + '-picking', on);
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const clearHover = () => {
|
|
265
|
+
if (hoverEl) { hoverEl.classList.remove(PFX + '-hover-outline'); hoverEl = null; }
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const onMouseMove = (e) => {
|
|
269
|
+
if (!state.picking) return;
|
|
270
|
+
const n = e.target;
|
|
271
|
+
if (isOverlayNode(n) || !findVariant(n)) { clearHover(); return; }
|
|
272
|
+
if (hoverEl === n) return;
|
|
273
|
+
clearHover();
|
|
274
|
+
hoverEl = n;
|
|
275
|
+
n.classList.add(PFX + '-hover-outline');
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const placePin = (variant, coords, count) => {
|
|
279
|
+
const root = document.querySelector(`[data-variant="${variant}"]`);
|
|
280
|
+
if (!root) return;
|
|
281
|
+
// Pin is absolutely positioned relative to its variant root, which we make
|
|
282
|
+
// position:relative if it isn't already.
|
|
283
|
+
const cs = getComputedStyle(root);
|
|
284
|
+
if (cs.position === 'static') root.style.position = 'relative';
|
|
285
|
+
const pin = el('div', {
|
|
286
|
+
class: `${PFX}-pin`,
|
|
287
|
+
style: { left: coords.x + '%', top: coords.y + '%' },
|
|
288
|
+
on: { click: (ev) => { ev.stopPropagation(); showToast(`${count} comment${count>1?'s':''} on this element`); } },
|
|
289
|
+
}, String(count));
|
|
290
|
+
root.appendChild(pin);
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const closePopup = () => {
|
|
294
|
+
if (popupNode) { popupNode.remove(); popupNode = null; }
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
const openCommentPopup = (target, clickX, clickY) => {
|
|
298
|
+
closePopup();
|
|
299
|
+
const variant = findVariant(target);
|
|
300
|
+
if (!variant) { showToast('Click inside one of the variant cards'); return; }
|
|
301
|
+
const ident = identify(target);
|
|
302
|
+
const coords = coordsWithin(target, clickX, clickY);
|
|
303
|
+
|
|
304
|
+
const textarea = el('textarea', { placeholder: 'What should change here?' });
|
|
305
|
+
const save = el('button', { class: `${PFX}-btn-primary` }, 'Save');
|
|
306
|
+
const cancel = el('button', { class: `${PFX}-btn-ghost` }, 'Cancel');
|
|
307
|
+
popupNode = el('div', {
|
|
308
|
+
class: `${PFX}-popup`,
|
|
309
|
+
style: { left: Math.min(window.innerWidth - 340, clickX + 12) + 'px',
|
|
310
|
+
top: Math.min(window.innerHeight - 220, clickY + 12) + 'px' },
|
|
311
|
+
}, [
|
|
312
|
+
el('h4', {}, `Variant ${variant} — ${ident.tagName}`),
|
|
313
|
+
el('code', {}, ident.selector),
|
|
314
|
+
textarea,
|
|
315
|
+
el('div', { class: `${PFX}-row` }, [cancel, save]),
|
|
316
|
+
]);
|
|
317
|
+
document.body.appendChild(popupNode);
|
|
318
|
+
setTimeout(() => textarea.focus(), 0);
|
|
319
|
+
|
|
320
|
+
cancel.addEventListener('click', closePopup);
|
|
321
|
+
save.addEventListener('click', async () => {
|
|
322
|
+
const text = textarea.value.trim();
|
|
323
|
+
if (!text) { textarea.focus(); return; }
|
|
324
|
+
const comment = {
|
|
325
|
+
id: uid(), variant, element: ident, coordinates: coords,
|
|
326
|
+
text, timestamp: Date.now(),
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
// Pin count is tracked independently of batch state so pins persist
|
|
330
|
+
// visually even when comments have been delivered and removed from batch.
|
|
331
|
+
const pinKey = variant + '|' + ident.selector;
|
|
332
|
+
state.pinCounts[pinKey] = (state.pinCounts[pinKey] || 0) + 1;
|
|
333
|
+
placePin(variant, coords, state.pinCounts[pinKey]);
|
|
334
|
+
closePopup();
|
|
335
|
+
|
|
336
|
+
if (state.mode === 'live') {
|
|
337
|
+
const ok = await pushLive(comment);
|
|
338
|
+
if (ok) {
|
|
339
|
+
// Push landed. Nothing goes into state.comments — the comment is
|
|
340
|
+
// already in Claude's hands. Counter stays at 0.
|
|
341
|
+
showToast('Pushed to Claude ✨');
|
|
342
|
+
} else {
|
|
343
|
+
// Push failed. Keep the comment locally as a pending batch item and
|
|
344
|
+
// degrade the overlay to batch mode for the rest of the session.
|
|
345
|
+
comment.delivered = false;
|
|
346
|
+
state.comments.push(comment);
|
|
347
|
+
state.mode = 'batch';
|
|
348
|
+
refreshSubmitBtn();
|
|
349
|
+
showToast('Channel unreachable — kept locally');
|
|
350
|
+
}
|
|
351
|
+
} else {
|
|
352
|
+
comment.delivered = false;
|
|
353
|
+
state.comments.push(comment);
|
|
354
|
+
refreshSubmitBtn();
|
|
355
|
+
showToast('Saved — submit when ready');
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
const onClick = (e) => {
|
|
361
|
+
if (!state.picking) return;
|
|
362
|
+
if (isOverlayNode(e.target)) return;
|
|
363
|
+
e.preventDefault();
|
|
364
|
+
e.stopPropagation();
|
|
365
|
+
state.picking = false;
|
|
366
|
+
setPickingCursor(false);
|
|
367
|
+
clearHover();
|
|
368
|
+
toggleBtn.setAttribute('data-active', '0');
|
|
369
|
+
toggleBtn.textContent = 'Add Feedback';
|
|
370
|
+
if (!findVariant(e.target)) return;
|
|
371
|
+
openCommentPopup(e.target, e.clientX, e.clientY);
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
const openSubmitModal = () => {
|
|
375
|
+
const pending = pendingCount();
|
|
376
|
+
const isLive = state.mode === 'live';
|
|
377
|
+
// Block only when there's truly nothing to send: no pending comments AND
|
|
378
|
+
// no overall direction so far. In live mode that's the same check.
|
|
379
|
+
if (pending === 0 && !state.overall && state.comments.length === 0) {
|
|
380
|
+
showToast('No feedback yet');
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const heading = isLive
|
|
385
|
+
? (pending > 0 ? `Finish: ${pending} pending + overall direction` : 'Finish: add overall direction')
|
|
386
|
+
: `Submit ${pending} comment${pending === 1 ? '' : 's'}`;
|
|
387
|
+
const modeNote = isLive
|
|
388
|
+
? (pending > 0
|
|
389
|
+
? `${state.comments.length - pending} comment(s) already streamed; ${pending} pending will go with submit.`
|
|
390
|
+
: `All comments already streamed. Submit sends your overall direction as the wrap-up.`)
|
|
391
|
+
: `Will POST JSONL (or download if no endpoint).`;
|
|
392
|
+
|
|
393
|
+
const ta = el('textarea', { placeholder: 'Overall direction (e.g. "Go with B\'s layout, A\'s button styling")' }, state.overall);
|
|
394
|
+
const submit = el('button', { class: `${PFX}-btn-primary` }, isLive ? 'Finish' : 'Submit All');
|
|
395
|
+
const cancel = el('button', { class: `${PFX}-btn-ghost` }, 'Cancel');
|
|
396
|
+
const card = el('div', { class: `${PFX}-submit-card` }, [
|
|
397
|
+
el('h3', {}, heading),
|
|
398
|
+
el('p', { style: { margin: '0 0 8px', fontSize: '12px', color: '#666' } },
|
|
399
|
+
`Mode: ${state.mode.toUpperCase()} — ${modeNote}`),
|
|
400
|
+
ta,
|
|
401
|
+
el('div', { class: `${PFX}-row`, style: { marginTop: '12px' } }, [cancel, submit]),
|
|
402
|
+
]);
|
|
403
|
+
const modal = el('div', { class: `${PFX}-submit-modal`, on: {
|
|
404
|
+
click: (e) => { if (e.target === modal) modal.remove(); },
|
|
405
|
+
}}, card);
|
|
406
|
+
document.body.appendChild(modal);
|
|
407
|
+
setTimeout(() => ta.focus(), 0);
|
|
408
|
+
cancel.addEventListener('click', () => modal.remove());
|
|
409
|
+
submit.addEventListener('click', async () => {
|
|
410
|
+
state.overall = ta.value.trim();
|
|
411
|
+
if (!state.overall) { ta.focus(); return; }
|
|
412
|
+
const ok = await submitBatch();
|
|
413
|
+
modal.remove();
|
|
414
|
+
if (ok) {
|
|
415
|
+
// Mark any undelivered comments as delivered so they don't re-send on a
|
|
416
|
+
// later Finish/Submit. Keep pins as visual history.
|
|
417
|
+
state.comments.forEach(c => { c.delivered = true; });
|
|
418
|
+
refreshSubmitBtn();
|
|
419
|
+
}
|
|
420
|
+
showToast(ok ? (isLive ? 'Finished ✓' : 'Submitted ✓') : 'Submit failed');
|
|
421
|
+
});
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
const onToggleClick = () => {
|
|
425
|
+
// Clicking while picking cancels pick; clicking while idle enters pick OR opens submit on long-press — keep simple: left-click picks, right-click submits.
|
|
426
|
+
if (state.picking) {
|
|
427
|
+
state.picking = false; setPickingCursor(false); clearHover();
|
|
428
|
+
toggleBtn.setAttribute('data-active', '0'); toggleBtn.textContent = 'Add Feedback';
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
state.picking = true;
|
|
432
|
+
setPickingCursor(true);
|
|
433
|
+
toggleBtn.setAttribute('data-active', '1');
|
|
434
|
+
toggleBtn.textContent = 'Cancel pick';
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
const buildUI = () => {
|
|
438
|
+
injectStyles();
|
|
439
|
+
toggleBtn = el('button', { class: `${PFX}-btn ${PFX}-toggle`, on: {
|
|
440
|
+
click: onToggleClick,
|
|
441
|
+
}}, 'Add Feedback');
|
|
442
|
+
submitBtn = el('button', { class: `${PFX}-btn ${PFX}-submit`, on: {
|
|
443
|
+
click: openSubmitModal,
|
|
444
|
+
}});
|
|
445
|
+
refreshSubmitBtn();
|
|
446
|
+
const bar = el('div', { class: `${PFX}-bar` }, [submitBtn, toggleBtn]);
|
|
447
|
+
modeBadge = el('div', { class: `${PFX}-mode` }, `${state.mode === 'live' ? 'LIVE' : 'BATCH'} mode`);
|
|
448
|
+
toast = el('div', { class: `${PFX}-toast` });
|
|
449
|
+
document.body.appendChild(bar);
|
|
450
|
+
document.body.appendChild(modeBadge);
|
|
451
|
+
document.body.appendChild(toast);
|
|
452
|
+
|
|
453
|
+
document.addEventListener('mousemove', onMouseMove, true);
|
|
454
|
+
document.addEventListener('click', onClick, true);
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
// ---------- public API ----------
|
|
458
|
+
|
|
459
|
+
const LiveCanvas = {
|
|
460
|
+
async init(opts) {
|
|
461
|
+
opts = opts || {};
|
|
462
|
+
state.target = opts.target || 'Component';
|
|
463
|
+
state.channelUrl = opts.channelUrl || null;
|
|
464
|
+
state.batchEndpoint = opts.batchEndpoint || null;
|
|
465
|
+
|
|
466
|
+
if (state.channelUrl && await probeChannel(state.channelUrl)) {
|
|
467
|
+
state.mode = 'live';
|
|
468
|
+
} else {
|
|
469
|
+
state.mode = 'batch';
|
|
470
|
+
}
|
|
471
|
+
buildUI();
|
|
472
|
+
},
|
|
473
|
+
getState: () => ({ ...state, comments: [...state.comments] }),
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
window.LiveCanvas = LiveCanvas;
|
|
477
|
+
})();
|