@yagejs-tools/feedback 0.0.0 → 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/dist/index.js ADDED
@@ -0,0 +1,1057 @@
1
+ // src/shared/shortcuts.ts
2
+ var shortcutActions = [
3
+ "feedback",
4
+ "freeze",
5
+ "stepFrame",
6
+ "stepTenFrames"
7
+ ];
8
+ function resolveShortcuts(options = true) {
9
+ if (typeof options !== "boolean" && (!options || typeof options !== "object" || Array.isArray(options)))
10
+ throw new Error(
11
+ "Feedback shortcuts must be a boolean or a bindings object."
12
+ );
13
+ if (typeof options === "object") {
14
+ for (const key of Object.keys(options))
15
+ if (!shortcutActions.some((action) => action === key))
16
+ throw new Error(`Unknown feedback shortcut action: ${key}.`);
17
+ }
18
+ const resolve = (action, code, shift = false) => {
19
+ const configured = typeof options === "object" ? options[action] : void 0;
20
+ const binding = options === false ? false : configured === void 0 ? { code, shift } : configured;
21
+ if (binding === false) return false;
22
+ if (!binding || typeof binding !== "object" || typeof binding.code !== "string" || !/^[A-Za-z][A-Za-z0-9]*$/.test(binding.code) || /^(?:Shift|Control|Alt|Meta)(?:Left|Right)$/.test(binding.code))
23
+ throw new Error(
24
+ `Feedback shortcuts.${action}: provide a non-modifier KeyboardEvent.code.`
25
+ );
26
+ for (const key of Object.keys(binding))
27
+ if (!["code", "ctrl", "alt", "shift", "meta"].includes(key))
28
+ throw new Error(`Feedback shortcuts.${action}: unknown option ${key}.`);
29
+ for (const modifier of ["ctrl", "alt", "shift", "meta"])
30
+ if (binding[modifier] !== void 0 && typeof binding[modifier] !== "boolean")
31
+ throw new Error(
32
+ `Feedback shortcuts.${action}.${modifier} must be a boolean.`
33
+ );
34
+ return {
35
+ code: binding.code,
36
+ ctrl: binding.ctrl ?? false,
37
+ alt: binding.alt ?? false,
38
+ shift: binding.shift ?? false,
39
+ meta: binding.meta ?? false
40
+ };
41
+ };
42
+ const result = {
43
+ feedback: resolve("feedback", "F8"),
44
+ freeze: resolve("freeze", "F9"),
45
+ stepFrame: resolve("stepFrame", "F10"),
46
+ stepTenFrames: resolve("stepTenFrames", "F10", true)
47
+ };
48
+ const used = /* @__PURE__ */ new Set();
49
+ for (const action of shortcutActions) {
50
+ const binding = result[action];
51
+ if (!binding) continue;
52
+ const identity = JSON.stringify(binding);
53
+ if (used.has(identity))
54
+ throw new Error("Feedback shortcuts must use different bindings.");
55
+ used.add(identity);
56
+ }
57
+ return result;
58
+ }
59
+ function shortcutLabel(binding) {
60
+ return binding ? [
61
+ binding.ctrl && "Ctrl",
62
+ binding.alt && "Alt",
63
+ binding.shift && "Shift",
64
+ binding.meta && "Meta",
65
+ binding.code.replace(/^(Key|Digit)(?=.)/, "")
66
+ ].filter(Boolean).join("+") : "";
67
+ }
68
+
69
+ // src/client/FeedbackClient.ts
70
+ var FeedbackClient = class {
71
+ constructor(server = "http://127.0.0.1:5212", timeoutMs = 3e4) {
72
+ this.timeoutMs = timeoutMs;
73
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 3e5)
74
+ throw new Error(
75
+ "Feedback request timeout must be an integer from 1 to 300000 ms."
76
+ );
77
+ this.base = new URL(server);
78
+ this.base.pathname = this.base.pathname.replace(/\/?$/, "/");
79
+ if (this.base.protocol !== "http:" || !["localhost", "127.0.0.1", "[::1]"].includes(this.base.hostname) || this.base.username || this.base.password || this.base.search || this.base.hash)
80
+ throw new Error(
81
+ "Feedback server must be a loopback HTTP URL without credentials, query, or fragment."
82
+ );
83
+ }
84
+ timeoutMs;
85
+ base;
86
+ async request(route, body, signal) {
87
+ const timeout = AbortSignal.timeout(this.timeoutMs);
88
+ const response = await fetch(new URL(route.replace(/^\//, ""), this.base), {
89
+ ...body !== void 0 ? {
90
+ method: "POST",
91
+ headers: { "Content-Type": "application/json" },
92
+ body: JSON.stringify(body)
93
+ } : {},
94
+ signal: signal ? AbortSignal.any([signal, timeout]) : timeout
95
+ });
96
+ if (!response.ok)
97
+ throw new Error(
98
+ `Feedback server returned ${response.status}: ${await response.text()}`
99
+ );
100
+ return await response.json();
101
+ }
102
+ get galleryUrl() {
103
+ return new URL("gallery/", this.base).href;
104
+ }
105
+ session() {
106
+ return this.request("session");
107
+ }
108
+ list(status) {
109
+ return this.request(`/comments${status ? `?status=${status}` : ""}`);
110
+ }
111
+ show(id) {
112
+ return this.request(`/comments/${encodeURIComponent(id)}`);
113
+ }
114
+ save(upload, signal) {
115
+ return this.request("/comments", upload, signal);
116
+ }
117
+ transition(id, request) {
118
+ return this.request(
119
+ `/comments/${encodeURIComponent(id)}/transitions`,
120
+ request
121
+ );
122
+ }
123
+ };
124
+
125
+ // src/browser/session/FeedbackSession.ts
126
+ var FeedbackSession = class {
127
+ constructor(host, client) {
128
+ this.host = host;
129
+ this.client = client;
130
+ }
131
+ host;
132
+ client;
133
+ observation;
134
+ abort;
135
+ draft;
136
+ saved = [];
137
+ get pending() {
138
+ return this.draft !== void 0;
139
+ }
140
+ get draftComment() {
141
+ return this.draft?.comment;
142
+ }
143
+ get comments() {
144
+ return this.saved;
145
+ }
146
+ open() {
147
+ if (this.observation) return this.observation;
148
+ if (this.draft) {
149
+ this.host.freeze();
150
+ this.observation = {
151
+ capture: this.draft.capture,
152
+ image: this.draft.image
153
+ };
154
+ } else this.observation = this.host.capture();
155
+ return this.observation;
156
+ }
157
+ async save(text, target) {
158
+ if (!this.observation) throw new Error("Open feedback before saving.");
159
+ if (this.abort) return false;
160
+ if (!this.draft) {
161
+ if (!text.trim() || text.trim().length > 1e4)
162
+ throw new Error("Write a comment of 1\u201310000 characters.");
163
+ this.draft = {
164
+ ...this.observation,
165
+ comment: {
166
+ id: crypto.randomUUID(),
167
+ captureId: this.observation.capture.id,
168
+ created: (/* @__PURE__ */ new Date()).toISOString(),
169
+ text: text.trim(),
170
+ target: structuredClone(target),
171
+ status: "open"
172
+ }
173
+ };
174
+ }
175
+ const abort = new AbortController();
176
+ this.abort = abort;
177
+ try {
178
+ const detail = await this.client.save(this.draft, abort.signal);
179
+ if (this.abort !== abort) return false;
180
+ this.saved.push(detail.comment);
181
+ this.draft = void 0;
182
+ return true;
183
+ } catch (error) {
184
+ if (this.abort !== abort) return false;
185
+ throw error;
186
+ } finally {
187
+ if (this.abort === abort) this.abort = void 0;
188
+ }
189
+ }
190
+ discard() {
191
+ if (this.abort)
192
+ throw new Error("Wait for the current save before discarding.");
193
+ this.draft = void 0;
194
+ }
195
+ canStep() {
196
+ return !this.observation && this.host.canStep();
197
+ }
198
+ step(frames) {
199
+ if (this.observation)
200
+ throw new Error("Return to the game view before stepping.");
201
+ this.host.step(frames);
202
+ }
203
+ toggleFreeze() {
204
+ return this.host.toggleFreeze();
205
+ }
206
+ cancelSave() {
207
+ this.abort?.abort();
208
+ this.abort = void 0;
209
+ }
210
+ close() {
211
+ this.cancelSave();
212
+ this.observation = void 0;
213
+ this.host.release();
214
+ }
215
+ };
216
+
217
+ // src/browser/runtime/RuntimeFeedbackHost.ts
218
+ import {
219
+ EngineKey,
220
+ ErrorBoundaryKey,
221
+ InspectorKey as InspectorKey2,
222
+ ServiceKey
223
+ } from "@yagejs/core";
224
+
225
+ // src/browser/runtime/capture.ts
226
+ import { InspectorKey } from "@yagejs/core";
227
+ import { RendererKey, VisualComponent } from "@yagejs/renderer";
228
+
229
+ // src/shared/protocol.ts
230
+ function copyJson(value) {
231
+ const seen = /* @__PURE__ */ new Set();
232
+ function visit(item) {
233
+ if (item === null || typeof item === "string" || typeof item === "boolean")
234
+ return item;
235
+ if (typeof item === "number" && Number.isFinite(item)) return item;
236
+ if (typeof item !== "object" || seen.has(item))
237
+ throw new Error(
238
+ "Feedback context must contain finite, acyclic JSON values."
239
+ );
240
+ seen.add(item);
241
+ let result;
242
+ if (Array.isArray(item)) result = item.map(visit);
243
+ else {
244
+ if (Object.getPrototypeOf(item) !== Object.prototype && Object.getPrototypeOf(item) !== null)
245
+ throw new Error("Feedback context must contain plain JSON objects.");
246
+ result = Object.fromEntries(
247
+ Object.entries(item).map(([key, entry]) => [key, visit(entry)])
248
+ );
249
+ }
250
+ seen.delete(item);
251
+ return result;
252
+ }
253
+ return visit(value);
254
+ }
255
+
256
+ // src/browser/runtime/capture.ts
257
+ function captureView(engine, sessionId, context) {
258
+ const renderer = engine.context.resolve(RendererKey);
259
+ const inspector = engine.context.resolve(InspectorKey);
260
+ const debug = inspector.getExtension("debug");
261
+ const wasHudVisible = debug?.isHudVisible() ?? false;
262
+ const { canvas } = renderer;
263
+ let image;
264
+ try {
265
+ if (wasHudVisible) debug?.setHudVisible(false);
266
+ renderer.application.render();
267
+ image = canvas.toDataURL("image/png");
268
+ } finally {
269
+ if (wasHudVisible) debug?.setHudVisible(true);
270
+ }
271
+ const snapshot = inspector.snapshot();
272
+ const entities = captureEntities(engine, snapshot);
273
+ return {
274
+ image,
275
+ capture: {
276
+ version: 1,
277
+ id: crypto.randomUUID(),
278
+ sessionId,
279
+ created: (/* @__PURE__ */ new Date()).toISOString(),
280
+ frame: snapshot.frame,
281
+ url: location.href,
282
+ width: canvas.width,
283
+ height: canvas.height,
284
+ context,
285
+ snapshot: copyJson(JSON.parse(JSON.stringify(snapshot))),
286
+ entities
287
+ }
288
+ };
289
+ }
290
+ function captureEntities(engine, snapshot) {
291
+ const renderer = engine.context.resolve(RendererKey);
292
+ const { width, height } = renderer.canvas;
293
+ const screen = renderer.application.renderer.screen;
294
+ const result = [];
295
+ for (const scene of engine.scenes.all) {
296
+ for (const entity of scene.getEntities()) {
297
+ const visualBounds = [];
298
+ for (const component of entity.getAll(VisualComponent)) {
299
+ const display = component.renderObject;
300
+ let visible = true;
301
+ let parent = display;
302
+ while (true) {
303
+ if (!parent.visible || !parent.renderable || parent.alpha <= 0)
304
+ visible = false;
305
+ if (!parent.parent) break;
306
+ parent = parent.parent;
307
+ }
308
+ if (!visible || parent !== renderer.application.stage) continue;
309
+ const bounds = display.getBounds();
310
+ const x2 = Math.max(0, bounds.x * width / screen.width);
311
+ const y2 = Math.max(0, bounds.y * height / screen.height);
312
+ const right2 = Math.min(
313
+ width,
314
+ (bounds.x + bounds.width) * width / screen.width
315
+ );
316
+ const bottom2 = Math.min(
317
+ height,
318
+ (bounds.y + bounds.height) * height / screen.height
319
+ );
320
+ if ([x2, y2, right2, bottom2].every(Number.isFinite) && right2 > x2 && bottom2 > y2)
321
+ visualBounds.push({ x: x2, y: y2, width: right2 - x2, height: bottom2 - y2 });
322
+ }
323
+ if (visualBounds.length === 0) continue;
324
+ const x = Math.min(...visualBounds.map((box) => box.x));
325
+ const y = Math.min(...visualBounds.map((box) => box.y));
326
+ const right = Math.max(...visualBounds.map((box) => box.x + box.width));
327
+ const bottom = Math.max(...visualBounds.map((box) => box.y + box.height));
328
+ const capturedScene = snapshot.scenes.find(
329
+ (candidate) => candidate.entities.some((one) => one.id === String(entity.id))
330
+ );
331
+ const captured = capturedScene?.entities.find(
332
+ (one) => one.id === String(entity.id)
333
+ );
334
+ if (captured && capturedScene)
335
+ result.push({
336
+ sceneId: capturedScene.id,
337
+ id: captured.id,
338
+ generation: captured.generation,
339
+ name: entity.name,
340
+ bounds: { x, y, width: right - x, height: bottom - y }
341
+ });
342
+ }
343
+ }
344
+ return result;
345
+ }
346
+
347
+ // src/browser/runtime/RuntimeFeedbackHost.ts
348
+ var InputKey = new ServiceKey("inputManager");
349
+ var RuntimeFeedbackHost = class {
350
+ constructor(context, metadata) {
351
+ this.context = context;
352
+ this.metadata = metadata;
353
+ this.engine = context.resolve(EngineKey);
354
+ }
355
+ context;
356
+ metadata;
357
+ engine;
358
+ lease;
359
+ wasFrozen = false;
360
+ sessionId = crypto.randomUUID();
361
+ /** Resolved on use: the Inspector is installed by DebugPlugin at start. */
362
+ get inspector() {
363
+ return this.context.resolve(InspectorKey2);
364
+ }
365
+ capture() {
366
+ if (this.inspector.time.isOwned())
367
+ throw new Error(
368
+ "Pause the lab or other clock owner, then click here to leave feedback."
369
+ );
370
+ let metadata = {};
371
+ const callback = this.metadata;
372
+ if (callback)
373
+ this.context.resolve(ErrorBoundaryKey).wrapCallback(
374
+ () => {
375
+ metadata = copyJson(callback());
376
+ },
377
+ { kind: "Feedback context" }
378
+ );
379
+ this.freeze();
380
+ try {
381
+ const observation = captureView(this.engine, this.sessionId, metadata);
382
+ this.context.tryResolve(InputKey)?.clearAll();
383
+ return observation;
384
+ } catch (error) {
385
+ this.release();
386
+ throw error;
387
+ }
388
+ }
389
+ freeze() {
390
+ this.lease = this.inspector.time.acquire();
391
+ this.wasFrozen = this.lease.isFrozen();
392
+ this.lease.freeze();
393
+ this.context.tryResolve(InputKey)?.clearAll();
394
+ }
395
+ toggleFreeze() {
396
+ const time = this.inspector.time;
397
+ if (time.isOwned())
398
+ throw new Error(
399
+ "Pause the current clock owner before changing freeze state."
400
+ );
401
+ const lease = time.acquire();
402
+ if (lease.isFrozen()) lease.thaw();
403
+ else lease.freeze();
404
+ const frozen = lease.isFrozen();
405
+ this.context.tryResolve(InputKey)?.clearAll();
406
+ lease.release();
407
+ return frozen;
408
+ }
409
+ canStep() {
410
+ const time = this.inspector.time;
411
+ return time.isFrozen() && !time.isOwned();
412
+ }
413
+ step(frames) {
414
+ if (frames !== 1 && frames !== 10)
415
+ throw new Error(
416
+ `RuntimeFeedbackHost.step: expected 1 or 10 frames, got ${frames}.`
417
+ );
418
+ if (!this.canStep())
419
+ throw new Error(
420
+ "Freeze the game and pause any other clock owner before stepping."
421
+ );
422
+ const lease = this.inspector.time.acquire();
423
+ try {
424
+ this.context.tryResolve(InputKey)?.clearAll();
425
+ lease.step(frames);
426
+ } finally {
427
+ lease.release();
428
+ }
429
+ }
430
+ release() {
431
+ if (!this.lease) return;
432
+ this.context.tryResolve(InputKey)?.clearAll();
433
+ if (!this.wasFrozen) this.lease.thaw();
434
+ this.lease.release();
435
+ this.lease = void 0;
436
+ }
437
+ };
438
+
439
+ // src/browser/ui/FeedbackLauncher.ts
440
+ var FeedbackLauncher = class {
441
+ constructor(actions, shortcuts, galleryUrl) {
442
+ this.actions = actions;
443
+ this.shortcuts = shortcuts;
444
+ this.element.className = "yage-feedback-launch";
445
+ this.element.setAttribute("aria-label", "Feedback controls");
446
+ this.feedback.textContent = "Leave feedback";
447
+ const feedbackKey = shortcutLabel(shortcuts.feedback);
448
+ this.feedback.title = feedbackKey ? `Leave feedback (${feedbackKey})` : "Leave feedback";
449
+ this.freeze.textContent = "Freeze / resume";
450
+ const freezeKey = shortcutLabel(shortcuts.freeze);
451
+ this.freeze.title = freezeKey ? `Freeze / resume (${freezeKey})` : "Freeze / resume";
452
+ this.feedback.onclick = () => this.actions.open();
453
+ this.freeze.onclick = () => this.toggle();
454
+ this.stepFrame.textContent = "+1 frame";
455
+ this.stepTenFrames.textContent = "+10 frames";
456
+ for (const [button, binding] of [
457
+ [this.stepFrame, shortcuts.stepFrame],
458
+ [this.stepTenFrames, shortcuts.stepTenFrames]
459
+ ]) {
460
+ const label = shortcutLabel(binding);
461
+ button.title = label ? `${button.textContent} (${label})` : button.textContent ?? "";
462
+ }
463
+ this.stepFrame.onclick = () => this.step(1);
464
+ this.stepTenFrames.onclick = () => this.step(10);
465
+ this.element.append(
466
+ this.feedback,
467
+ this.freeze,
468
+ this.stepFrame,
469
+ this.stepTenFrames
470
+ );
471
+ if (galleryUrl) {
472
+ const gallery = document.createElement("button");
473
+ gallery.textContent = "Feedback gallery";
474
+ gallery.onclick = () => {
475
+ window.open(galleryUrl, "_blank", "noopener,noreferrer");
476
+ };
477
+ this.element.append(gallery);
478
+ }
479
+ for (const type of [
480
+ "keydown",
481
+ "keyup",
482
+ "pointerdown",
483
+ "pointerup",
484
+ "click"
485
+ ])
486
+ this.element.addEventListener(type, (event) => event.stopPropagation());
487
+ window.addEventListener("keydown", this.keyboard, true);
488
+ window.addEventListener("keyup", this.keyboard, true);
489
+ window.addEventListener("blur", this.blur);
490
+ document.body.append(this.element);
491
+ this.sync();
492
+ this.timer = window.setInterval(this.sync, 200);
493
+ }
494
+ actions;
495
+ shortcuts;
496
+ element = document.createElement("nav");
497
+ feedback = document.createElement("button");
498
+ freeze = document.createElement("button");
499
+ stepFrame = document.createElement("button");
500
+ stepTenFrames = document.createElement("button");
501
+ timer;
502
+ sync = () => {
503
+ const disabled = !!document.querySelector("dialog[open]") || !this.actions.canStep();
504
+ this.stepFrame.disabled = disabled;
505
+ this.stepTenFrames.disabled = disabled;
506
+ };
507
+ consumed = /* @__PURE__ */ new Set();
508
+ blur = () => {
509
+ this.consumed.clear();
510
+ };
511
+ keyboard = (event) => {
512
+ if (this.consumed.has(event.code)) {
513
+ event.preventDefault();
514
+ event.stopImmediatePropagation();
515
+ if (event.type === "keyup") this.consumed.delete(event.code);
516
+ return;
517
+ }
518
+ if (event.type !== "keydown" || event.repeat || event.isComposing || document.querySelector("dialog[open]") || event.target instanceof HTMLElement && (event.target.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(event.target.tagName)))
519
+ return;
520
+ const action = shortcutActions.find((key) => {
521
+ const binding = this.shortcuts[key];
522
+ return binding && event.code === binding.code && event.ctrlKey === binding.ctrl && event.altKey === binding.alt && event.shiftKey === binding.shift && event.metaKey === binding.meta;
523
+ });
524
+ if (!action) return;
525
+ this.consumed.add(event.code);
526
+ event.preventDefault();
527
+ event.stopImmediatePropagation();
528
+ if (action === "feedback") this.actions.open();
529
+ else if (action === "freeze") this.toggle();
530
+ else if (this.actions.canStep()) this.step(action === "stepFrame" ? 1 : 10);
531
+ };
532
+ toggle() {
533
+ try {
534
+ this.freeze.setAttribute(
535
+ "aria-pressed",
536
+ String(this.actions.toggleFreeze())
537
+ );
538
+ this.reset();
539
+ } catch (error) {
540
+ this.error(error);
541
+ }
542
+ }
543
+ step(frames) {
544
+ try {
545
+ if (document.querySelector("dialog[open]")) return;
546
+ this.actions.step(frames);
547
+ this.reset();
548
+ } catch (error) {
549
+ this.error(error);
550
+ }
551
+ this.sync();
552
+ }
553
+ error(error) {
554
+ this.feedback.textContent = error instanceof Error ? error.message : String(error);
555
+ }
556
+ reset() {
557
+ this.feedback.textContent = "Leave feedback";
558
+ this.sync();
559
+ }
560
+ focus() {
561
+ this.feedback.focus();
562
+ }
563
+ destroy() {
564
+ window.removeEventListener("keydown", this.keyboard, true);
565
+ window.removeEventListener("keyup", this.keyboard, true);
566
+ window.removeEventListener("blur", this.blur);
567
+ this.consumed.clear();
568
+ window.clearInterval(this.timer);
569
+ this.element.remove();
570
+ }
571
+ };
572
+
573
+ // src/browser/ui/styles.ts
574
+ var CSS = `
575
+ .yage-feedback-launch {position:fixed;right:18px;bottom:18px;z-index:10000;display:flex;flex-wrap:wrap;justify-content:flex-end;max-width:calc(100vw - 36px);gap:6px;opacity:.4;transition:opacity .15s}
576
+ .yage-feedback-launch:hover,.yage-feedback-launch:focus-within {opacity:1}
577
+ .yage-feedback-launch button {padding:8px 12px;border:1px solid #87a8ff;border-radius:8px;background:#203665;color:white;font:14px system-ui;cursor:pointer}
578
+ @media(hover:none){.yage-feedback-launch{opacity:1}}
579
+ .yage-feedback {box-sizing:border-box;width:min(1120px,96vw);max-height:94vh;padding:20px;border:1px solid #536789;border-radius:14px;background:#111c30;color:#edf3ff;font:14px/1.5 system-ui;overflow:auto}
580
+ .yage-feedback::backdrop {background:#030813cc}
581
+ .yage-feedback * {box-sizing:border-box}
582
+ .yage-feedback h2 {margin:0;font-size:22px}.yage-feedback p {color:#bac9e2;margin:6px 0 14px}
583
+ .yage-feedback button,.yage-feedback select,.yage-feedback textarea {font:inherit;border-radius:6px;border:1px solid #536789;background:#20304b;color:#edf3ff;padding:8px}
584
+ .yage-feedback button {cursor:pointer}.yage-feedback button:disabled {opacity:.5;cursor:default}
585
+ .yage-feedback header,.yage-feedback nav {display:flex;gap:10px;align-items:center;justify-content:space-between;margin-bottom:12px}
586
+ .yage-feedback .layout {display:grid;grid-template-columns:minmax(0,1fr) 300px;gap:20px}
587
+ .yage-feedback figure {position:relative;margin:0;align-self:start;line-height:0;touch-action:none;user-select:none;background:#000}
588
+ .yage-feedback img {display:block;width:100%;height:auto;pointer-events:none}
589
+ .yage-feedback .outlines {position:absolute;inset:0;pointer-events:none}
590
+ .yage-feedback .outline {position:absolute;border:2px solid #a4c4ff;background:#78a4ff22}
591
+ .yage-feedback textarea {width:100%;min-height:100px;resize:vertical}.yage-feedback .targets {margin:8px 0}.yage-feedback label {display:block;padding:3px 0}
592
+ .yage-feedback .entity-picker {border:1px solid #536789;border-radius:6px;background:#20304b}
593
+ .yage-feedback .entity-picker summary {padding:8px;cursor:pointer}
594
+ .yage-feedback .entity-picker fieldset {border:0;margin:0;padding:8px;min-width:0}
595
+ .yage-feedback .entity-picker input[type="search"] {width:100%;padding:8px;font:inherit;color:#edf3ff;background:#111c30;border:1px solid #536789;border-radius:4px;margin-bottom:6px}
596
+ .yage-feedback .entity-results {max-height:180px;overflow:auto;overflow-wrap:anywhere}
597
+ .yage-feedback .entity-paging {display:flex;gap:8px;margin:8px 0}
598
+ .yage-feedback .entity-selection {color:#bac9e2;font-size:12px;margin-top:6px;overflow-wrap:anywhere}
599
+ .yage-feedback .status {min-height:24px;color:#b5d4ff;white-space:pre-wrap}.yage-feedback .history {max-height:150px;overflow:auto;padding-left:20px}
600
+ @media(max-width:760px){.yage-feedback .layout{grid-template-columns:1fr}}
601
+ `;
602
+
603
+ // src/browser/ui/EntityPicker.ts
604
+ var PAGE_SIZE = 50;
605
+ var EntityPicker = class {
606
+ constructor(root, entities, selected, change) {
607
+ this.entities = entities;
608
+ this.selected = selected;
609
+ this.change = change;
610
+ this.matches = [...entities];
611
+ this.details.className = "entity-picker";
612
+ this.search.type = "search";
613
+ this.search.placeholder = "Filter by name, ID, or scene";
614
+ this.search.setAttribute("aria-label", "Filter entities");
615
+ this.results.className = "entity-results";
616
+ this.selection.className = "entity-selection";
617
+ this.count.setAttribute("aria-live", "polite");
618
+ this.previous.textContent = "Previous";
619
+ this.next.textContent = "Next";
620
+ this.clear.textContent = "Clear selection";
621
+ for (const button of [this.previous, this.next, this.clear])
622
+ button.type = "button";
623
+ const paging = document.createElement("div");
624
+ paging.className = "entity-paging";
625
+ paging.append(this.previous, this.next);
626
+ this.fields.append(
627
+ this.search,
628
+ this.count,
629
+ this.results,
630
+ paging,
631
+ this.clear
632
+ );
633
+ this.details.append(this.summary, this.fields);
634
+ root.append(this.details, this.selection);
635
+ this.summary.onclick = (event) => {
636
+ if (this.disabled) event.preventDefault();
637
+ };
638
+ this.details.ontoggle = () => {
639
+ if (this.details.open) this.search.focus();
640
+ };
641
+ this.search.oninput = () => {
642
+ const terms = this.search.value.toLocaleLowerCase().trim().split(/\s+/).filter(Boolean);
643
+ this.matches = this.entities.filter((entity) => {
644
+ const label = `${entity.name} ${entity.id} ${entity.sceneId}`.toLocaleLowerCase();
645
+ return terms.every((term) => label.includes(term));
646
+ });
647
+ this.page = 0;
648
+ this.renderResults();
649
+ };
650
+ this.search.onkeydown = (event) => {
651
+ if (event.key === "Escape") {
652
+ event.preventDefault();
653
+ event.stopPropagation();
654
+ this.details.open = false;
655
+ this.summary.focus();
656
+ }
657
+ };
658
+ this.previous.onclick = () => {
659
+ this.page--;
660
+ this.renderResults();
661
+ };
662
+ this.next.onclick = () => {
663
+ this.page++;
664
+ this.renderResults();
665
+ };
666
+ this.clear.onclick = () => this.change([]);
667
+ this.renderResults();
668
+ }
669
+ entities;
670
+ selected;
671
+ change;
672
+ details = document.createElement("details");
673
+ summary = document.createElement("summary");
674
+ search = document.createElement("input");
675
+ results = document.createElement("div");
676
+ count = document.createElement("span");
677
+ previous = document.createElement("button");
678
+ next = document.createElement("button");
679
+ clear = document.createElement("button");
680
+ selection = document.createElement("div");
681
+ fields = document.createElement("fieldset");
682
+ page = 0;
683
+ disabled = false;
684
+ matches;
685
+ refresh() {
686
+ const selected = this.selected();
687
+ this.summary.textContent = selected.length ? `${selected.length} entities selected` : "Select entities\u2026";
688
+ this.selection.textContent = selected.length ? selected.slice(0, 3).map((entity) => `${entity.name || "Entity"} (${entity.id})`).join(", ") + (selected.length > 3 ? ` +${selected.length - 3} more` : "") : "No entities selected";
689
+ this.clear.disabled = selected.length === 0;
690
+ const ids = new Set(
691
+ selected.map((entity) => `${entity.sceneId}:${entity.id}`)
692
+ );
693
+ for (const input of this.results.querySelectorAll(
694
+ "input"
695
+ ))
696
+ input.checked = ids.has(input.value);
697
+ }
698
+ setDisabled(disabled) {
699
+ this.disabled = disabled;
700
+ this.fields.disabled = disabled;
701
+ this.summary.setAttribute("aria-disabled", String(disabled));
702
+ if (disabled) this.details.open = false;
703
+ }
704
+ renderResults() {
705
+ this.results.replaceChildren();
706
+ const start = this.page * PAGE_SIZE;
707
+ const page = this.matches.slice(start, start + PAGE_SIZE);
708
+ this.count.textContent = page.length ? `${start + 1}\u2013${start + page.length} of ${this.matches.length} matches` : "No matching entities";
709
+ this.previous.disabled = this.page === 0;
710
+ this.next.disabled = start + PAGE_SIZE >= this.matches.length;
711
+ for (const entity of page) {
712
+ const label = document.createElement("label");
713
+ const input = document.createElement("input");
714
+ input.type = "checkbox";
715
+ input.value = `${entity.sceneId}:${entity.id}`;
716
+ input.onchange = () => this.change(
717
+ input.checked ? [...this.selected(), entity] : this.selected().filter((one) => one !== entity)
718
+ );
719
+ label.append(
720
+ input,
721
+ ` ${entity.name || "Entity"} (${entity.id}) \xB7 ${entity.sceneId}`
722
+ );
723
+ this.results.append(label);
724
+ }
725
+ this.refresh();
726
+ }
727
+ };
728
+
729
+ // src/browser/ui/FeedbackPanel.ts
730
+ var FeedbackPanel = class {
731
+ constructor(session, shortcuts, galleryUrl) {
732
+ this.session = session;
733
+ this.style.textContent = CSS;
734
+ document.head.append(this.style);
735
+ this.launcher = new FeedbackLauncher(
736
+ {
737
+ open: () => this.open(),
738
+ toggleFreeze: () => this.session.toggleFreeze(),
739
+ canStep: () => this.session.canStep(),
740
+ step: (frames) => this.session.step(frames)
741
+ },
742
+ shortcuts,
743
+ galleryUrl
744
+ );
745
+ }
746
+ session;
747
+ style = document.createElement("style");
748
+ launcher;
749
+ dialog;
750
+ destroyed = false;
751
+ open() {
752
+ if (this.dialog || this.destroyed) return;
753
+ let observation;
754
+ try {
755
+ observation = this.session.open();
756
+ } catch (error) {
757
+ this.launcher.error(error);
758
+ return;
759
+ }
760
+ this.launcher.reset();
761
+ const { capture, image } = observation;
762
+ const dialog = document.createElement("dialog");
763
+ this.dialog = dialog;
764
+ dialog.className = "yage-feedback";
765
+ dialog.setAttribute("aria-label", "YAGE feedback");
766
+ dialog.innerHTML = `<header><h2>Leave feedback</h2><button data-close>Return to view</button></header>
767
+ <p data-frame></p><div class="layout"><figure aria-label="Captured game view"><img alt="Captured game viewport"><div class="outlines"></div></figure>
768
+ <section><label>Target <select aria-label="Target mode"><option value="global">Whole view</option><option value="entities">Entities</option><option value="area">Area</option></select></label>
769
+ <div class="targets"></div><label>Comment<textarea aria-label="Comment" maxlength="10000" placeholder="What would you like changed?"></textarea></label>
770
+ <button data-save>Save comment</button><button data-cancel hidden>Cancel save</button><button data-discard hidden>Discard unsaved draft</button><p class="status" role="status"></p><strong>Comments in this session</strong><ol class="history"></ol></section></div>`;
771
+ const query = (selector) => {
772
+ const element = dialog.querySelector(selector);
773
+ if (!element) throw new Error(`Feedback UI element missing: ${selector}`);
774
+ return element;
775
+ };
776
+ query("[data-frame]").textContent = `Frame ${capture.frame} \xB7 ${capture.width} \xD7 ${capture.height} \xB7 Select a target and add one or more comments.`;
777
+ query("img").src = image;
778
+ const figure = query("figure");
779
+ const select = query("select");
780
+ const textarea = query("textarea");
781
+ const save = query("[data-save]");
782
+ const close = query("[data-close]");
783
+ const discard = query("[data-discard]");
784
+ const cancel = query("[data-cancel]");
785
+ const status = query(".status");
786
+ const targets = query(".targets");
787
+ const outlines = query(".outlines");
788
+ let selected = [];
789
+ let area;
790
+ let start;
791
+ const draw = () => {
792
+ outlines.replaceChildren();
793
+ const boxes = select.value === "entities" ? selected.map((entity) => entity.bounds) : select.value === "area" && area ? [area] : [];
794
+ for (const box of boxes) {
795
+ const outline = document.createElement("div");
796
+ outline.className = "outline";
797
+ Object.assign(outline.style, {
798
+ left: `${box.x / capture.width * 100}%`,
799
+ top: `${box.y / capture.height * 100}%`,
800
+ width: `${box.width / capture.width * 100}%`,
801
+ height: `${box.height / capture.height * 100}%`
802
+ });
803
+ outlines.append(outline);
804
+ }
805
+ targets.hidden = select.value !== "entities";
806
+ picker.refresh();
807
+ };
808
+ const picker = new EntityPicker(
809
+ targets,
810
+ capture.entities,
811
+ () => selected,
812
+ (entities) => {
813
+ selected = entities;
814
+ draw();
815
+ }
816
+ );
817
+ select.onchange = () => {
818
+ area = void 0;
819
+ selected = [];
820
+ draw();
821
+ };
822
+ const point = (event) => {
823
+ const box = figure.getBoundingClientRect();
824
+ return {
825
+ x: Math.max(
826
+ 0,
827
+ Math.min(
828
+ capture.width,
829
+ (event.clientX - box.left) / box.width * capture.width
830
+ )
831
+ ),
832
+ y: Math.max(
833
+ 0,
834
+ Math.min(
835
+ capture.height,
836
+ (event.clientY - box.top) / box.height * capture.height
837
+ )
838
+ )
839
+ };
840
+ };
841
+ figure.onpointerdown = (event) => {
842
+ if (event.button !== 0 || this.session.pending) return;
843
+ const p = point(event);
844
+ if (select.value === "area") {
845
+ start = p;
846
+ area = void 0;
847
+ figure.setPointerCapture(event.pointerId);
848
+ }
849
+ if (select.value === "entities") {
850
+ const candidates = capture.entities.filter(
851
+ ({ bounds: b }) => p.x >= b.x && p.x <= b.x + b.width && p.y >= b.y && p.y <= b.y + b.height
852
+ );
853
+ const entity = candidates.at(-1);
854
+ if (entity)
855
+ selected = event.shiftKey ? selected.includes(entity) ? selected.filter((one) => one !== entity) : [...selected, entity] : [entity];
856
+ else if (!event.shiftKey) selected = [];
857
+ status.textContent = candidates.length > 1 ? "Overlapping bounds: use the entity dropdown to choose the intended entity." : "";
858
+ }
859
+ draw();
860
+ };
861
+ figure.onpointermove = (event) => {
862
+ if (!start) return;
863
+ const p = point(event);
864
+ area = {
865
+ x: Math.min(start.x, p.x),
866
+ y: Math.min(start.y, p.y),
867
+ width: Math.abs(p.x - start.x),
868
+ height: Math.abs(p.y - start.y)
869
+ };
870
+ draw();
871
+ };
872
+ figure.onpointerup = () => {
873
+ start = void 0;
874
+ };
875
+ figure.onpointercancel = () => {
876
+ start = void 0;
877
+ area = void 0;
878
+ draw();
879
+ };
880
+ const history = () => {
881
+ const list = query(".history");
882
+ list.replaceChildren();
883
+ for (const comment of this.session.comments) {
884
+ const li = document.createElement("li");
885
+ li.textContent = comment.text;
886
+ list.append(li);
887
+ }
888
+ };
889
+ const closeDialog = () => {
890
+ this.close();
891
+ };
892
+ close.onclick = closeDialog;
893
+ dialog.oncancel = (event) => {
894
+ event.preventDefault();
895
+ closeDialog();
896
+ };
897
+ for (const type of [
898
+ "keydown",
899
+ "keyup",
900
+ "pointerdown",
901
+ "pointerup",
902
+ "pointermove",
903
+ "mousedown",
904
+ "mouseup",
905
+ "click",
906
+ "wheel"
907
+ ])
908
+ dialog.addEventListener(type, (event) => event.stopPropagation());
909
+ const showPending = () => {
910
+ const draft = this.session.draftComment;
911
+ if (!draft) return;
912
+ textarea.value = draft.text;
913
+ select.value = draft.target.kind;
914
+ const target = draft.target;
915
+ if (target.kind === "entities")
916
+ selected = capture.entities.filter(
917
+ (entity) => target.entities.some(
918
+ (one) => one.sceneId === entity.sceneId && one.id === entity.id && one.generation === entity.generation
919
+ )
920
+ );
921
+ if (target.kind === "area") area = { ...target.rect };
922
+ textarea.disabled = true;
923
+ select.disabled = true;
924
+ picker.setDisabled(true);
925
+ save.disabled = false;
926
+ save.textContent = "Retry save";
927
+ discard.hidden = false;
928
+ cancel.hidden = true;
929
+ draw();
930
+ };
931
+ cancel.onclick = () => {
932
+ this.session.cancelSave();
933
+ showPending();
934
+ status.textContent = "Save cancelled. The draft is retained; retry or return to the view. A server commit may already have completed.";
935
+ };
936
+ discard.onclick = () => {
937
+ this.session.discard();
938
+ textarea.value = "";
939
+ textarea.disabled = false;
940
+ select.disabled = false;
941
+ close.disabled = false;
942
+ picker.setDisabled(false);
943
+ save.textContent = "Save comment";
944
+ discard.hidden = true;
945
+ status.textContent = "Draft discarded. Any comment already saved on the server remains there.";
946
+ };
947
+ save.onclick = () => {
948
+ void (async () => {
949
+ let target = { kind: "global" };
950
+ if (!this.session.pending) {
951
+ if (select.value === "entities") {
952
+ if (!selected.length) {
953
+ status.textContent = "Select at least one entity.";
954
+ return;
955
+ }
956
+ target = { kind: "entities", entities: [...selected] };
957
+ } else if (select.value === "area") {
958
+ if (!area || area.width < 1 || area.height < 1) {
959
+ status.textContent = "Draw an area on the image.";
960
+ return;
961
+ }
962
+ target = { kind: "area", rect: { ...area } };
963
+ }
964
+ if (!textarea.value.trim()) {
965
+ status.textContent = "Write a comment first.";
966
+ return;
967
+ }
968
+ }
969
+ save.disabled = true;
970
+ cancel.hidden = false;
971
+ select.disabled = true;
972
+ textarea.disabled = true;
973
+ picker.setDisabled(true);
974
+ status.textContent = "Saving\u2026";
975
+ discard.hidden = true;
976
+ try {
977
+ const saved = await this.session.save(textarea.value, target);
978
+ if (this.dialog !== dialog || !saved) return;
979
+ textarea.value = "";
980
+ textarea.disabled = false;
981
+ select.disabled = false;
982
+ close.disabled = false;
983
+ picker.setDisabled(false);
984
+ save.textContent = "Save comment";
985
+ status.textContent = "Saved. Add another comment or return to the view.";
986
+ history();
987
+ } catch (error) {
988
+ if (this.dialog !== dialog) return;
989
+ status.textContent = `${error instanceof Error ? error.message : String(error)}
990
+ The draft is retained. Retry or return to the view.`;
991
+ save.textContent = "Retry save";
992
+ discard.hidden = false;
993
+ }
994
+ save.disabled = false;
995
+ cancel.hidden = true;
996
+ })();
997
+ };
998
+ showPending();
999
+ draw();
1000
+ history();
1001
+ document.body.append(dialog);
1002
+ dialog.showModal();
1003
+ }
1004
+ close() {
1005
+ this.dialog?.remove();
1006
+ this.dialog = void 0;
1007
+ this.session.close();
1008
+ this.launcher.focus();
1009
+ }
1010
+ destroy() {
1011
+ this.destroyed = true;
1012
+ if (this.dialog) this.close();
1013
+ this.launcher.destroy();
1014
+ this.style.remove();
1015
+ }
1016
+ };
1017
+
1018
+ // src/browser/FeedbackPlugin.ts
1019
+ var FeedbackPlugin = class {
1020
+ constructor(options) {
1021
+ this.options = options;
1022
+ this.shortcuts = resolveShortcuts(options.shortcuts);
1023
+ const configured = typeof document === "undefined" ? void 0 : document.querySelector(
1024
+ 'meta[name="yage-feedback-server"]'
1025
+ )?.content;
1026
+ this.client = new FeedbackClient(
1027
+ options.server ?? (configured ? new URL(configured, location.href).href : void 0)
1028
+ );
1029
+ }
1030
+ options;
1031
+ name = "feedback";
1032
+ version = "0.0.0";
1033
+ dependencies = ["renderer", "debug"];
1034
+ context;
1035
+ panel;
1036
+ client;
1037
+ shortcuts;
1038
+ install(context) {
1039
+ this.context = context;
1040
+ }
1041
+ onStart() {
1042
+ if (!this.options.enabled || !this.context) return;
1043
+ const host = new RuntimeFeedbackHost(this.context, this.options.context);
1044
+ this.panel = new FeedbackPanel(
1045
+ new FeedbackSession(host, this.client),
1046
+ this.shortcuts,
1047
+ this.client.galleryUrl
1048
+ );
1049
+ }
1050
+ onDestroy() {
1051
+ this.panel?.destroy();
1052
+ this.panel = void 0;
1053
+ }
1054
+ };
1055
+ export {
1056
+ FeedbackPlugin
1057
+ };