Add core feedback workflow hardening

This commit is contained in:
Franz Rolfsvaag 2026-06-18 10:54:34 +02:00
parent 9c27d12445
commit 82b3984d14
11 changed files with 1892 additions and 167 deletions

171
TODO.md
View File

@ -125,7 +125,134 @@ This file tracks larger Lumi work that cannot safely be completed in one pass. K
## Core Feedback System
First implementation pass completed 2026-06-18. Remaining feedback work is mainly optional screenshot capture/storage, richer duplicate detection, date/plugin/area admin filters, issue creation, OKF correction conversion, and deeper attachment/DOM snapshot handling. Keep the detailed checklist below as the remaining reference for hardening and follow-up passes.
Current state on `experimental-feedback-system` as of 2026-06-18: the core feedback system is implemented locally and not yet pushed after the latest context-menu, review action, and screenshot attachment refinements. The detailed checklist below is retained for hardening, but this current-state block is the source of truth.
### Implemented Locally
- Core-level feedback storage in SQLite:
- `feedback_entries`
- `feedback_comments`
- `feedback_status_history`
- `feedback_view_state`
- Logged-in feedback submission through:
- persistent floating feedback button
- site-wide custom context menu
- `/api/feedback`
- `/feedback` under Community navigation for logged-in users.
- `/admin/feedback` under Admin navigation for admin review.
- Generic issue categories, severity values, scope types, and status labels.
- Feedback modal fields for summary, category, severity, scope, description, steps, expected behavior, actual behavior, page URL, page title, clicked-element metadata, and optional browser/viewport diagnostics.
- Element-targeted feedback from right-click context menu with safe metadata:
- selector/path
- tag
- visible text snippet
- aria label/title/role
- nearest label/heading
- page URL/title
- viewport size
- Sensitive form field values are not captured by the element metadata collector.
- Selected feedback target is highlighted while the modal is open and cleared when closed.
- User feedback center:
- public non-identifying feedback summary list
- “My feedback” section
- own-feedback detail view
- submitter comments/follow-up
- admin replies visible to submitter
- Per-user notification counters for solved, needs-context, and not-worked/not-planned outcomes.
- Notification counters reset/update when the user opens `/feedback`.
- Admin review page:
- full feedback details
- submitter identity
- target metadata
- optional diagnostics
- status history
- comments/replies
- private work notes
- filters for status, category, severity, scope, submitter, and needs-action
- filters for plugin/area text and submitted date range
- sorting by newest, oldest, severity, status, and last activity
- status/category/severity updates
- admin reply to submitter
- private admin work note
- request-more-context support through `needs_more_context`
- duplicate/rejected/not-planned/won't-fix/archive/delete statuses
- manual links to issue/correction references
- Admin review actions:
- `Finalize & Close`
- reopen closed feedback
- hard-delete feedback, comments, private notes, status history, and screenshots
- sensitive-data cleanup for screenshots, diagnostics, target metadata, and public admin replies
- feedback rows are collapsed by default
- Moderate submission rate limit.
- Validation against empty/too-short feedback.
- Server-side permission checks for submission, own-feedback details, submitter comments, and admin review.
- EJS escaping and sanitized server-side JSON/text handling for submitted feedback.
- AI Improvement Center remains separate; `wrong_tool_usage` classification already exists there.
- Custom context menu:
- Back
- Forward
- Copy
- Cut
- Paste
- Link to here
- Hard reload
- Feedback
- Context menu availability behavior:
- all actions remain visible
- unavailable actions are greyed out and non-interactive
- Back/Forward availability is history-aware where browser APIs allow
- Copy is available only for selected text or right-clicked images
- Cut is available only for selected text inside editable fields
- Paste is available only when an editable field is active and readable clipboard text exists
- Link to here, Hard reload, and Feedback remain available
- Context menu tooltips:
- show after one second of hover
- explain each action
- use fixed positioning
- clamp to the viewport so they do not overflow or expand the page
- Context menu keyboard behavior:
- arrow-key navigation
- Home/End navigation
- Enter/Space activation
- Tab focus wrapping while open
- Scope label/target field:
- displayed as a generated read-only “Detected target” field
- updates when “Feedback is about” changes
- is regenerated server-side and does not trust submitted free text
- Optional screenshot support:
- explicit opt-in only
- supports image upload
- supports pasted screenshots from the clipboard where browsers allow
- avoids browser tab/window picker capture so the browser prompt is not submitted
- shows an attached screenshot preview
- lets users remove the screenshot before submitting
- stores screenshots under `data/feedback/screenshots`
- exposes screenshots only to the submitter or admins through authenticated routes
- General protected attachments:
- supports up to three optional PNG/JPEG/WebP/PDF/text files per feedback item
- stores attachments under `data/feedback/attachments`
- exposes attachments only to the submitter or admins through authenticated routes
- supports admin cleanup/removal
- Public discussion on existing feedback:
- logged-in users can open public feedback detail without private diagnostics or attachments
- logged-in users can mark existing feedback as affecting them too
- logged-in users can add public context comments to existing feedback
### Partially Implemented
- Optional diagnostics exist for browser/user-agent, viewport, language, current URL, page title, selected scope, target metadata, screenshot attachment source, and opt-in sanitized page structure snapshots.
- Admin filters cover status/category/severity/scope/plugin-area/submitter/date/needs-action. Richer custom-scope filters are not implemented.
- OKF correction conversion is deferred for a later pass. Task tracking remains manual and outside the feedback feature.
- Duplicate handling includes lightweight similar-feedback suggestions from summary, description, scope, category, and page path; “also affected” counts; and public context comments, but not full duplicate merging.
- Feedback data is in SQLite under `data/app.db`, which is preserved by the update system through the existing protected `data/` path. Screenshot files are stored under `data/feedback/screenshots`, also covered by the protected `data/` path.
### Remaining Work
- Full scroll-page or automatic current-tab screenshot capture is not implemented. Current browser APIs require prompts for page capture, so the implemented path is explicit upload/paste.
- Full duplicate merge workflow and richer duplicate detection beyond lightweight summary/description/scope/category/page matching.
- Richer custom scope targeting filters.
- OKF correction conversion for documentation/terminology/knowledge feedback, deferred.
- Broader browser testing for image clipboard copy and clipboard-read availability because browser support and permissions vary.
- Implement a general core feedback system available to logged-in users only.
- Add a persistent feedback entry point somewhere logical in the UI.
@ -223,13 +350,12 @@ First implementation pass completed 2026-06-18. Remaining feedback work is mainl
### Optional Screenshot Support
- Add optional browser-generated screenshot support.
- Add optional screenshot attachment support without browser tab/window picker prompts.
- Screenshots must be opt-in by the submitting user.
- Do not require screenshots for feedback submission.
- Support full-page screenshots where technically possible.
- Support cropped/section-only screenshots where technically possible.
- Temporarily hide feedback buttons, feedback modals, context menus, and feedback overlays while capturing screenshots.
- Restore hidden feedback UI immediately after capture.
- Support uploaded screenshot image files.
- Support pasted screenshots from the clipboard where browsers allow it.
- Consider full-page screenshots only if a safe DOM-rendering implementation or browser extension path is added later.
- Store screenshots only when explicitly attached by the user.
- Clearly warn users not to include sensitive information in screenshots.
- Allow users to remove an attached screenshot before submitting.
@ -343,22 +469,8 @@ First implementation pass completed 2026-06-18. Remaining feedback work is mainl
- mark duplicate
- archive
- delete
- convert to TODO
- convert to issue
- convert to OKF correction where relevant
- link to existing TODO/issue/correction
### TODO and Issue Conversion
- Allow admins to convert feedback into a TODO entry.
- Support injecting generated TODO entries into `TODO.md`.
- Preserve the existing TODO format when injecting new entries.
- Allow admins to edit the generated TODO text before saving.
- Add enough context to generated TODO items to be useful for later Codex work.
- Allow admins to convert feedback into a GitHub/Gitea issue if configured.
- Allow admins to link feedback to manually created TODOs or issues.
- Store links between feedback items and generated TODOs/issues.
- Do not automatically create TODOs or issues without admin confirmation.
- link to existing issue/correction
### OKF and AI Improvement Integration
@ -377,7 +489,7 @@ First implementation pass completed 2026-06-18. Remaining feedback work is mainl
- Encourage one feedback item per issue.
- Add validation to prevent empty or extremely vague feedback.
- Add optional duplicate detection based on similar title, scope, and page.
- If similar existing feedback exists, suggest adding a comment or upvote/support instead of creating a duplicate.
- If similar existing feedback exists, suggest adding a public comment or marking that it affects the user too instead of creating a duplicate.
- Allow admins to delete or archive abusive/spam feedback.
### Data Storage and Preservation
@ -395,7 +507,7 @@ First implementation pass completed 2026-06-18. Remaining feedback work is mainl
- assigned_admin_id if used
- status history
- comments/replies
- linked TODOs/issues/corrections
- linked issues/corrections
### Privacy and Safety
@ -414,18 +526,25 @@ First implementation pass completed 2026-06-18. Remaining feedback work is mainl
- Logged-in users can submit targeted feedback from the custom right-click context menu.
- Right-click context menu includes Back, Forward, Copy, Cut, Paste, Link to here, Hard reload, and Feedback.
- Feedback modal can target the whole page, clicked element, current feature/page, plugin, broad system area, or custom scope.
- Users can optionally attach browser-generated screenshots while feedback UI is hidden during capture.
- Users can optionally attach screenshots by upload or clipboard paste.
- `/feedback` appears under the Community navbar section for logged-in users.
- `/feedback` shows general non-identifying feedback summaries and detailed feedback for the current users own submissions.
- Users see per-user notification badges for solved, needs-context, and not-worked-on feedback.
- Admins can review and manage feedback at `/admin/feedback`.
- Admins can reply, add work notes, request more context, change statuses, and convert feedback to TODOs/issues/corrections.
- Admins can reply, add work notes, request more context, change statuses, finalize and close, reopen closed feedback, and permanently delete feedback.
- Feedback data is preserved across updates and included in protected user data.
- AI reply feedback remains handled by the AI Improvement Center, with an added `wrong tool` classification.
## Done
- 2026-06-18: Added a core feedback system first pass on `experimental-feedback-system`: SQLite feedback entries/comments/status history/view state, logged-in `/feedback`, admin `/admin/feedback`, persistent feedback modal, site-wide custom context menu with element-targeted feedback, per-user notification badges, admin replies/work notes/status changes/TODO conversion, and AI Improvement Center `wrong_tool_usage` confirmed present.
- 2026-06-18: Removed feedback conversion actions from the experimental feedback system, added Finalize & Close/Reopen/Delete admin actions, made delete hard-remove feedback records/comments/notes/history/screenshots, kept admin feedback rows collapsed by default, and replaced browser tab capture screenshots with upload/clipboard-paste attachments.
- 2026-06-18: Fixed experimental feedback review buttons to use the stable save endpoint for Finalize & Close/Reopen and added a reusable Lumi `button-group` utility, including the `.field.button-group` specificity case, so review buttons align horizontally.
- 2026-06-18: Moved feedback screenshot guidance above Description, centered feedback screenshot/form action rows, and moved feedback modal sizing overrides into the later-loaded Lumi component layer so generic `.modal` styles no longer win.
- 2026-06-18: Added feedback “also affects me” records, user-facing similar-feedback suggestions before submit, admin plugin/area/date filters, sensitive-data cleanup controls, and keyboard navigation for the custom context menu; dropped external issue creation from the feedback roadmap and left OKF conversion deferred.
- 2026-06-18: Added protected general feedback attachments, protected attachment downloads, admin attachment cleanup/removal, viewer-safe public feedback details, and public context comments on existing feedback.
- 2026-06-18: Added opt-in sanitized page structure diagnostics for feedback, excluding sensitive form values and capping stored snapshot text server-side.
- 2026-06-18: Updated the experimental feedback context menu locally so all actions remain visible with disabled unavailable states, Back/Forward/Copy/Cut/Paste availability is context-aware, image/text copy and input-only cut/paste rules are enforced where browsers allow, and one-second viewport-clamped tooltips explain each action.
- 2026-06-18: Added a core feedback system first pass on `experimental-feedback-system`: SQLite feedback entries/comments/status history/view state, logged-in `/feedback`, admin `/admin/feedback`, persistent feedback modal, site-wide custom context menu with element-targeted feedback, per-user notification badges, admin replies/work notes/status changes, and AI Improvement Center `wrong_tool_usage` confirmed present.
- 2026-06-17: Added Economy cleanup for fallback-name artifact users such as `SammyCat-2`, merging them into matching real platform users and making Economy stats tolerate missing/mid-migration tables; bumped Economy Framework to v0.2.9.
- 2026-06-17: Fixed update metadata for renamed Economy plugins so legacy installed rows are folded into canonical `economy-*` plugin update rows instead of appearing as separate installs; bumped core to v0.1.8.
- 2026-06-17: Renamed all remaining Economy internals from the old misspelled IDs/paths/tables to `economy-*`, added startup migration for legacy plugin rows, settings, command usage IDs, tables, uploads, asset paths, old URLs, and bumped core/plugin patch versions.

View File

@ -25,6 +25,9 @@ try {
db.prepare(
"INSERT INTO user_profiles (id, internal_username, created_at, updated_at) VALUES (?, ?, ?, ?)"
).run("admin-1", "AdminUser", now, now);
db.prepare(
"INSERT INTO user_profiles (id, internal_username, created_at, updated_at) VALUES (?, ?, ?, ?)"
).run("user-2", "SecondUser", now, now);
const entry = feedback.createFeedback({
summary: "Save button fails",
@ -47,22 +50,40 @@ try {
diagnostics: {
user_agent: "verification-agent",
viewport: "1200x800",
screenshot_mode: "target",
hidden: "must not persist"
}
}, { id: "user-1" });
}, { id: "user-1" }, {
screenshot: {
path: "feedback/screenshots/test-shot.png",
mime: "image/png",
size: 2048
}
});
assert.equal(entry.status, "new");
assert.equal(entry.scope_label, "Clicked element: Save settings");
assert.equal(entry.target_metadata.secret, undefined);
assert.equal(entry.diagnostics.hidden, undefined);
assert.equal(entry.screenshot.mime, "image/png");
assert.equal(feedback.listPublicFeedback({ userId: "user-1" })[0].is_mine, true);
assert.equal(feedback.listMyFeedback("user-1").length, 1);
assert.equal(feedback.supportFeedback(entry.id, { id: "user-2" }), 1);
assert.equal(feedback.listPublicFeedback({ userId: "user-2" })[0].supported_by_me, true);
feedback.addSubmitterComment(entry.id, "This also happens after a hard reload.", { id: "user-1" });
feedback.addSubmitterComment(entry.id, "I see this too on the same settings page.", { id: "user-2" });
const withComment = feedback.getFeedbackForSubmitter(entry.id, "user-1");
assert.equal(withComment.comments.length, 1);
assert.equal(withComment.comments.length, 2);
assert.equal(withComment.comments[0].kind, "submitter_comment");
assert.equal(withComment.comments[1].kind, "public_comment");
const publicViewerEntry = feedback.getFeedbackForViewer(entry.id, "user-2");
assert.equal(publicViewerEntry.is_mine, false);
assert.equal(publicViewerEntry.screenshot, null);
assert(publicViewerEntry.comments.some((comment) => comment.kind === "public_comment"));
feedback.markFeedbackViewed("user-1");
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2);
feedback.adminUpdateFeedback(entry.id, {
status: "needs_more_context",
category: "bug",
@ -79,11 +100,79 @@ try {
const submitterEntry = feedback.getFeedbackForSubmitter(entry.id, "user-1");
assert.equal(submitterEntry.comments.some((comment) => comment.kind === "work_note"), false);
const todoPath = path.join(sandbox, "TODO.md");
fs.writeFileSync(todoPath, "# TODO\n\n## Done\n");
feedback.convertFeedbackToTodo(entry.id, "Fix feedback verification issue.", { id: "admin-1" }, todoPath);
assert(fs.readFileSync(todoPath, "utf8").includes("## Feedback-Derived TODOs"));
assert(fs.readFileSync(todoPath, "utf8").includes("Fix feedback verification issue."));
feedback.adminUpdateFeedback(entry.id, {
status: "closed",
category: "bug",
severity: "urgent",
status_note: "Finalized after verification."
}, { id: "admin-1" });
assert.equal(feedback.getFeedbackForAdmin(entry.id).status, "closed");
feedback.adminUpdateFeedback(entry.id, {
status: "reviewed",
category: "bug",
severity: "urgent",
status_note: "Reopened after verification."
}, { id: "admin-1" });
assert.equal(feedback.getFeedbackForAdmin(entry.id).status, "reviewed");
assert(feedback.findSimilarFeedback({
summary: "Settings save button does nothing",
category: "bug",
scope_type: "element",
current_url: "http://localhost/admin/settings"
}, { userId: "user-2" }).some((match) => match.id === entry.id));
assert(feedback.findSimilarFeedback({
summary: "Controls are weird",
description: "Clicking the save button does not apply changes after editing settings.",
category: "bug",
scope_type: "element",
current_url: "http://localhost/admin/settings"
}, { userId: "user-2" }).some((match) => match.id === entry.id));
const cleanupEntry = feedback.createFeedback({
summary: "Sensitive screenshot cleanup",
category: "bug",
severity: "minor",
scope_type: "page",
description: "This report exists to verify cleanup behavior.",
current_url: "http://localhost/admin/feedback",
page_title: "Feedback review",
diagnostics: { user_agent: "sensitive-agent" }
}, { id: "user-1" }, {
screenshot: {
path: "feedback/screenshots/cleanup-shot.png",
mime: "image/png",
size: 1024
},
attachments: [{
path: "feedback/attachments/cleanup-note.txt",
mime: "text/plain",
size: 128,
original_name: "cleanup-note.txt"
}]
});
assert.equal(feedback.getFeedbackForAdmin(cleanupEntry.id).attachments.length, 1);
let cleanedScreenshot = "";
let cleanedAttachment = "";
feedback.cleanupFeedback(cleanupEntry.id, {
clear_screenshot: "1",
clear_attachments: "1",
clear_diagnostics: "1",
clear_target_metadata: "1"
}, { id: "admin-1" }, {
deleteScreenshot(relativePath) {
cleanedScreenshot = relativePath;
},
deleteAttachment(relativePath) {
cleanedAttachment = relativePath;
}
});
const cleaned = feedback.getFeedbackForAdmin(cleanupEntry.id);
assert.equal(cleaned.screenshot, null);
assert.deepEqual(cleaned.diagnostics, {});
assert.deepEqual(cleaned.target_metadata, {});
assert.equal(cleaned.attachments.length, 0);
assert.equal(cleanedScreenshot, "feedback/screenshots/cleanup-shot.png");
assert.equal(cleanedAttachment, "feedback/attachments/cleanup-note.txt");
const feedbackView = path.join(root, "src", "web", "views", "feedback.ejs");
const adminView = path.join(root, "src", "web", "views", "admin-feedback.ejs");
@ -113,6 +202,17 @@ try {
}, { filename: feedbackView });
assert(userRendered.includes("My feedback"));
assert(userRendered.includes("Admin reply"));
assert(userRendered.includes("Open screenshot"));
assert(userRendered.includes("Community comment"));
const supporterRendered = ejs.render(fs.readFileSync(feedbackView, "utf8"), {
...commonLocals,
user: { id: "user-2", username: "SecondUser" },
title: "Feedback",
feedbackList: feedback.listPublicFeedback({ userId: "user-2" }),
myFeedback: feedback.listMyFeedback("user-2"),
selectedFeedback: null
}, { filename: feedbackView });
assert(supporterRendered.includes("Marked as affecting you too."));
const adminRendered = ejs.render(fs.readFileSync(adminView, "utf8"), {
...commonLocals,
@ -120,10 +220,28 @@ try {
userInitial: "A",
title: "Feedback review",
feedbackItems: feedback.listFeedbackForAdmin({}),
filters: { status: "", category: "", severity: "", scope: "", submitter: "", needs_action: "", sort: "last_activity" }
filters: { status: "", category: "", severity: "", scope: "", area: "", submitter: "", date_from: "", date_to: "", needs_action: "", sort: "last_activity" }
}, { filename: adminView });
assert(adminRendered.includes("Feedback queue"));
assert(adminRendered.includes("Convert to TODO"));
assert(!adminRendered.includes("Convert useful reports"));
assert(adminRendered.includes("Finalize & Close"));
assert(adminRendered.includes("Delete"));
assert(adminRendered.includes("Open attached screenshot"));
assert(adminRendered.includes("Sensitive data cleanup"));
assert(adminRendered.includes("Remove attachments"));
assert(adminRendered.includes("Plugin/area"));
assert(adminRendered.includes("1 also affected"));
let deletedScreenshot = "";
feedback.deleteFeedback(entry.id, {
deleteScreenshot(relativePath) {
deletedScreenshot = relativePath;
}
});
assert.equal(feedback.getFeedbackForAdmin(entry.id), null);
assert.equal(db.prepare("SELECT COUNT(*) AS count FROM feedback_comments WHERE feedback_id = ?").get(entry.id).count, 0);
assert.equal(db.prepare("SELECT COUNT(*) AS count FROM feedback_status_history WHERE feedback_id = ?").get(entry.id).count, 0);
assert.equal(deletedScreenshot, "feedback/screenshots/test-shot.png");
console.log("Core feedback system verification passed.");
} finally {

View File

@ -166,6 +166,9 @@ function migrate() {
expected_behavior TEXT,
actual_behavior TEXT,
diagnostics_json TEXT NOT NULL DEFAULT '{}',
screenshot_path TEXT,
screenshot_mime TEXT,
screenshot_size INTEGER,
status TEXT NOT NULL DEFAULT 'new',
admin_reply TEXT,
assigned_admin_id TEXT,
@ -209,6 +212,28 @@ function migrate() {
user_id TEXT PRIMARY KEY,
last_seen_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS feedback_support (
feedback_id TEXT NOT NULL,
user_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (feedback_id, user_id)
);
CREATE INDEX IF NOT EXISTS feedback_support_feedback_idx ON feedback_support (feedback_id, created_at);
CREATE TABLE IF NOT EXISTS feedback_attachments (
id TEXT PRIMARY KEY,
feedback_id TEXT NOT NULL,
storage_path TEXT NOT NULL,
original_name TEXT,
mime TEXT NOT NULL,
size INTEGER NOT NULL,
kind TEXT NOT NULL DEFAULT 'attachment',
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS feedback_attachments_feedback_idx ON feedback_attachments (feedback_id, created_at);
`);
const columns = db
@ -271,6 +296,20 @@ function migrate() {
db.exec("ALTER TABLE user_profiles ADD COLUMN username_updated_at INTEGER");
}
const feedbackColumns = db
.prepare("PRAGMA table_info(feedback_entries)")
.all()
.map((column) => column.name);
if (!feedbackColumns.includes("screenshot_path")) {
db.exec("ALTER TABLE feedback_entries ADD COLUMN screenshot_path TEXT");
}
if (!feedbackColumns.includes("screenshot_mime")) {
db.exec("ALTER TABLE feedback_entries ADD COLUMN screenshot_mime TEXT");
}
if (!feedbackColumns.includes("screenshot_size")) {
db.exec("ALTER TABLE feedback_entries ADD COLUMN screenshot_size INTEGER");
}
migrateLegacyUsers();
}

View File

@ -1,5 +1,3 @@
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const { db } = require("./db");
@ -49,12 +47,13 @@ const FEEDBACK_STATUSES = Object.freeze([
"rejected",
"not_planned",
"wont_fix",
"closed",
"archived",
"deleted"
]);
const USER_VISIBLE_STATUSES = new Set(FEEDBACK_STATUSES.filter((status) => status !== "deleted"));
const SOLVED_STATUSES = new Set(["fixed", "solved"]);
const SOLVED_STATUSES = new Set(["fixed", "solved", "closed"]);
const NEEDS_CONTEXT_STATUSES = new Set(["needs_more_context"]);
const NOT_WORKING_STATUSES = new Set(["duplicate", "rejected", "not_planned", "wont_fix"]);
const RATE_LIMIT = { max: 5, windowMs: 10 * 60 * 1000 };
@ -104,6 +103,7 @@ const STATUS_LABELS = Object.freeze({
rejected: "Rejected",
not_planned: "Not planned",
wont_fix: "Won't fix",
closed: "Closed",
archived: "Archived",
deleted: "Deleted"
});
@ -121,23 +121,26 @@ const STATUS_HELP = Object.freeze({
rejected: "Rejected after review.",
not_planned: "Not planned for implementation.",
wont_fix: "Reviewed, but will not be changed.",
closed: "Finalized and closed by an administrator.",
archived: "Archived for record keeping.",
deleted: "Deleted by an administrator."
});
function createFeedback(input, actor) {
function createFeedback(input, actor, options = {}) {
if (!actor?.id) {
throw new Error("Feedback requires a logged-in user.");
}
enforceRateLimit(actor.id);
const now = Date.now();
const entry = normalizeFeedbackInput(input);
const screenshot = normalizeScreenshot(options.screenshot);
const attachments = normalizeAttachments(options.attachments);
const id = crypto.randomUUID();
db.transaction(() => {
db.prepare(
"INSERT INTO feedback_entries " +
"(id, submitter_id, summary, category, severity, scope_type, scope_label, target_metadata_json, current_url, page_title, description, steps_to_reproduce, expected_behavior, actual_behavior, diagnostics_json, status, created_at, updated_at, last_activity_at) " +
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'new', ?, ?, ?)"
"(id, submitter_id, summary, category, severity, scope_type, scope_label, target_metadata_json, current_url, page_title, description, steps_to_reproduce, expected_behavior, actual_behavior, diagnostics_json, screenshot_path, screenshot_mime, screenshot_size, status, created_at, updated_at, last_activity_at) " +
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'new', ?, ?, ?)"
).run(
id,
actor.id,
@ -154,10 +157,27 @@ function createFeedback(input, actor) {
entry.expected_behavior,
entry.actual_behavior,
JSON.stringify(entry.diagnostics),
screenshot.path,
screenshot.mime,
screenshot.size,
now,
now,
now
);
for (const attachment of attachments) {
db.prepare(
"INSERT INTO feedback_attachments (id, feedback_id, storage_path, original_name, mime, size, kind, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
).run(
crypto.randomUUID(),
id,
attachment.path,
attachment.original_name,
attachment.mime,
attachment.size,
attachment.kind,
now
);
}
addStatusHistory(id, "new", actor.id, "Submitted", now);
})();
return getFeedbackForAdmin(id);
@ -170,13 +190,58 @@ function listPublicFeedback({ userId, limit = 100 } = {}) {
"FROM feedback_entries WHERE deleted_at IS NULL AND status != 'deleted' " +
"ORDER BY last_activity_at DESC LIMIT ?"
)
.all(limit)
.map((row) => ({
.all(limit);
const support = supportSummary(rows.map((row) => row.id), userId);
return rows.map((row) => ({
...decorateLabels(row),
support_count: support.counts.get(row.id) || 0,
supported_by_me: support.mine.has(row.id),
is_mine: userId ? row.submitter_id === userId : false,
submitter_id: undefined
}));
}
function findSimilarFeedback(input = {}, options = {}) {
const summary = cleanText(input.summary, 140);
const description = cleanText(input.description, 6000);
const scopeType = FEEDBACK_SCOPE_TYPES.includes(input.scope_type) ? input.scope_type : "";
const category = FEEDBACK_CATEGORIES.includes(input.category) ? input.category : "";
const currentUrl = cleanUrl(input.current_url);
const pagePath = pagePathKey(currentUrl);
if (summary.length < 6 && description.length < 12 && !pagePath) return [];
const rows = db
.prepare(
"SELECT id, submitter_id, summary, description, category, severity, scope_type, scope_label, current_url, page_title, status, created_at, updated_at, last_activity_at " +
"FROM feedback_entries WHERE deleted_at IS NULL AND status NOT IN ('deleted', 'closed', 'solved', 'fixed', 'archived') " +
"ORDER BY last_activity_at DESC LIMIT 150"
)
.all();
const queryTokens = tokenSet(`${summary} ${description}`);
const matches = rows
.map((row) => {
const rowTokens = tokenSet(`${row.summary} ${row.description || ""} ${row.scope_label || ""} ${row.page_title || ""}`);
const samePath = pagePath && pagePath === pagePathKey(row.current_url);
const score =
jaccardScore(queryTokens, rowTokens) +
(scopeType && row.scope_type === scopeType ? 0.25 : 0) +
(category && row.category === category ? 0.15 : 0) +
(samePath ? 0.35 : 0);
return { row, score };
})
.filter(({ score }) => score >= 0.32)
.sort((a, b) => b.score - a.score)
.slice(0, Math.min(Number(options.limit || 5), 10))
.map(({ row, score }) => ({
...decorateLabels(row),
is_mine: userId ? row.submitter_id === userId : false,
match_score: Math.round(score * 100) / 100,
submitter_id: undefined
}));
return rows;
const support = supportSummary(matches.map((row) => row.id), options.userId);
return matches.map((row) => ({
...row,
support_count: support.counts.get(row.id) || 0,
supported_by_me: support.mine.has(row.id)
}));
}
function listMyFeedback(userId) {
@ -197,9 +262,30 @@ function getFeedbackForSubmitter(id, userId) {
return hydrateFeedback(row, { admin: false });
}
function getFeedbackForViewer(id, userId) {
const row = db.prepare("SELECT * FROM feedback_entries WHERE id = ?").get(id);
if (!row || row.deleted_at || row.status === "deleted") {
return null;
}
const entry = hydrateFeedback(row, { admin: false });
entry.is_mine = Boolean(userId && row.submitter_id === userId);
const support = supportSummary([row.id], userId);
entry.support_count = support.counts.get(row.id) || 0;
entry.supported_by_me = support.mine.has(row.id);
if (!entry.is_mine) {
entry.screenshot = null;
entry.attachments = [];
}
return entry;
}
function getFeedbackForAdmin(id) {
const row = db.prepare("SELECT * FROM feedback_entries WHERE id = ?").get(id);
return row ? hydrateFeedback(row, { admin: true }) : null;
if (!row) return null;
const entry = hydrateFeedback(row, { admin: true });
const support = supportSummary([row.id]);
entry.support_count = support.counts.get(row.id) || 0;
return entry;
}
function listFeedbackForAdmin(filters = {}) {
@ -224,6 +310,22 @@ function listFeedbackForAdmin(filters = {}) {
where.push("feedback_entries.scope_type = ?");
params.push(filters.scope);
}
if (filters.area) {
where.push(
"(lower(feedback_entries.scope_label) LIKE lower(?) OR lower(feedback_entries.current_url) LIKE lower(?) OR lower(feedback_entries.page_title) LIKE lower(?))"
);
params.push(`%${filters.area}%`, `%${filters.area}%`, `%${filters.area}%`);
}
const from = parseDateBoundary(filters.date_from, "start");
if (from) {
where.push("feedback_entries.created_at >= ?");
params.push(from);
}
const to = parseDateBoundary(filters.date_to, "end");
if (to) {
where.push("feedback_entries.created_at <= ?");
params.push(to);
}
if (filters.submitter) {
where.push(
"(feedback_entries.submitter_id = ? OR lower(user_profiles.internal_username) LIKE lower(?))"
@ -240,34 +342,54 @@ function listFeedbackForAdmin(filters = {}) {
last_activity: "feedback_entries.last_activity_at DESC",
newest: "feedback_entries.created_at DESC"
}[filters.sort || "last_activity"];
return db
const rows = db
.prepare(
"SELECT feedback_entries.*, user_profiles.internal_username AS submitter_name " +
"FROM feedback_entries LEFT JOIN user_profiles ON user_profiles.id = feedback_entries.submitter_id " +
`WHERE ${where.join(" AND ")} ORDER BY ${order} LIMIT 250`
)
.all(...params)
.map((row) => hydrateFeedback(row, { admin: true }));
.all(...params);
const support = supportSummary(rows.map((row) => row.id));
return rows.map((row) => {
const entry = hydrateFeedback(row, { admin: true });
entry.support_count = support.counts.get(row.id) || 0;
return entry;
});
}
function addSubmitterComment(id, body, actor) {
const entry = getFeedbackForSubmitter(id, actor?.id);
if (!entry) {
const row = db.prepare("SELECT id, submitter_id, status, deleted_at FROM feedback_entries WHERE id = ?").get(id);
if (!row || row.deleted_at || row.status === "deleted" || !actor?.id) {
throw new Error("Feedback item was not found.");
}
const comment = cleanText(body, 4000);
if (comment.length < 3) {
throw new Error("Comment is too short.");
}
const kind = row.submitter_id === actor.id ? "submitter_comment" : "public_comment";
const now = Date.now();
db.transaction(() => {
db.prepare(
"INSERT INTO feedback_comments (feedback_id, actor_id, kind, body, visible_to_submitter, created_at) VALUES (?, ?, 'submitter_comment', ?, 1, ?)"
).run(id, actor.id, comment, now);
"INSERT INTO feedback_comments (feedback_id, actor_id, kind, body, visible_to_submitter, created_at) VALUES (?, ?, ?, ?, 1, ?)"
).run(id, actor.id, kind, comment, now);
touchFeedback(id, now);
})();
}
function supportFeedback(id, actor) {
if (!actor?.id) {
throw new Error("Support requires a logged-in user.");
}
const row = db.prepare("SELECT id, status, deleted_at FROM feedback_entries WHERE id = ?").get(id);
if (!row || row.deleted_at || row.status === "deleted") {
throw new Error("Feedback item was not found.");
}
db.prepare(
"INSERT OR IGNORE INTO feedback_support (feedback_id, user_id, created_at) VALUES (?, ?, ?)"
).run(id, actor.id, Date.now());
return supportSummary([id], actor.id).counts.get(id) || 0;
}
function adminUpdateFeedback(id, input, actor) {
const current = getFeedbackForAdmin(id);
if (!current) {
@ -314,41 +436,97 @@ function adminUpdateFeedback(id, input, actor) {
return getFeedbackForAdmin(id);
}
function convertFeedbackToTodo(id, body, actor, todoPath = path.join(__dirname, "..", "..", "TODO.md")) {
const entry = getFeedbackForAdmin(id);
if (!entry) {
function deleteFeedback(id, options = {}) {
const current = db.prepare("SELECT id, screenshot_path FROM feedback_entries WHERE id = ?").get(id);
if (!current) {
throw new Error("Feedback item was not found.");
}
const text = cleanText(body, 4000) || defaultTodoText(entry);
if (!text) {
throw new Error("TODO text is required.");
const attachments = attachmentsFor(id);
db.transaction(() => {
db.prepare("DELETE FROM feedback_comments WHERE feedback_id = ?").run(id);
db.prepare("DELETE FROM feedback_status_history WHERE feedback_id = ?").run(id);
db.prepare("DELETE FROM feedback_support WHERE feedback_id = ?").run(id);
db.prepare("DELETE FROM feedback_attachments WHERE feedback_id = ?").run(id);
db.prepare("DELETE FROM feedback_entries WHERE id = ?").run(id);
})();
if (current.screenshot_path && typeof options.deleteScreenshot === "function") {
options.deleteScreenshot(current.screenshot_path);
}
const current = fs.existsSync(todoPath) ? fs.readFileSync(todoPath, "utf8") : "# TODO\n\n## Done\n";
const section = "## Feedback-Derived TODOs";
const bullet = text
.split(/\r?\n/)
.map((line, index) => (index === 0 ? `- ${line}` : ` ${line}`))
.join("\n");
let next = current;
if (!current.includes(section)) {
next = current.replace(/\n## Done\b/, `\n${section}\n\n${bullet}\n\n## Done`);
if (next === current) {
next = `${current.replace(/\s*$/, "")}\n\n${section}\n\n${bullet}\n`;
if (typeof options.deleteAttachment === "function") {
attachments.forEach((attachment) => options.deleteAttachment(attachment.storage_path));
}
return current;
}
function cleanupFeedback(id, input = {}, actor, options = {}) {
const current = getFeedbackForAdmin(id);
if (!current) {
throw new Error("Feedback item was not found.");
}
const changes = [];
const sets = [];
if (input.clear_screenshot === "1" && current.screenshot?.path) {
sets.push("screenshot_path = NULL", "screenshot_mime = NULL", "screenshot_size = NULL");
changes.push("screenshot");
}
if (input.clear_diagnostics === "1") {
sets.push("diagnostics_json = '{}'");
changes.push("diagnostics");
}
if (input.clear_target_metadata === "1") {
sets.push("target_metadata_json = '{}'");
changes.push("target metadata");
}
if (input.clear_admin_reply === "1") {
sets.push("admin_reply = NULL");
changes.push("admin reply");
}
const clearAttachments = input.clear_attachments === "1";
const attachments = clearAttachments ? attachmentsFor(id) : [];
if (clearAttachments && attachments.length) {
changes.push("attachments");
}
if (!sets.length) {
if (!clearAttachments || !attachments.length) {
throw new Error("Choose at least one feedback data field to clean.");
}
} else {
next = current.replace(section, `${section}\n\n${bullet}`);
}
fs.writeFileSync(todoPath, next);
if (!sets.length && clearAttachments && attachments.length) {
sets.push("updated_at = updated_at");
}
if (!changes.length) {
throw new Error("Choose at least one feedback data field to clean.");
}
const now = Date.now();
db.transaction(() => {
db.prepare(
"UPDATE feedback_entries SET linked_todo = ?, updated_at = ?, last_activity_at = ? WHERE id = ?"
).run(text, now, now, id);
`UPDATE feedback_entries SET ${sets.join(", ")}, updated_at = ?, last_activity_at = ? WHERE id = ?`
).run(now, now, id);
if (clearAttachments) {
db.prepare("DELETE FROM feedback_attachments WHERE feedback_id = ?").run(id);
}
db.prepare(
"INSERT INTO feedback_comments (feedback_id, actor_id, kind, body, visible_to_submitter, created_at) VALUES (?, ?, 'work_note', ?, 0, ?)"
).run(id, actor.id, `Converted to TODO: ${text}`, now);
).run(id, actor?.id || null, `Cleaned sensitive feedback data: ${changes.join(", ")}.`, now);
})();
return text;
if (input.clear_screenshot === "1" && current.screenshot?.path && typeof options.deleteScreenshot === "function") {
options.deleteScreenshot(current.screenshot.path);
}
if (clearAttachments && typeof options.deleteAttachment === "function") {
attachments.forEach((attachment) => options.deleteAttachment(attachment.storage_path));
}
return getFeedbackForAdmin(id);
}
function getFeedbackAttachment(feedbackId, attachmentId, userId, isAdmin = false) {
const row = db.prepare("SELECT id, submitter_id, deleted_at, status FROM feedback_entries WHERE id = ?").get(feedbackId);
if (!row || row.deleted_at || row.status === "deleted") {
return null;
}
if (!isAdmin && row.submitter_id !== userId) {
return null;
}
return db.prepare("SELECT * FROM feedback_attachments WHERE feedback_id = ? AND id = ?").get(feedbackId, attachmentId) || null;
}
function markFeedbackViewed(userId) {
@ -407,15 +585,17 @@ function normalizeFeedbackInput(input = {}) {
const category = FEEDBACK_CATEGORIES.includes(input.category) ? input.category : "other";
const severity = FEEDBACK_SEVERITIES.includes(input.severity) ? input.severity : "minor";
const scopeType = FEEDBACK_SCOPE_TYPES.includes(input.scope_type) ? input.scope_type : "page";
const targetMetadata = sanitizeJsonObject(input.target_metadata, sanitizeTargetMetadata);
const pageTitle = cleanText(input.page_title, 240);
return {
summary,
category,
severity,
scope_type: scopeType,
scope_label: cleanText(input.scope_label, 240),
target_metadata: sanitizeJsonObject(input.target_metadata, sanitizeTargetMetadata),
scope_label: deriveScopeLabel(scopeType, targetMetadata, pageTitle, input.current_url),
target_metadata: targetMetadata,
current_url: cleanUrl(input.current_url),
page_title: cleanText(input.page_title, 240),
page_title: pageTitle,
description,
steps_to_reproduce: cleanText(input.steps_to_reproduce, 4000),
expected_behavior: cleanText(input.expected_behavior, 4000),
@ -424,6 +604,102 @@ function normalizeFeedbackInput(input = {}) {
};
}
function deriveScopeLabel(scopeType, metadata, pageTitle, currentUrl) {
const pageLabel = pageTitle || pagePathLabel(currentUrl) || "this page";
const elementLabel = metadata.label || metadata.text || metadata.aria_label || metadata.title || metadata.selector;
if (scopeType === "element") {
return cleanText(elementLabel ? `Clicked element: ${elementLabel}` : `Clicked element on ${pageLabel}`, 240);
}
if (scopeType === "feature") return cleanText(`Feature/page: ${pageLabel}`, 240);
if (scopeType === "plugin") {
const plugin = pluginLabelFromUrl(currentUrl);
return cleanText(plugin ? `Plugin: ${plugin}` : `Plugin-related feedback on ${pageLabel}`, 240);
}
if (scopeType === "system") return cleanText(`System area: ${pageLabel}`, 240);
if (scopeType === "other") return cleanText(`Other feedback on ${pageLabel}`, 240);
return cleanText(`Whole page: ${pageLabel}`, 240);
}
function pluginLabelFromUrl(value) {
const raw = cleanText(value, 1000);
try {
const url = new URL(raw, "http://localhost");
const match = url.pathname.match(/\/plugins\/([^/]+)/);
return match ? match[1].replace(/[_-]+/g, " ") : "";
} catch {
return "";
}
}
function pagePathLabel(value) {
const raw = cleanText(value, 1000);
try {
const url = new URL(raw, "http://localhost");
return url.pathname === "/" ? "Home" : url.pathname.replace(/^\/+/, "").replace(/[/_-]+/g, " ");
} catch {
return "";
}
}
function pagePathKey(value) {
const raw = cleanText(value, 1000);
try {
const url = new URL(raw, "http://localhost");
return url.pathname.replace(/\/+$/, "") || "/";
} catch {
return "";
}
}
function tokenSet(value) {
return new Set(
cleanText(value, 500)
.toLowerCase()
.split(/[^a-z0-9]+/)
.filter((token) => token.length >= 3)
);
}
function jaccardScore(a, b) {
if (!a.size || !b.size) return 0;
let intersection = 0;
for (const token of a) {
if (b.has(token)) intersection += 1;
}
return intersection / new Set([...a, ...b]).size;
}
function supportSummary(ids, userId) {
const cleanIds = [...new Set((ids || []).filter(Boolean))];
const counts = new Map();
const mine = new Set();
if (!cleanIds.length) {
return { counts, mine };
}
const placeholders = cleanIds.map(() => "?").join(",");
db.prepare(
`SELECT feedback_id, COUNT(*) AS count FROM feedback_support WHERE feedback_id IN (${placeholders}) GROUP BY feedback_id`
)
.all(...cleanIds)
.forEach((row) => counts.set(row.feedback_id, row.count));
if (userId) {
db.prepare(
`SELECT feedback_id FROM feedback_support WHERE user_id = ? AND feedback_id IN (${placeholders})`
)
.all(userId, ...cleanIds)
.forEach((row) => mine.add(row.feedback_id));
}
return { counts, mine };
}
function parseDateBoundary(value, edge) {
const raw = cleanText(value, 40);
if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) return null;
const date = new Date(`${raw}T${edge === "end" ? "23:59:59.999" : "00:00:00.000"}`);
const time = date.getTime();
return Number.isFinite(time) ? time : null;
}
function enforceRateLimit(userId) {
const cutoff = Date.now() - RATE_LIMIT.windowMs;
const count = db
@ -441,6 +717,14 @@ function hydrateFeedback(row, { admin }) {
...decorateLabels(row),
target_metadata: parseJson(row.target_metadata_json, {}),
diagnostics: admin ? parseJson(row.diagnostics_json, {}) : {},
screenshot: row.screenshot_path
? {
path: row.screenshot_path,
mime: row.screenshot_mime || "image/png",
size: row.screenshot_size || 0
}
: null,
attachments: attachmentsFor(row.id),
comments: commentsFor(row.id, admin),
history: statusHistoryFor(row.id)
};
@ -453,6 +737,21 @@ function hydrateFeedback(row, { admin }) {
return parsed;
}
function attachmentsFor(feedbackId) {
return db
.prepare("SELECT * FROM feedback_attachments WHERE feedback_id = ? ORDER BY created_at ASC")
.all(feedbackId)
.map((row) => ({
id: row.id,
storage_path: row.storage_path,
original_name: row.original_name || "attachment",
mime: row.mime,
size: row.size,
kind: row.kind,
created_at: row.created_at
}));
}
function commentsFor(feedbackId, admin) {
return db
.prepare(
@ -502,11 +801,6 @@ function decorateLabels(row) {
};
}
function defaultTodoText(entry) {
const url = entry.current_url ? ` (${entry.current_url})` : "";
return `[Feedback ${entry.id.slice(0, 8)}] ${entry.summary}${url}: ${entry.description.slice(0, 300)}`;
}
function sanitizeTargetMetadata(value) {
return {
selector: cleanText(value.selector, 500),
@ -527,10 +821,57 @@ function sanitizeDiagnostics(value) {
return {
user_agent: cleanText(value.user_agent, 500),
viewport: cleanText(value.viewport, 80),
language: cleanText(value.language, 80)
language: cleanText(value.language, 80),
dom_snapshot: cleanText(value.dom_snapshot, 6000),
screenshot_mode: cleanText(value.screenshot_mode, 40),
screenshot_source: cleanText(value.screenshot_source, 40)
};
}
function normalizeScreenshot(value = {}) {
const pathValue = cleanText(value.path, 500);
const mime = cleanText(value.mime, 80);
const size = Number(value.size || 0);
if (!pathValue) {
return { path: null, mime: null, size: null };
}
if (!/^feedback\/screenshots\/[a-zA-Z0-9_.-]+$/.test(pathValue)) {
throw new Error("Invalid screenshot storage path.");
}
if (!["image/png", "image/jpeg", "image/webp"].includes(mime)) {
throw new Error("Unsupported screenshot type.");
}
if (!Number.isFinite(size) || size <= 0 || size > 8 * 1024 * 1024) {
throw new Error("Screenshot file size is invalid.");
}
return { path: pathValue, mime, size };
}
function normalizeAttachments(values = []) {
const entries = Array.isArray(values) ? values : [];
return entries.map((value) => {
const pathValue = cleanText(value.path, 500);
const mime = cleanText(value.mime, 80);
const size = Number(value.size || 0);
if (!/^feedback\/attachments\/[a-zA-Z0-9_.-]+$/.test(pathValue)) {
throw new Error("Invalid attachment storage path.");
}
if (!["image/png", "image/jpeg", "image/webp", "application/pdf", "text/plain"].includes(mime)) {
throw new Error("Unsupported attachment type.");
}
if (!Number.isFinite(size) || size <= 0 || size > 8 * 1024 * 1024) {
throw new Error("Attachment file size is invalid.");
}
return {
path: pathValue,
mime,
size,
original_name: cleanText(value.original_name, 240) || "attachment",
kind: "attachment"
};
});
}
function sanitizeJsonObject(value, sanitizer) {
const object = typeof value === "object" && value && !Array.isArray(value) ? value : {};
return removeEmptyFields(sanitizer(object));
@ -568,6 +909,7 @@ function cleanUrl(value) {
function commentKindLabel(kind) {
return {
submitter_comment: "Submitter comment",
public_comment: "Community comment",
admin_reply: "Admin reply",
work_note: "Private work note"
}[kind] || kind;
@ -591,15 +933,20 @@ module.exports = {
FEEDBACK_STATUSES,
USER_VISIBLE_STATUSES,
adminUpdateFeedback,
convertFeedbackToTodo,
cleanupFeedback,
deleteFeedback,
createFeedback,
findSimilarFeedback,
feedbackOptions,
getFeedbackForAdmin,
getFeedbackAttachment,
getFeedbackForSubmitter,
getFeedbackForViewer,
listFeedbackForAdmin,
listMyFeedback,
listPublicFeedback,
markFeedbackViewed,
notificationSummary,
supportFeedback,
addSubmitterComment
};

View File

@ -951,6 +951,17 @@
const contextMenu = document.querySelector("[data-lumi-context-menu]");
let contextTarget = null;
let highlightedFeedbackTarget = null;
let contextClipboardText = "";
let contextTooltipTimer = null;
let contextTooltip = null;
let activeFeedbackTarget = null;
let activeFeedbackMetadata = {};
let attachedScreenshotBlob = null;
let attachedScreenshotUrl = "";
let attachedScreenshotSource = "";
let attachedScreenshotName = "feedback-screenshot.png";
let feedbackSimilarTimer = null;
let feedbackSimilarController = null;
const parseJson = (value, fallback = {}) => {
try {
@ -980,6 +991,35 @@
setSelectOptions(feedbackForm.querySelector("[data-feedback-scope-type]"), feedbackOptions.scopes, "page");
}
const scopeLabelForValue = (value) => {
const option = (feedbackOptions.scopes || []).find((entry) => entry.value === value);
return option?.label || value;
};
const screenshotShortcutHint = () => {
const platform = `${navigator.userAgentData?.platform || navigator.platform || ""}`.toLowerCase();
if (platform.includes("mac")) {
return "Press Shift + Command + 4 to take a selected-area screenshot";
}
if (platform.includes("linux")) {
return "Press Print Screen or Shift + Print Screen to take a screenshot";
}
if (platform.includes("iphone") || platform.includes("ipad")) {
return "Use your device screenshot buttons, then choose the saved image";
}
if (platform.includes("android")) {
return "Use your device screenshot buttons, then choose the saved image";
}
return "Press Win + Shift + S to take a snippet screenshot";
};
if (feedbackForm) {
const keybindHint = feedbackForm.querySelector("[data-feedback-screenshot-keybind]");
if (keybindHint) {
keybindHint.textContent = screenshotShortcutHint();
}
}
const sensitiveSelector = [
"input[type='password']",
"input[type='hidden']",
@ -1049,6 +1089,28 @@
return safeText(h1?.innerText || document.title, 180);
};
const generatedScopeLabel = (scope, metadata = {}) => {
const pageLabel = currentPageLabel() || "this page";
const elementLabel = metadata.label || metadata.text || metadata.aria_label || metadata.title || metadata.selector;
if (scope === "element") return elementLabel ? `Clicked element: ${elementLabel}` : `Clicked element on ${pageLabel}`;
if (scope === "feature") return `Feature/page: ${pageLabel}`;
if (scope === "plugin") {
const pluginMatch = window.location.pathname.match(/\/plugins\/([^/]+)/);
return pluginMatch ? `Plugin: ${pluginMatch[1].replaceAll("_", " ")}` : `Plugin-related feedback on ${pageLabel}`;
}
if (scope === "system") return `System area: ${pageLabel}`;
if (scope === "other") return `Other feedback on ${pageLabel}`;
return `Whole page: ${pageLabel}`;
};
const updateGeneratedScopeLabel = () => {
if (!feedbackForm) return;
const scopeSelect = feedbackForm.querySelector("[data-feedback-scope-type]");
const scopeLabel = feedbackForm.querySelector("[data-feedback-scope-label]");
if (!scopeSelect || !scopeLabel) return;
scopeLabel.value = generatedScopeLabel(scopeSelect.value, activeFeedbackMetadata);
};
const clearFeedbackHighlight = () => {
highlightedFeedbackTarget?.classList?.remove("feedback-target-highlight");
highlightedFeedbackTarget = null;
@ -1059,23 +1121,24 @@
hideContextMenu();
clearFeedbackHighlight();
feedbackForm.reset();
clearAttachedScreenshot();
setSelectOptions(feedbackForm.querySelector("[data-feedback-category]"), feedbackOptions.categories, "bug");
setSelectOptions(feedbackForm.querySelector("[data-feedback-severity]"), feedbackOptions.severities, "minor");
setSelectOptions(feedbackForm.querySelector("[data-feedback-scope-type]"), feedbackOptions.scopes, scope);
const metadata = target ? feedbackTargetMetadata(target) : {};
activeFeedbackTarget = target;
activeFeedbackMetadata = metadata;
const targetInput = feedbackForm.querySelector("[data-feedback-target-metadata]");
const urlInput = feedbackForm.querySelector("[data-feedback-current-url]");
const titleInput = feedbackForm.querySelector("[data-feedback-page-title]");
const diagnosticsInput = feedbackForm.querySelector("[data-feedback-diagnostics]");
const scopeLabel = feedbackForm.querySelector("[data-feedback-scope-label]");
const status = feedbackForm.querySelector("[data-feedback-status]");
if (targetInput) targetInput.value = JSON.stringify(metadata);
if (urlInput) urlInput.value = window.location.href;
if (titleInput) titleInput.value = document.title;
if (diagnosticsInput) diagnosticsInput.value = "{}";
if (scopeLabel) scopeLabel.value = scope === "element"
? (metadata.label || metadata.text || metadata.selector || "Clicked element")
: currentPageLabel();
updateGeneratedScopeLabel();
clearSimilarFeedback();
if (status) status.textContent = "";
if (target && scope === "element") {
highlightedFeedbackTarget = target;
@ -1091,6 +1154,8 @@
feedbackModal.classList.remove("is-open");
feedbackModal.setAttribute("aria-hidden", "true");
clearFeedbackHighlight();
activeFeedbackTarget = null;
activeFeedbackMetadata = {};
};
document.addEventListener("click", (event) => {
@ -1106,28 +1171,174 @@
}
});
feedbackForm?.querySelector("[data-feedback-scope-type]")?.addEventListener("change", updateGeneratedScopeLabel);
["input", "change"].forEach((eventName) => {
feedbackForm?.addEventListener(eventName, (event) => {
if (!event.target.matches?.("input[name='summary'], textarea[name='description'], select[name='category'], select[name='scope_type']")) return;
scheduleSimilarFeedbackLookup();
});
});
feedbackForm?.addEventListener("click", async (event) => {
const supportButton = event.target.closest("[data-feedback-support-similar]");
if (!supportButton) return;
event.preventDefault();
supportButton.disabled = true;
try {
const response = await fetch(`/api/feedback/${encodeURIComponent(supportButton.dataset.feedbackSupportSimilar)}/support`, {
method: "POST",
headers: { Accept: "application/json" }
});
const result = await response.json();
if (!response.ok) throw new Error(result.error || "Could not mark this as affecting you.");
supportButton.textContent = `Affects me too (${result.support_count})`;
} catch (error) {
supportButton.disabled = false;
supportButton.textContent = error.message || "Could not mark";
}
});
feedbackForm?.querySelector("[data-feedback-screenshot-file]")?.addEventListener("change", (event) => {
const file = event.target.files?.[0];
if (file) {
setAttachedScreenshot(file, "upload", file.name || "feedback-screenshot.png");
}
event.target.value = "";
});
feedbackForm?.querySelector("[data-feedback-paste-screenshot]")?.addEventListener("click", async () => {
await pasteFeedbackScreenshot();
});
feedbackModal?.addEventListener("paste", (event) => {
const file = imageFileFromClipboardData(event.clipboardData);
if (!file) return;
event.preventDefault();
setAttachedScreenshot(file, "paste", file.name || "feedback-screenshot.png");
});
feedbackForm?.querySelector("[data-feedback-remove-screenshot]")?.addEventListener("click", clearAttachedScreenshot);
function clearSimilarFeedback() {
feedbackSimilarController?.abort?.();
feedbackSimilarController = null;
window.clearTimeout(feedbackSimilarTimer);
const box = feedbackForm?.querySelector("[data-feedback-similar]");
const list = feedbackForm?.querySelector("[data-feedback-similar-list]");
if (box) box.hidden = true;
if (list) list.replaceChildren();
}
function scheduleSimilarFeedbackLookup() {
window.clearTimeout(feedbackSimilarTimer);
feedbackSimilarTimer = window.setTimeout(loadSimilarFeedback, 450);
}
async function loadSimilarFeedback() {
if (!feedbackForm) return;
const summary = feedbackForm.querySelector("input[name='summary']")?.value || "";
const description = feedbackForm.querySelector("textarea[name='description']")?.value || "";
if (summary.trim().length < 6 && description.trim().length < 12) {
clearSimilarFeedback();
return;
}
feedbackSimilarController?.abort?.();
feedbackSimilarController = new AbortController();
const params = new URLSearchParams({
summary,
description,
category: feedbackForm.querySelector("select[name='category']")?.value || "",
scope_type: feedbackForm.querySelector("select[name='scope_type']")?.value || "",
current_url: window.location.href,
limit: "4"
});
try {
const response = await fetch(`/api/feedback/similar?${params.toString()}`, {
headers: { Accept: "application/json" },
signal: feedbackSimilarController.signal
});
const result = await response.json();
if (!response.ok) throw new Error(result.error || "Could not load similar feedback.");
renderSimilarFeedback(result.matches || []);
} catch (error) {
if (error.name !== "AbortError") {
renderSimilarFeedback([]);
}
}
}
function renderSimilarFeedback(matches) {
const box = feedbackForm?.querySelector("[data-feedback-similar]");
const list = feedbackForm?.querySelector("[data-feedback-similar-list]");
if (!box || !list) return;
list.replaceChildren();
if (!matches.length) {
box.hidden = true;
return;
}
matches.forEach((item) => {
const row = document.createElement("article");
row.className = "feedback-similar-item";
const title = document.createElement("strong");
title.textContent = item.summary;
const meta = document.createElement("span");
meta.className = "hint";
meta.textContent = `${item.category_label} · ${item.scope_label_display} · ${item.status_label} · ${item.support_count || 0} also affected`;
const actions = document.createElement("div");
actions.className = "button-group";
const open = document.createElement("a");
open.className = "button subtle";
open.href = `/feedback?id=${encodeURIComponent(item.id)}`;
open.textContent = "Open";
const support = document.createElement("button");
support.type = "button";
support.className = "button subtle";
support.dataset.feedbackSupportSimilar = item.id;
support.disabled = Boolean(item.supported_by_me);
support.textContent = item.supported_by_me ? "Affects me too" : "This affects me too";
actions.append(open, support);
row.append(title, meta, actions);
list.append(row);
});
box.hidden = false;
}
feedbackForm?.addEventListener("submit", async (event) => {
event.preventDefault();
const submitter = feedbackForm.querySelector("button[type='submit']");
const status = feedbackForm.querySelector("[data-feedback-status]");
const includeBrowser = feedbackForm.querySelector("[data-feedback-include-browser]")?.checked;
const includeDom = feedbackForm.querySelector("[data-feedback-include-dom]")?.checked;
const formData = new FormData(feedbackForm);
const payload = Object.fromEntries(formData.entries());
payload.target_metadata = parseJson(payload.target_metadata, {});
payload.diagnostics = includeBrowser
? {
user_agent: navigator.userAgent,
viewport: `${window.innerWidth}x${window.innerHeight}`,
language: navigator.language || ""
}
: {};
formData.set("target_metadata", JSON.stringify(parseJson(formData.get("target_metadata"), {})));
formData.set("diagnostics", JSON.stringify({
...(includeBrowser
? {
user_agent: navigator.userAgent,
viewport: `${window.innerWidth}x${window.innerHeight}`,
language: navigator.language || ""
}
: {}),
...(includeDom
? { dom_snapshot: sanitizedDomSnapshot(activeFeedbackTarget || document.querySelector("main") || document.body) }
: {}),
...(attachedScreenshotBlob
? {
screenshot_mode: "user_attached",
screenshot_source: attachedScreenshotSource
}
: {})
}));
if (attachedScreenshotBlob) {
formData.set("screenshot", attachedScreenshotBlob, attachedScreenshotName);
}
submitter.disabled = true;
if (status) status.textContent = "Submitting feedback...";
try {
const response = await fetch("/api/feedback", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify(payload)
headers: { Accept: "application/json" },
body: formData
});
const result = await response.json();
if (!response.ok) throw new Error(result.error || "Feedback could not be submitted.");
@ -1142,35 +1353,221 @@
}
});
const isEditableTarget = (target) => {
function sanitizedDomSnapshot(root) {
const lines = [];
const maxNodes = 80;
const maxDepth = 4;
const skipTags = new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "SVG"]);
const walk = (node, depth = 0) => {
if (!node || lines.length >= maxNodes || depth > maxDepth) return;
if (node.nodeType !== 1 || skipTags.has(node.tagName)) return;
const indent = " ".repeat(depth);
const tag = node.tagName.toLowerCase();
const attrs = [];
if (node.id) attrs.push(`#${node.id.slice(0, 60)}`);
if (node.classList?.length) attrs.push(`.${Array.from(node.classList).slice(0, 3).join(".").slice(0, 80)}`);
const role = node.getAttribute?.("role");
const label = node.getAttribute?.("aria-label") || node.getAttribute?.("title");
if (role) attrs.push(`role=${role.slice(0, 40)}`);
if (label) attrs.push(`label="${safeText(label, 80)}"`);
const sensitive = node.matches?.(sensitiveSelector) || ["INPUT", "TEXTAREA", "SELECT"].includes(node.tagName);
const text = sensitive ? "" : safeElementText(node);
lines.push(`${indent}<${tag}${attrs.length ? ` ${attrs.join(" ")}` : ""}>${text ? ` ${safeText(text, 120)}` : ""}`);
Array.from(node.children || []).slice(0, 10).forEach((child) => walk(child, depth + 1));
};
walk(root);
return lines.join("\n").slice(0, 6000);
}
function clearAttachedScreenshot() {
if (attachedScreenshotUrl) {
URL.revokeObjectURL(attachedScreenshotUrl);
}
attachedScreenshotBlob = null;
attachedScreenshotUrl = "";
attachedScreenshotSource = "";
attachedScreenshotName = "feedback-screenshot.png";
const preview = feedbackForm?.querySelector("[data-feedback-screenshot-preview]");
const image = feedbackForm?.querySelector("[data-feedback-screenshot-image]");
const meta = feedbackForm?.querySelector("[data-feedback-screenshot-meta]");
const removeButton = feedbackForm?.querySelector("[data-feedback-remove-screenshot]");
const screenshotStatus = feedbackForm?.querySelector("[data-feedback-screenshot-status]");
if (preview) preview.hidden = true;
if (image) image.removeAttribute("src");
if (meta) meta.textContent = "";
if (removeButton) removeButton.hidden = true;
if (screenshotStatus) screenshotStatus.textContent = "";
}
async function pasteFeedbackScreenshot() {
const screenshotStatus = feedbackForm?.querySelector("[data-feedback-screenshot-status]");
if (screenshotStatus) {
screenshotStatus.textContent = "Looking for an image in the clipboard...";
}
try {
if (!navigator.clipboard?.read || !window.isSecureContext) {
throw new Error("Clipboard image read is not available.");
}
const items = await navigator.clipboard.read();
for (const item of items) {
const type = item.types.find((entry) => entry.startsWith("image/"));
if (type) {
const blob = await item.getType(type);
setAttachedScreenshot(blob, "paste", `feedback-screenshot${screenshotExtension(type)}`);
return;
}
}
throw new Error("No image was found in the clipboard.");
} catch (error) {
if (screenshotStatus) {
screenshotStatus.textContent = `${error.message || "Clipboard image could not be read."} You can also copy a screenshot and press Ctrl+V while this dialog is open.`;
}
}
}
function imageFileFromClipboardData(data) {
const files = Array.from(data?.files || []);
const file = files.find((entry) => entry.type?.startsWith("image/"));
if (file) return file;
const items = Array.from(data?.items || []);
const item = items.find((entry) => entry.type?.startsWith("image/"));
return item?.getAsFile?.() || null;
}
function screenshotExtension(mime) {
if (mime === "image/jpeg") return ".jpg";
if (mime === "image/webp") return ".webp";
return ".png";
}
function screenshotLabel(source) {
return source === "paste" ? "Pasted screenshot" : "Uploaded screenshot";
}
function setAttachedScreenshot(blob, source, name = "feedback-screenshot.png") {
const screenshotStatus = feedbackForm?.querySelector("[data-feedback-screenshot-status]");
if (!["image/png", "image/jpeg", "image/webp"].includes(blob.type)) {
if (screenshotStatus) screenshotStatus.textContent = "Only PNG, JPEG, or WebP screenshots can be attached.";
return;
}
if (blob.size > 8 * 1024 * 1024) {
if (screenshotStatus) screenshotStatus.textContent = "Screenshot must be 8 MB or smaller.";
return;
}
clearAttachedScreenshot();
attachedScreenshotBlob = blob;
attachedScreenshotUrl = URL.createObjectURL(blob);
attachedScreenshotSource = source;
attachedScreenshotName = name || `feedback-screenshot${screenshotExtension(blob.type)}`;
const preview = feedbackForm?.querySelector("[data-feedback-screenshot-preview]");
const image = feedbackForm?.querySelector("[data-feedback-screenshot-image]");
const meta = feedbackForm?.querySelector("[data-feedback-screenshot-meta]");
const removeButton = feedbackForm?.querySelector("[data-feedback-remove-screenshot]");
const status = feedbackForm?.querySelector("[data-feedback-screenshot-status]");
if (image && attachedScreenshotUrl) {
image.src = attachedScreenshotUrl;
}
if (meta) {
const sizeKb = Math.max(1, Math.round(blob.size / 1024));
meta.textContent = `${screenshotLabel(source)} · ${blob.type.replace("image/", "").toUpperCase()} · ${sizeKb} KB`;
}
if (preview) preview.hidden = !attachedScreenshotUrl;
if (removeButton) removeButton.hidden = false;
if (status) status.textContent = "Screenshot attached. Review the preview and remove it if it contains sensitive information.";
}
const editableElementForTarget = (target) => {
const element = target?.closest?.("input, textarea, [contenteditable='true']");
return element && !element.matches("[readonly], [disabled]");
if (!element || element.matches("[readonly], [disabled]")) return null;
const inputType = String(element.getAttribute("type") || "").toLowerCase();
if (["button", "checkbox", "color", "file", "image", "radio", "range", "reset", "submit"].includes(inputType)) {
return null;
}
return element;
};
const updateContextMenuItems = (target) => {
const selectedTextForTarget = (target) => {
const editable = editableElementForTarget(target);
if (editable && typeof editable.selectionStart === "number" && typeof editable.selectionEnd === "number") {
return editable.value.slice(editable.selectionStart, editable.selectionEnd);
}
return window.getSelection?.().toString() || "";
};
const hasInputSelection = (target) => {
const editable = editableElementForTarget(target);
if (!editable) return false;
if (typeof editable.selectionStart === "number" && typeof editable.selectionEnd === "number") {
return editable.selectionEnd > editable.selectionStart;
}
const selection = window.getSelection?.();
return Boolean(selection && !selection.isCollapsed && editable.contains(selection.anchorNode));
};
const contextImageForTarget = (target) => target?.closest?.("img") || null;
const readClipboardText = async () => {
try {
if (!navigator.clipboard?.readText || !window.isSecureContext) return "";
return await navigator.clipboard.readText();
} catch {
return "";
}
};
const setContextActionEnabled = (action, enabled) => {
const button = contextMenu?.querySelector(`[data-context-action="${action}"]`);
if (!button) return;
button.setAttribute("aria-disabled", enabled ? "false" : "true");
button.classList.toggle("is-disabled", !enabled);
button.tabIndex = enabled ? 0 : -1;
};
const enabledContextButtons = () =>
Array.from(contextMenu?.querySelectorAll("[data-context-action]:not(.is-disabled)") || []);
const focusContextButton = (offset) => {
const buttons = enabledContextButtons();
if (!buttons.length) return;
const current = buttons.indexOf(document.activeElement);
const next = current === -1 ? 0 : (current + offset + buttons.length) % buttons.length;
buttons[next].focus();
};
const updateContextMenuItems = async (target) => {
if (!contextMenu) return;
const editable = Boolean(isEditableTarget(target));
contextMenu.querySelector("[data-context-action='cut']")?.toggleAttribute("hidden", !editable);
contextMenu.querySelector("[data-context-action='paste']")?.toggleAttribute("hidden", !editable);
contextMenu.querySelector("[data-context-action='copy']")?.toggleAttribute("hidden", false);
const active = document.activeElement;
const pasteTarget = editableElementForTarget(active);
const selectedText = selectedTextForTarget(target).trim();
const image = contextImageForTarget(target);
contextClipboardText = pasteTarget ? await readClipboardText() : "";
setContextActionEnabled("back", window.history.length > 1);
setContextActionEnabled("forward", Boolean(window.navigation?.canGoForward));
setContextActionEnabled("copy", Boolean(selectedText || image));
setContextActionEnabled("cut", hasInputSelection(target));
setContextActionEnabled("paste", Boolean(pasteTarget && contextClipboardText.length > 0));
setContextActionEnabled("link", true);
setContextActionEnabled("reload", true);
setContextActionEnabled("feedback", true);
};
const showContextMenu = (event) => {
const showContextMenu = async (event) => {
if (!contextMenu || event.shiftKey) return;
event.preventDefault();
contextTarget = event.target;
updateContextMenuItems(contextTarget);
contextMenu.hidden = false;
await updateContextMenuItems(contextTarget);
const width = contextMenu.offsetWidth || 180;
const height = contextMenu.offsetHeight || 260;
contextMenu.style.left = `${Math.min(event.clientX, window.innerWidth - width - 8)}px`;
contextMenu.style.top = `${Math.min(event.clientY, window.innerHeight - height - 8)}px`;
contextMenu.querySelector("button:not([hidden])")?.focus();
contextMenu.querySelector("button:not(.is-disabled)")?.focus();
};
function hideContextMenu() {
if (!contextMenu) return;
contextMenu.hidden = true;
hideContextTooltip();
}
const linkForTarget = (target) => {
@ -1180,19 +1577,49 @@
return url.toString();
};
const runContextAction = async (action) => {
const editable = isEditableTarget(contextTarget);
const copyImage = async (image) => {
if (!image) return false;
try {
if (!navigator.clipboard?.write || !window.ClipboardItem || !window.isSecureContext) return false;
const response = await fetch(image.currentSrc || image.src);
const blob = await response.blob();
await navigator.clipboard.write([new ClipboardItem({ [blob.type || "image/png"]: blob })]);
return true;
} catch {
return false;
}
};
const insertTextIntoEditable = (editable, text) => {
if (!editable || !text) return;
editable.focus();
if (typeof editable.selectionStart === "number" && typeof editable.selectionEnd === "number") {
const start = editable.selectionStart;
const end = editable.selectionEnd;
editable.setRangeText(text, start, end, "end");
editable.dispatchEvent(new Event("input", { bubbles: true }));
return;
}
document.execCommand("insertText", false, text);
};
const runContextAction = async (action, button) => {
if (button?.classList.contains("is-disabled") || button?.getAttribute("aria-disabled") === "true") return;
const editable = editableElementForTarget(contextTarget) || editableElementForTarget(document.activeElement);
try {
if (action === "back") window.history.back();
if (action === "forward") window.history.forward();
if (action === "forward") {
if (window.navigation?.canGoForward) window.history.forward();
}
if (action === "copy") {
const selection = window.getSelection?.().toString();
await copyText(selection || linkForTarget(contextTarget));
const selectedText = selectedTextForTarget(contextTarget);
if (selectedText) await copyText(selectedText);
else await copyImage(contextImageForTarget(contextTarget));
}
if (action === "cut" && editable) document.execCommand("cut");
if (action === "paste" && editable) {
const text = await navigator.clipboard?.readText?.();
if (text !== undefined) document.execCommand("insertText", false, text);
const text = contextClipboardText || await readClipboardText();
insertTextIntoEditable(editable, text);
}
if (action === "link") await copyText(linkForTarget(contextTarget));
if (action === "reload") window.location.reload(true);
@ -1212,13 +1639,91 @@
return;
}
event.preventDefault();
runContextAction(button.dataset.contextAction);
runContextAction(button.dataset.contextAction, button);
});
contextMenu?.addEventListener("pointerover", (event) => {
const button = event.target.closest("[data-context-action]");
if (!button || !contextMenu.contains(button)) return;
window.clearTimeout(contextTooltipTimer);
contextTooltipTimer = window.setTimeout(() => showContextTooltip(button), 1000);
});
contextMenu?.addEventListener("pointerout", (event) => {
const button = event.target.closest("[data-context-action]");
if (!button) return;
const next = event.relatedTarget;
if (next && (button.contains(next) || contextTooltip?.contains(next))) return;
window.clearTimeout(contextTooltipTimer);
hideContextTooltip();
});
document.addEventListener("keydown", (event) => {
if (!contextMenu?.hidden && contextMenu?.contains(document.activeElement)) {
if (event.key === "ArrowDown") {
event.preventDefault();
focusContextButton(1);
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
focusContextButton(-1);
return;
}
if (event.key === "Home") {
event.preventDefault();
enabledContextButtons()[0]?.focus();
return;
}
if (event.key === "End") {
event.preventDefault();
enabledContextButtons().at(-1)?.focus();
return;
}
if (event.key === "Enter" || event.key === " ") {
const button = document.activeElement?.closest?.("[data-context-action]");
if (button) {
event.preventDefault();
runContextAction(button.dataset.contextAction, button);
return;
}
}
if (event.key === "Tab") {
event.preventDefault();
focusContextButton(event.shiftKey ? -1 : 1);
return;
}
}
if (event.key === "Escape") {
hideContextMenu();
closeFeedbackModal();
}
});
window.addEventListener("blur", hideContextMenu);
function showContextTooltip(button) {
const text = button?.dataset.contextTooltip || "";
if (!text) return;
hideContextTooltip();
contextTooltip = document.createElement("div");
contextTooltip.className = "lumi-context-tooltip";
contextTooltip.textContent = text;
contextTooltip.setAttribute("role", "tooltip");
document.body.append(contextTooltip);
const buttonRect = button.getBoundingClientRect();
const tooltipRect = contextTooltip.getBoundingClientRect();
const padding = 8;
let left = buttonRect.right + padding;
let top = buttonRect.top + (buttonRect.height - tooltipRect.height) / 2;
if (left + tooltipRect.width > window.innerWidth - padding) {
left = buttonRect.left - tooltipRect.width - padding;
}
left = Math.max(padding, Math.min(left, window.innerWidth - tooltipRect.width - padding));
top = Math.max(padding, Math.min(top, window.innerHeight - tooltipRect.height - padding));
contextTooltip.style.left = `${left}px`;
contextTooltip.style.top = `${top}px`;
}
function hideContextTooltip() {
window.clearTimeout(contextTooltipTimer);
contextTooltip?.remove();
contextTooltip = null;
}
})();

View File

@ -351,6 +351,30 @@ button:disabled {
width: 100%;
}
.button-group {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: var(--lumi-space-2);
align-items: center;
}
.form-grid .field.button-group,
.field.button-group {
flex-direction: row;
}
.button-group.centered {
justify-content: center;
}
.button-group .button,
.button-group button.button,
.button-group input[type="submit"].button,
.button-group .lumi-state-btn {
flex: 0 0 auto;
}
.lumi-state-btn-spinner {
width: 1em;
height: 1em;
@ -1107,6 +1131,16 @@ input[type="color"] {
box-shadow: var(--lumi-shadow-lg);
}
.modal.feedback-modal {
width: min(50vw, 60rem);
min-width: min(92vw, 45rem);
max-height: 75vh;
}
.feedback-modal .modal-actions {
justify-content: center;
}
.modal-header h2,
.modal-header h3 {
margin-bottom: 0;
@ -1246,6 +1280,19 @@ details > summary {
padding: var(--lumi-space-3);
}
.feedback-modal-backdrop {
align-items: stretch;
padding: var(--lumi-space-2);
}
.modal.feedback-modal {
width: 100%;
min-width: 0;
max-height: calc(100dvh - (var(--lumi-space-2) * 2));
min-height: calc(100dvh - (var(--lumi-space-2) * 2));
padding: var(--lumi-space-3);
}
.hero {
padding: var(--lumi-space-4);
}

View File

@ -1285,16 +1285,97 @@ body {
text-align: center;
}
.feedback-modal {
max-width: 820px;
}
.feedback-submit-status {
min-height: 20px;
color: var(--ink-soft);
font-weight: 700;
}
.feedback-screenshot-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
justify-content: center;
}
.feedback-screenshot-actions label.button {
margin: 0;
cursor: pointer;
}
.feedback-screenshot-guide {
display: grid;
gap: 4px;
margin: 2px 0 10px;
padding: 12px;
border: 1px solid color-mix(in srgb, var(--sea) 35%, var(--border));
border-radius: 12px;
background: color-mix(in srgb, var(--sea) 9%, var(--surface-2));
}
.feedback-screenshot-guide p {
margin: 0;
max-width: none;
}
.feedback-screenshot-preview {
display: inline-flex;
gap: 10px;
align-items: center;
width: fit-content;
max-width: 100%;
margin-top: 10px;
padding: 10px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--surface-2);
}
.feedback-screenshot-preview[hidden] {
display: none;
}
.feedback-screenshot-preview img {
width: auto;
max-width: min(260px, 42vw);
max-height: 160px;
border-radius: 8px;
object-fit: contain;
background: var(--surface-3);
}
.feedback-screenshot-preview img:not([src]) {
display: none;
}
.feedback-similar {
padding: 12px;
border: 1px solid color-mix(in srgb, var(--sun) 40%, var(--border));
border-radius: 12px;
background: color-mix(in srgb, var(--sun) 8%, var(--surface-2));
}
.feedback-similar-list {
display: grid;
gap: 8px;
}
.feedback-similar-item {
display: grid;
gap: 6px;
padding: 10px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--surface-2);
}
.feedback-attachment-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.feedback-page-actions,
.feedback-badge-row,
.feedback-two-col,
@ -1418,7 +1499,8 @@ body {
}
.feedback-status-fixed,
.feedback-status-solved {
.feedback-status-solved,
.feedback-status-closed {
border-color: rgba(48, 170, 112, 0.45);
color: var(--success);
}
@ -1517,6 +1599,32 @@ body {
background: var(--surface-2);
}
.lumi-context-menu button.is-disabled {
color: var(--ink-soft);
cursor: not-allowed;
opacity: 0.48;
}
.lumi-context-menu button.is-disabled:hover,
.lumi-context-menu button.is-disabled:focus {
background: transparent;
}
.lumi-context-tooltip {
position: fixed;
z-index: 130;
max-width: min(280px, calc(100vw - 16px));
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--surface-3);
color: var(--ink);
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.26);
font-size: 13px;
line-height: 1.35;
pointer-events: none;
}
.checkbox-inline {
display: inline-flex;
gap: 8px;
@ -1786,6 +1894,33 @@ body .modal-backdrop.destructive-confirm-modal {
}
@media (max-width: 900px) {
.feedback-modal .form-grid {
grid-template-columns: 1fr;
}
.feedback-modal .field.full {
grid-column: auto;
}
.feedback-screenshot-actions,
.feedback-modal .modal-actions {
align-items: stretch;
flex-direction: column;
}
.feedback-screenshot-actions .button,
.feedback-modal .modal-actions .button {
width: 100%;
}
.feedback-screenshot-preview {
width: 100%;
}
.feedback-screenshot-preview img {
max-width: 100%;
}
.app-shell {
grid-template-columns: 1fr;
}

View File

@ -113,15 +113,21 @@ const {
const {
addSubmitterComment,
adminUpdateFeedback,
convertFeedbackToTodo,
cleanupFeedback,
createFeedback,
deleteFeedback,
findSimilarFeedback,
feedbackOptions,
getFeedbackForSubmitter,
getFeedbackForAdmin,
getFeedbackAttachment,
getFeedbackForViewer,
listFeedbackForAdmin,
listMyFeedback,
listPublicFeedback,
markFeedbackViewed,
notificationSummary
notificationSummary,
supportFeedback
} = require("../services/feedback");
function ensureSessionSecret() {
@ -1683,6 +1689,60 @@ function setFlash(req, type, message) {
req.session.flash = { type, message };
}
function screenshotExtension(mime) {
if (mime === "image/jpeg") return ".jpg";
if (mime === "image/webp") return ".webp";
return ".png";
}
function feedbackAttachmentExtension(file = {}) {
const original = path.extname(file.originalname || "").slice(0, 12).toLowerCase();
if ([".png", ".jpg", ".jpeg", ".webp", ".pdf", ".txt"].includes(original)) return original;
if (file.mimetype === "application/pdf") return ".pdf";
if (file.mimetype === "text/plain") return ".txt";
return screenshotExtension(file.mimetype);
}
function normalizeFeedbackRequestBody(body = {}) {
return {
...body,
target_metadata: parseBodyJson(body.target_metadata),
diagnostics: parseBodyJson(body.diagnostics)
};
}
function parseBodyJson(value) {
if (value && typeof value === "object") return value;
try {
const parsed = JSON.parse(String(value || "{}"));
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
} catch {
return {};
}
}
function resolveFeedbackScreenshotPath(relativePath, screenshotDir) {
const normalized = String(relativePath || "").replace(/\\/g, "/");
const prefix = "feedback/screenshots/";
if (!normalized.startsWith(prefix)) return null;
const fileName = path.basename(normalized.slice(prefix.length));
if (!fileName || fileName !== normalized.slice(prefix.length)) return null;
const target = path.resolve(screenshotDir, fileName);
const root = path.resolve(screenshotDir);
return target.startsWith(`${root}${path.sep}`) ? target : null;
}
function resolveFeedbackAttachmentPath(relativePath, attachmentDir) {
const normalized = String(relativePath || "").replace(/\\/g, "/");
const prefix = "feedback/attachments/";
if (!normalized.startsWith(prefix)) return null;
const fileName = path.basename(normalized.slice(prefix.length));
if (!fileName || fileName !== normalized.slice(prefix.length)) return null;
const target = path.resolve(attachmentDir, fileName);
const root = path.resolve(attachmentDir);
return target.startsWith(`${root}${path.sep}`) ? target : null;
}
function getThemeSettings() {
return getActiveTheme();
}
@ -2488,6 +2548,10 @@ function createWebServer({ loadPlugins, discordClient }) {
const navIconDir = path.join(__dirname, "..", "..", "data", "nav-icons");
fs.mkdirSync(navIconDir, { recursive: true });
app.use("/assets/nav-icons", express.static(navIconDir));
const feedbackScreenshotDir = path.join(__dirname, "..", "..", "data", "feedback", "screenshots");
fs.mkdirSync(feedbackScreenshotDir, { recursive: true });
const feedbackAttachmentDir = path.join(__dirname, "..", "..", "data", "feedback", "attachments");
fs.mkdirSync(feedbackAttachmentDir, { recursive: true });
const upload = multer ? multer({ dest: uploadDir }) : null;
const uploadSingle = (fieldName) => {
if (!upload) {
@ -2522,6 +2586,41 @@ function createWebServer({ loadPlugins, discordClient }) {
}
return navIconUpload.single("icon_file")(req, _res, next);
};
const feedbackScreenshotUpload =
multer &&
multer({
storage: multer.diskStorage({
destination: (_req, file, cb) => cb(null, file.fieldname === "attachments" ? feedbackAttachmentDir : feedbackScreenshotDir),
filename: (_req, file, cb) => {
const ext = file.fieldname === "attachments" ? feedbackAttachmentExtension(file) : screenshotExtension(file.mimetype);
cb(null, `${crypto.randomUUID()}${ext}`);
}
}),
limits: { fileSize: 8 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
if (file.fieldname === "screenshot" && ["image/png", "image/jpeg", "image/webp"].includes(file.mimetype)) {
return cb(null, true);
}
if (file.fieldname === "attachments" && ["image/png", "image/jpeg", "image/webp", "application/pdf", "text/plain"].includes(file.mimetype)) {
return cb(null, true);
}
cb(new Error("Only PNG, JPEG, WebP, PDF, or text feedback files are allowed."));
}
});
const feedbackUploadFields = (req, res, next) => {
if (!feedbackScreenshotUpload) {
return next();
}
return feedbackScreenshotUpload.fields([
{ name: "screenshot", maxCount: 1 },
{ name: "attachments", maxCount: 3 }
])(req, res, (error) => {
if (error) {
req.feedbackUploadError = error.message;
}
next();
});
};
const navItems = [];
const profileSections = [];
@ -2579,15 +2678,59 @@ function createWebServer({ loadPlugins, discordClient }) {
res.status(400).json({ error: error.message });
}
});
app.post("/api/feedback", requireAuth, (req, res) => {
app.get("/api/feedback/similar", requireAuth, (req, res) => {
try {
const entry = createFeedback(req.body, req.session.user);
res.json({
ok: true,
matches: findSimilarFeedback(req.query, {
userId: req.session.user.id,
limit: req.query.limit || 5
})
});
} catch (error) {
res.status(400).json({ error: error.message });
}
});
app.post("/api/feedback/:id/support", requireAuth, (req, res) => {
try {
const support_count = supportFeedback(req.params.id, req.session.user);
res.json({ ok: true, support_count });
} catch (error) {
res.status(400).json({ error: error.message });
}
});
app.post("/api/feedback", requireAuth, feedbackUploadFields, (req, res) => {
try {
if (req.feedbackUploadError) {
throw new Error(req.feedbackUploadError);
}
const body = normalizeFeedbackRequestBody(req.body);
const screenshotFile = req.files?.screenshot?.[0] || null;
const attachmentFiles = req.files?.attachments || [];
const entry = createFeedback(body, req.session.user, {
screenshot: screenshotFile
? {
path: `feedback/screenshots/${screenshotFile.filename}`,
mime: screenshotFile.mimetype,
size: screenshotFile.size
}
: null,
attachments: attachmentFiles.map((file) => ({
path: `feedback/attachments/${file.filename}`,
mime: file.mimetype,
size: file.size,
original_name: file.originalname
}))
});
res.status(201).json({
ok: true,
id: entry.id,
message: "Feedback submitted. You can review it from the Feedback page."
});
} catch (error) {
[...(req.files?.screenshot || []), ...(req.files?.attachments || [])].forEach((file) => {
if (file?.path) fs.rmSync(file.path, { force: true });
});
res.status(400).json({ error: error.message });
}
});
@ -3698,7 +3841,7 @@ function createWebServer({ loadPlugins, discordClient }) {
markFeedbackViewed(req.session.user.id);
res.locals.feedbackNotifications = notificationSummary(req.session.user.id);
const selected = req.query.id
? getFeedbackForSubmitter(String(req.query.id), req.session.user.id)
? getFeedbackForViewer(String(req.query.id), req.session.user.id)
: null;
res.render("feedback", {
title: "Feedback",
@ -3719,6 +3862,64 @@ function createWebServer({ loadPlugins, discordClient }) {
res.redirect(`/feedback?id=${encodeURIComponent(req.params.id)}`);
});
app.post("/feedback/:id/support", requireAuth, (req, res) => {
try {
supportFeedback(req.params.id, req.session.user);
setFlash(req, "success", "Feedback supported.");
} catch (error) {
setFlash(req, "error", error.message);
}
res.redirect(req.get("referer") || "/feedback");
});
app.get("/feedback/:id/screenshot", requireAuth, (req, res) => {
const entry = req.session.user?.isAdmin
? getFeedbackForAdmin(req.params.id)
: getFeedbackForSubmitter(req.params.id, req.session.user.id);
if (!entry?.screenshot?.path) {
return res.status(404).render("error", {
title: "Screenshot not found",
message: "That feedback screenshot is not available."
});
}
const screenshotPath = resolveFeedbackScreenshotPath(entry.screenshot.path, feedbackScreenshotDir);
if (!screenshotPath || !fs.existsSync(screenshotPath)) {
return res.status(404).render("error", {
title: "Screenshot not found",
message: "That feedback screenshot is no longer available."
});
}
res.setHeader("Content-Type", entry.screenshot.mime || "image/png");
res.setHeader("Cache-Control", "private, no-store");
res.sendFile(screenshotPath);
});
app.get("/feedback/:id/attachments/:attachmentId", requireAuth, (req, res) => {
const attachment = getFeedbackAttachment(
req.params.id,
req.params.attachmentId,
req.session.user.id,
Boolean(req.session.user?.isAdmin)
);
if (!attachment?.storage_path) {
return res.status(404).render("error", {
title: "Attachment not found",
message: "That feedback attachment is not available."
});
}
const attachmentPath = resolveFeedbackAttachmentPath(attachment.storage_path, feedbackAttachmentDir);
if (!attachmentPath || !fs.existsSync(attachmentPath)) {
return res.status(404).render("error", {
title: "Attachment not found",
message: "That feedback attachment is no longer available."
});
}
res.setHeader("Content-Type", attachment.mime || "application/octet-stream");
res.setHeader("Cache-Control", "private, no-store");
res.setHeader("Content-Disposition", `attachment; filename="${path.basename(attachment.original_name || "attachment")}"`);
res.sendFile(attachmentPath);
});
app.get("/health", (req, res) => {
res.set("Cache-Control", "no-store");
res.json({ ok: true, ts: Date.now() });
@ -4987,7 +5188,10 @@ function createWebServer({ loadPlugins, discordClient }) {
category: req.query.category || "",
severity: req.query.severity || "",
scope: req.query.scope || "",
area: req.query.area || "",
submitter: req.query.submitter || "",
date_from: req.query.date_from || "",
date_to: req.query.date_to || "",
needs_action: req.query.needs_action || "",
sort: req.query.sort || "last_activity"
},
@ -4997,18 +5201,94 @@ function createWebServer({ loadPlugins, discordClient }) {
app.post("/admin/feedback/:id", requireRole("admin"), (req, res) => {
try {
adminUpdateFeedback(req.params.id, req.body, req.session.user);
setFlash(req, "success", "Feedback updated.");
const body = { ...req.body };
if (body.review_action === "finalize") {
body.status = "closed";
body.status_note = body.status_note || "Finalized and closed.";
} else if (body.review_action === "reopen") {
body.status = "reviewed";
body.status_note = body.status_note || "Reopened for review.";
}
adminUpdateFeedback(req.params.id, body, req.session.user);
setFlash(req, "success", body.review_action === "finalize"
? "Feedback finalized and closed."
: body.review_action === "reopen"
? "Feedback reopened."
: "Feedback updated.");
} catch (error) {
setFlash(req, "error", error.message);
}
res.redirect(req.get("referer") || "/admin/feedback");
});
app.post("/admin/feedback/:id/todo", requireRole("admin"), (req, res) => {
app.post("/admin/feedback/:id/finalize", requireRole("admin"), (req, res) => {
try {
convertFeedbackToTodo(req.params.id, req.body.todo_text, req.session.user);
setFlash(req, "success", "Feedback converted to TODO.");
adminUpdateFeedback(req.params.id, {
...req.body,
status: "closed",
status_note: req.body.status_note || "Finalized and closed."
}, req.session.user);
setFlash(req, "success", "Feedback finalized and closed.");
} catch (error) {
setFlash(req, "error", error.message);
}
res.redirect(req.get("referer") || "/admin/feedback");
});
app.post("/admin/feedback/:id/reopen", requireRole("admin"), (req, res) => {
try {
adminUpdateFeedback(req.params.id, {
...req.body,
status: "reviewed",
status_note: req.body.status_note || "Reopened for review."
}, req.session.user);
setFlash(req, "success", "Feedback reopened.");
} catch (error) {
setFlash(req, "error", error.message);
}
res.redirect(req.get("referer") || "/admin/feedback");
});
app.post("/admin/feedback/:id/delete", requireRole("admin"), (req, res) => {
try {
deleteFeedback(req.params.id, {
deleteScreenshot(relativePath) {
const screenshotPath = resolveFeedbackScreenshotPath(relativePath, feedbackScreenshotDir);
if (screenshotPath) {
fs.rmSync(screenshotPath, { force: true });
}
},
deleteAttachment(relativePath) {
const attachmentPath = resolveFeedbackAttachmentPath(relativePath, feedbackAttachmentDir);
if (attachmentPath) {
fs.rmSync(attachmentPath, { force: true });
}
}
});
setFlash(req, "success", "Feedback permanently deleted.");
} catch (error) {
setFlash(req, "error", error.message);
}
res.redirect("/admin/feedback");
});
app.post("/admin/feedback/:id/cleanup", requireRole("admin"), (req, res) => {
try {
cleanupFeedback(req.params.id, req.body, req.session.user, {
deleteScreenshot(relativePath) {
const screenshotPath = resolveFeedbackScreenshotPath(relativePath, feedbackScreenshotDir);
if (screenshotPath) {
fs.rmSync(screenshotPath, { force: true });
}
},
deleteAttachment(relativePath) {
const attachmentPath = resolveFeedbackAttachmentPath(relativePath, feedbackAttachmentDir);
if (attachmentPath) {
fs.rmSync(attachmentPath, { force: true });
}
}
});
setFlash(req, "success", "Feedback data cleaned.");
} catch (error) {
setFlash(req, "error", error.message);
}

View File

@ -3,7 +3,7 @@
<%- include("partials/page-header", {
eyebrow: "Administration",
pageTitle: "Feedback review",
description: "Review core feedback, reply to submitters, record private work notes, and convert useful reports into TODO entries."
description: "Review core feedback, reply to submitters, record private work notes, and close or delete resolved reports."
}) %>
<form method="get" action="/admin/feedback" class="log-controls feedback-admin-filters">
<label>
@ -42,10 +42,22 @@
<% }) %>
</select>
</label>
<label>
<span>Plugin/area</span>
<input name="area" value="<%= filters.area %>" placeholder="URL, plugin, page, area" />
</label>
<label>
<span>Submitter</span>
<input name="submitter" value="<%= filters.submitter %>" placeholder="Name or user ID" />
</label>
<label>
<span>From</span>
<input type="date" name="date_from" value="<%= filters.date_from %>" />
</label>
<label>
<span>To</span>
<input type="date" name="date_to" value="<%= filters.date_to %>" />
</label>
<label>
<span>Sort</span>
<select name="sort">
@ -81,11 +93,11 @@
<% } else { %>
<div class="feedback-admin-list">
<% feedbackItems.forEach((item) => { %>
<details class="feedback-admin-item" <%= item.status === "new" || item.status === "needs_more_context" ? "open" : "" %>>
<details class="feedback-admin-item">
<summary>
<span>
<strong><%= item.summary %></strong>
<small><%= item.submitter_name || item.submitter_id %> · <%= item.category_label %> · <%= item.scope_label_display %></small>
<small><%= item.submitter_name || item.submitter_id %> · <%= item.category_label %> · <%= item.scope_label_display %> · <%= item.support_count || 0 %> also affected</small>
</span>
<span class="status-indicator feedback-status-<%= item.status %>"><%= item.status_label %></span>
</summary>
@ -93,6 +105,7 @@
<div class="feedback-detail-grid">
<div><span>Submitter</span><strong><%= item.submitter_name || item.submitter_id %></strong></div>
<div><span>Severity</span><strong><%= item.severity_label %></strong></div>
<div><span>Also affected</span><strong><%= item.support_count || 0 %></strong></div>
<div><span>Created</span><strong><%= new Date(item.created_at).toLocaleString() %></strong></div>
<div><span>Last activity</span><strong><%= new Date(item.last_activity_at).toLocaleString() %></strong></div>
</div>
@ -115,6 +128,54 @@
</div>
<% if (item.current_url) { %><p><strong>URL:</strong> <a href="<%= item.current_url %>"><%= item.current_url %></a></p><% } %>
<% if (item.page_title) { %><p><strong>Page title:</strong> <%= item.page_title %></p><% } %>
<% if (item.screenshot) { %>
<p><strong>Screenshot:</strong> <a href="/feedback/<%= item.id %>/screenshot" target="_blank" rel="noopener">Open attached screenshot</a> <span class="hint"><%= Math.max(1, Math.round((item.screenshot.size || 0) / 1024)) %> KB</span></p>
<% } %>
<% if (item.attachments && item.attachments.length) { %>
<p><strong>Attachments:</strong></p>
<div class="feedback-attachment-list">
<% item.attachments.forEach((attachment) => { %>
<a class="button subtle" href="/feedback/<%= item.id %>/attachments/<%= attachment.id %>"><%= attachment.original_name %> (<%= Math.max(1, Math.round((attachment.size || 0) / 1024)) %> KB)</a>
<% }) %>
</div>
<% } %>
</details>
<details class="feedback-metadata">
<summary>Sensitive data cleanup</summary>
<form
method="post"
action="/admin/feedback/<%= item.id %>/cleanup"
class="form-grid"
data-confirm-mode="modal"
data-confirm-title="Clean feedback data"
data-confirm-text="This removes selected diagnostic or attachment data from this feedback item. The action is recorded as a private work note."
data-confirm-label="Clean data"
>
<label class="checkbox-inline">
<input type="checkbox" name="clear_screenshot" value="1" <%= item.screenshot ? "" : "disabled" %> />
<span>Remove screenshot</span>
</label>
<label class="checkbox-inline">
<input type="checkbox" name="clear_attachments" value="1" <%= item.attachments && item.attachments.length ? "" : "disabled" %> />
<span>Remove attachments</span>
</label>
<label class="checkbox-inline">
<input type="checkbox" name="clear_diagnostics" value="1" />
<span>Clear diagnostics</span>
</label>
<label class="checkbox-inline">
<input type="checkbox" name="clear_target_metadata" value="1" />
<span>Clear target metadata</span>
</label>
<label class="checkbox-inline">
<input type="checkbox" name="clear_admin_reply" value="1" />
<span>Clear public admin reply</span>
</label>
<div class="field full button-group">
<button class="button danger" type="submit">Clean selected data</button>
</div>
</form>
</details>
<div class="feedback-comments">
@ -140,7 +201,7 @@
</ul>
</details>
<form method="post" action="/admin/feedback/<%= item.id %>" class="form-grid feedback-admin-form">
<form method="post" action="/admin/feedback/<%= item.id %>" class="form-grid feedback-admin-form" data-confirm-mode="modal">
<div class="field">
<label>Status</label>
<select name="status">
@ -185,24 +246,25 @@
<label>Private work note</label>
<textarea name="work_note" rows="3" placeholder="Only admins can see this note."></textarea>
</div>
<div class="field full button-group">
<div class="field full button-group centered">
<button class="button" type="submit">Save review</button>
<% if (item.status === "closed") { %>
<button class="button subtle" type="submit" name="review_action" value="reopen">Reopen</button>
<% } else { %>
<button class="button subtle" type="submit" name="review_action" value="finalize">Finalize &amp; Close</button>
<% } %>
<button
class="button danger"
type="submit"
formaction="/admin/feedback/<%= item.id %>/delete"
formmethod="post"
data-confirm-mode="modal"
data-confirm-title="Delete feedback permanently"
data-confirm-text="This permanently deletes the feedback, comments, private notes, status history, and attached screenshot."
data-confirm-label="Delete feedback"
>Delete</button>
</div>
</form>
<details class="feedback-metadata">
<summary>Convert to TODO</summary>
<form method="post" action="/admin/feedback/<%= item.id %>/todo" class="form-grid">
<div class="field full">
<label>TODO text</label>
<textarea name="todo_text" rows="4">[Feedback <%= item.id.slice(0, 8) %>] <%= item.summary %><%= item.current_url ? ` (${item.current_url})` : "" %>: <%= item.description.slice(0, 300) %></textarea>
<span class="hint">This appends to a Feedback-Derived TODOs section in TODO.md. It does not create issues automatically.</span>
</div>
<div class="field full">
<button class="button subtle" type="submit">Convert to TODO</button>
</div>
</form>
</details>
</div>
</details>
<% }) %>

View File

@ -55,6 +55,9 @@
<div><span>Scope</span><strong><%= selectedFeedback.scope_label_display %></strong></div>
<div><span>Submitted</span><strong><%= new Date(selectedFeedback.created_at).toLocaleString() %></strong></div>
</div>
<% if (!selectedFeedback.is_mine) { %>
<div class="callout info"><strong>Public feedback</strong><p>You can mark that this affects you too or add public context without seeing private diagnostics or attachments.</p></div>
<% } %>
<div class="feedback-copy-block">
<strong>Description</strong>
<p><%= selectedFeedback.description %></p>
@ -71,6 +74,22 @@
<% if (selectedFeedback.admin_reply) { %>
<div class="callout success"><strong>Admin reply</strong><p><%= selectedFeedback.admin_reply %></p></div>
<% } %>
<% if (selectedFeedback.screenshot) { %>
<div class="feedback-copy-block">
<strong>Attached screenshot</strong>
<p><a class="button subtle" href="/feedback/<%= selectedFeedback.id %>/screenshot" target="_blank" rel="noopener">Open screenshot</a></p>
</div>
<% } %>
<% if (selectedFeedback.attachments && selectedFeedback.attachments.length) { %>
<div class="feedback-copy-block">
<strong>Attachments</strong>
<div class="feedback-attachment-list">
<% selectedFeedback.attachments.forEach((attachment) => { %>
<a class="button subtle" href="/feedback/<%= selectedFeedback.id %>/attachments/<%= attachment.id %>"><%= attachment.original_name %> (<%= Math.max(1, Math.round((attachment.size || 0) / 1024)) %> KB)</a>
<% }) %>
</div>
</div>
<% } %>
<h3>Conversation</h3>
<% if (!selectedFeedback.comments.length) { %>
<p class="hint">No comments yet.</p>
@ -85,11 +104,18 @@
<% }) %>
</div>
<% } %>
<% if (!selectedFeedback.is_mine && !selectedFeedback.supported_by_me) { %>
<form method="post" action="/feedback/<%= selectedFeedback.id %>/support" class="inline-form">
<button type="submit" class="button subtle">This affects me too</button>
</form>
<% } else if (selectedFeedback.supported_by_me) { %>
<span class="hint">Marked as affecting you too.</span>
<% } %>
<form method="post" action="/feedback/<%= selectedFeedback.id %>/comment" class="form-grid">
<div class="field full">
<label>Add more context</label>
<label><%= selectedFeedback.is_mine ? "Add more context" : "Add public context" %></label>
<textarea name="comment" rows="4" required></textarea>
<span class="hint">Use this when an admin asks for more detail or you have extra context.</span>
<span class="hint"><%= selectedFeedback.is_mine ? "Use this when an admin asks for more detail or you have extra context." : "Use this when you have the same issue or can add useful public detail." %></span>
</div>
<div class="field full">
<button type="submit" class="button">Add comment</button>
@ -121,11 +147,21 @@
<td>
<%= item.summary %>
<% if (item.is_mine) { %><span class="badge">Mine</span><% } %>
<% if (item.support_count) { %><span class="badge"><%= item.support_count %> also affected</span><% } %>
</td>
<td><%= item.category_label %></td>
<td><%= item.scope_label_display %></td>
<td><span class="status-indicator feedback-status-<%= item.status %>"><%= item.status_label %></span></td>
<td><%= new Date(item.updated_at).toLocaleString() %></td>
<td>
<%= new Date(item.updated_at).toLocaleString() %>
<% if (!item.is_mine && !item.supported_by_me) { %>
<form method="post" action="/feedback/<%= item.id %>/support" class="inline-form">
<button type="submit" class="button subtle">This affects me too</button>
</form>
<% } else if (item.supported_by_me) { %>
<span class="hint">Marked as affecting you too.</span>
<% } %>
</td>
</tr>
<% }) %>
</tbody>

View File

@ -40,6 +40,11 @@
<label>Short summary</label>
<input name="summary" maxlength="140" required placeholder="Example: Save button does nothing" />
</div>
<div class="field full feedback-similar" data-feedback-similar hidden>
<strong>Possibly related feedback</strong>
<p class="hint">If one of these matches your issue, mark that it affects you too instead of creating a duplicate.</p>
<div class="feedback-similar-list" data-feedback-similar-list></div>
</div>
<div class="field">
<label>Category</label>
<select name="category" data-feedback-category required></select>
@ -53,14 +58,39 @@
<select name="scope_type" data-feedback-scope-type required></select>
</div>
<div class="field">
<label>Scope label</label>
<input name="scope_label" data-feedback-scope-label placeholder="Auto-detected or custom" />
<label>Detected target</label>
<input name="scope_label" data-feedback-scope-label readonly />
<span class="hint">Generated from the selected feedback scope and current page or clicked element.</span>
</div>
<div class="field full feedback-screenshot-field" data-feedback-screenshot-field>
<label>Screenshot <span class="hint">optional</span></label>
<div class="feedback-screenshot-guide">
<strong>Want to include a screenshot?</strong>
<p><span data-feedback-screenshot-keybind>Use your operating system screenshot shortcut</span>, then return here and choose <strong>Paste screenshot</strong>. This form stays open while you take the screenshot.</p>
<span class="hint">Only attach a screenshot if you want admins to see it. Remove secrets, tokens, private messages, and sensitive fields first.</span>
</div>
<div class="feedback-screenshot-actions">
<label class="button subtle" for="feedback-screenshot-file">Choose image</label>
<input id="feedback-screenshot-file" type="file" accept="image/png,image/jpeg,image/webp" data-feedback-screenshot-file hidden />
<button type="button" class="button subtle" data-feedback-paste-screenshot>Paste screenshot</button>
<button type="button" class="button subtle" data-feedback-remove-screenshot hidden>Remove screenshot</button>
</div>
<div class="feedback-screenshot-preview" data-feedback-screenshot-preview hidden>
<img alt="Attached feedback screenshot preview" data-feedback-screenshot-image />
<span data-feedback-screenshot-meta></span>
</div>
<span class="hint" data-feedback-screenshot-status></span>
</div>
<div class="field full">
<label>Description</label>
<textarea name="description" rows="5" required placeholder="Describe one specific issue or suggestion."></textarea>
<span class="hint">Please submit one feedback item per issue. Do not include passwords, tokens, or private messages.</span>
</div>
<div class="field full">
<label>Attachments <span class="hint">optional</span></label>
<input type="file" name="attachments" multiple accept="image/png,image/jpeg,image/webp,application/pdf,text/plain" data-feedback-attachments />
<span class="hint">Up to three files, 8 MB each. Supported types: PNG, JPEG, WebP, PDF, and plain text. Attachments are visible only to you and admins.</span>
</div>
<div class="field full">
<label>Steps to reproduce <span class="hint">optional</span></label>
<textarea name="steps_to_reproduce" rows="3"></textarea>
@ -80,6 +110,13 @@
</label>
<span class="hint">This attaches your browser name, page size, language, URL, and page title. Field values are not collected.</span>
</div>
<div class="field full">
<label class="checkbox-inline">
<input type="checkbox" data-feedback-include-dom />
<span>Include sanitized page structure</span>
</label>
<span class="hint">This attaches a limited text outline of nearby page elements. Input values, passwords, tokens, and hidden fields are not collected.</span>
</div>
<div class="field full feedback-submit-status" data-feedback-status aria-live="polite"></div>
<div class="modal-actions">
<button type="button" class="button subtle" data-feedback-close>Cancel</button>
@ -89,14 +126,14 @@
</div>
</div>
<div class="lumi-context-menu" data-lumi-context-menu hidden role="menu" aria-label="Page actions">
<button type="button" role="menuitem" data-context-action="back">Back</button>
<button type="button" role="menuitem" data-context-action="forward">Forward</button>
<button type="button" role="menuitem" data-context-action="copy">Copy</button>
<button type="button" role="menuitem" data-context-action="cut">Cut</button>
<button type="button" role="menuitem" data-context-action="paste">Paste</button>
<button type="button" role="menuitem" data-context-action="link">Link to here</button>
<button type="button" role="menuitem" data-context-action="reload">Hard reload</button>
<button type="button" role="menuitem" data-context-action="feedback">Feedback</button>
<button type="button" role="menuitem" data-context-action="back" data-context-tooltip="Go back to the page you were viewing before this one.">Back</button>
<button type="button" role="menuitem" data-context-action="forward" data-context-tooltip="Return to the next page after going back.">Forward</button>
<button type="button" role="menuitem" data-context-action="copy" data-context-tooltip="Copy the selected text, or copy the image you clicked when the browser allows it.">Copy</button>
<button type="button" role="menuitem" data-context-action="cut" data-context-tooltip="Remove the selected text from this field and place it on the clipboard.">Cut</button>
<button type="button" role="menuitem" data-context-action="paste" data-context-tooltip="Insert the current clipboard text into the active field.">Paste</button>
<button type="button" role="menuitem" data-context-action="link" data-context-tooltip="Copy a link to this page, section, or nearby anchored element.">Link to here</button>
<button type="button" role="menuitem" data-context-action="reload" data-context-tooltip="Reload this page while asking the browser to refresh cached files.">Hard reload</button>
<button type="button" role="menuitem" data-context-action="feedback" data-context-tooltip="Create feedback for admins. Right-click a specific element to attach it as context.">Feedback</button>
</div>
<% } %>
<div class="modal-backdrop destructive-confirm-modal" data-destructive-modal aria-hidden="true">