diff --git a/artifacts/tx-os/src/pages/notes.tsx b/artifacts/tx-os/src/pages/notes.tsx
index 6cfb9eb5..c7657c43 100644
--- a/artifacts/tx-os/src/pages/notes.tsx
+++ b/artifacts/tx-os/src/pages/notes.tsx
@@ -258,6 +258,7 @@ export default function NotesPage() {
size="sm"
variant="ghost"
onClick={() => setLabelsDialogOpen(true)}
+ data-testid="notes-manage-labels-open"
>
{t("notes.labels", "Labels")}
@@ -544,6 +545,7 @@ function NoteCard({
const update = useUpdateNote();
const del = useDeleteNote();
const noteLabels = labels.filter((l) => note.labelIds.includes(l.id));
+ const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
// Touch fallback for mobile / iPad: a long-press starts a drag, touchmove
// hit-tests folder drop targets, touchend resolves the drop.
@@ -567,6 +569,7 @@ function NoteCard({
};
return (
+ <>
: }
+ {/* AlertDialog rendered as a sibling (not a child of the clickable card)
+ so React's portal event bubbling can't trigger the card's onEdit
+ click handler when the user interacts with the confirmation. */}
+
+
+
+
+ {t("notes.deleteConfirm", "Delete this note?")}
+
+
+
+
+ {t("common.cancel", "Cancel")}
+
+ {
+ del.mutate(note.id);
+ setConfirmDeleteOpen(false);
+ }}
+ data-testid={`note-delete-confirm-${note.id}`}
+ >
+ {t("notes.delete", "Delete")}
+
+
+
+
+ >
);
}
@@ -2005,6 +2038,9 @@ function LabelsDialog({
const [name, setName] = useState("");
const [editingId, setEditingId] = useState(null);
const [editingName, setEditingName] = useState("");
+ const [pendingDeleteLabel, setPendingDeleteLabel] = useState(
+ null,
+ );
const create = useCreateLabel();
const update = useUpdateLabel();
const del = useDeleteLabel();
@@ -2095,12 +2131,9 @@ function LabelsDialog({
{l.name}
@@ -2110,6 +2143,46 @@ function LabelsDialog({
))}
+ {
+ if (!o) setPendingDeleteLabel(null);
+ }}
+ >
+
+
+
+ {t("notes.deleteLabelConfirm", "Delete this label?")}
+
+
+
+
+ {t("common.cancel", "Cancel")}
+
+ {
+ if (pendingDeleteLabel) {
+ del.mutate(pendingDeleteLabel.id);
+ setPendingDeleteLabel(null);
+ }
+ }}
+ data-testid={
+ pendingDeleteLabel
+ ? `label-delete-confirm-${pendingDeleteLabel.id}`
+ : "label-delete-confirm"
+ }
+ >
+ {t("notes.delete", "Delete")}
+
+
+
+
);
}
diff --git a/artifacts/tx-os/tests/notes-delete-confirm.spec.mjs b/artifacts/tx-os/tests/notes-delete-confirm.spec.mjs
new file mode 100644
index 00000000..0fce3620
--- /dev/null
+++ b/artifacts/tx-os/tests/notes-delete-confirm.spec.mjs
@@ -0,0 +1,176 @@
+// E2E regression for the styled in-app delete confirmation on notes.
+// Previously the trash icon called the browser's native confirm() which
+// shows the raw Repl hostname and clashes with the rest of the app's
+// design. This test guards the styled AlertDialog: cancelling keeps the
+// note, confirming deletes it. We deliberately do NOT register a
+// `dialog` event handler — if a native confirm fires the test will hang
+// (and fail), proving the native dialog is gone.
+import { test, expect } from "@playwright/test";
+import pg from "pg";
+
+const DATABASE_URL = process.env.DATABASE_URL;
+if (!DATABASE_URL) {
+ throw new Error("DATABASE_URL must be set to run this UI test");
+}
+
+const TEST_PASSWORD = "TestPass123!";
+const TEST_PASSWORD_HASH =
+ "$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";
+
+const pool = new pg.Pool({ connectionString: DATABASE_URL });
+const createdUserIds = [];
+
+function uniq() {
+ return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
+}
+
+async function createUser(prefix, displayEn) {
+ const username = `${prefix}_${uniq()}`;
+ const { rows } = await pool.query(
+ `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
+ VALUES ($1, $2, $3, $4, 'en', true) RETURNING id`,
+ [username, `${username}@example.com`, TEST_PASSWORD_HASH, displayEn],
+ );
+ const id = rows[0].id;
+ createdUserIds.push(id);
+ await pool.query(
+ `INSERT INTO user_roles (user_id, role_id)
+ SELECT $1, id FROM roles WHERE name = 'user'`,
+ [id],
+ );
+ return { id, username };
+}
+
+test.afterAll(async () => {
+ if (createdUserIds.length > 0) {
+ await pool.query(`DELETE FROM notes WHERE user_id = ANY($1::int[])`, [
+ createdUserIds,
+ ]);
+ await pool.query(`DELETE FROM user_roles WHERE user_id = ANY($1::int[])`, [
+ createdUserIds,
+ ]);
+ await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [
+ createdUserIds,
+ ]);
+ }
+ await pool.end();
+});
+
+async function loginViaUi(page, username) {
+ await page.goto("/login");
+ await page.locator("#username").fill(username);
+ await page.locator("#password").fill(TEST_PASSWORD);
+ await Promise.all([
+ page.waitForURL((url) => !url.pathname.endsWith("/login"), {
+ timeout: 15_000,
+ }),
+ page.locator('form button[type="submit"]').click(),
+ ]);
+}
+
+async function createNoteViaComposer(page, title) {
+ await page.getByTestId("notes-composer").click();
+ await page.getByTestId("notes-composer-title").fill(title);
+ const savePromise = page.waitForResponse(
+ (r) =>
+ new URL(r.url()).pathname === "/api/notes" &&
+ r.request().method() === "POST" &&
+ r.status() >= 200 &&
+ r.status() < 300,
+ );
+ await page.mouse.click(10, 10);
+ const resp = await savePromise;
+ return resp.json();
+}
+
+test("delete note uses styled confirmation dialog (cancel keeps it, confirm deletes)", async ({
+ page,
+}) => {
+ const user = await createUser("notes_delete_confirm", "Delete Tester");
+ await loginViaUi(page, user.username);
+
+ await page.goto("/notes");
+ await expect(page.getByTestId("notes-page")).toBeVisible();
+
+ const title = `delete-test ${uniq()}`;
+ const note = await createNoteViaComposer(page, title);
+
+ const card = page.getByTestId(`note-card-${note.id}`);
+ await expect(card).toBeVisible();
+
+ // Open the styled confirmation. Hover first so the action row appears.
+ await card.hover();
+ await page.getByTestId(`note-card-delete-${note.id}`).click();
+
+ // Styled dialog appears (no native confirm).
+ const dialog = page.getByTestId(`note-delete-dialog-${note.id}`);
+ await expect(dialog).toBeVisible();
+
+ // Cancel — note must still be there.
+ await dialog.getByRole("button", { name: "Cancel" }).click();
+ await expect(dialog).toBeHidden();
+ await expect(card).toBeVisible();
+
+ // Re-open and confirm.
+ await card.hover();
+ await page.getByTestId(`note-card-delete-${note.id}`).click();
+ await expect(page.getByTestId(`note-delete-dialog-${note.id}`)).toBeVisible();
+
+ const deletePromise = page.waitForResponse(
+ (r) =>
+ new URL(r.url()).pathname === `/api/notes/${note.id}` &&
+ r.request().method() === "DELETE" &&
+ r.status() >= 200 &&
+ r.status() < 300,
+ );
+ await page.getByTestId(`note-delete-confirm-${note.id}`).click();
+ await deletePromise;
+
+ await expect(card).toHaveCount(0);
+});
+
+test("delete label uses styled confirmation dialog (cancel keeps it, confirm deletes)", async ({
+ page,
+}) => {
+ const user = await createUser("notes_label_delete", "Label Tester");
+ await loginViaUi(page, user.username);
+
+ await page.goto("/notes");
+ await expect(page.getByTestId("notes-page")).toBeVisible();
+
+ // Open the manage-labels dialog and create a label through the UI so
+ // React Query has the row in its cache by the time we go to delete it.
+ await page.getByTestId("notes-manage-labels-open").click();
+
+ const labelName = `lbl-${uniq()}`;
+ const createPromise = page.waitForResponse(
+ (r) =>
+ new URL(r.url()).pathname === "/api/note-labels" &&
+ r.request().method() === "POST" &&
+ r.status() >= 200 &&
+ r.status() < 300,
+ );
+ await page.getByPlaceholder(/new label|تصنيف جديد/i).fill(labelName);
+ await page.getByPlaceholder(/new label|تصنيف جديد/i).press("Enter");
+ const label = await (await createPromise).json();
+
+ // The trash button uses opacity-0 + group-hover; force the click since
+ // hovering a per-row group on portaled dialogs is flaky in headless.
+ const labelDeleteBtn = page.getByTestId(`label-delete-${label.id}`);
+ await labelDeleteBtn.click({ force: true });
+
+ const dialog = page.getByTestId(`label-delete-dialog-${label.id}`);
+ await expect(dialog).toBeVisible();
+
+ const deletePromise = page.waitForResponse(
+ (r) =>
+ new URL(r.url()).pathname === `/api/note-labels/${label.id}` &&
+ r.request().method() === "DELETE" &&
+ r.status() >= 200 &&
+ r.status() < 300,
+ );
+ await page.getByTestId(`label-delete-confirm-${label.id}`).click();
+ await deletePromise;
+
+ await expect(labelDeleteBtn).toHaveCount(0);
+});