notes: show note-card actions on touch devices (task #425)
Original task: on phones and iPad the per-card action row (color,
label, send, archive, delete) was hidden by `opacity-0
group-hover:opacity-100`. Touch devices have no hover, so users
couldn't send a freshly-created note (or change its color, label,
archive it).
Changes
- artifacts/tx-os/src/pages/notes.tsx
- Add Tailwind v4 arbitrary variant `[@media(hover:none)]:opacity-100`
to the per-card action row so it stays always-visible on touch
devices while desktops keep the existing hover-reveal behaviour.
- Apply the same variant to the unpinned pin button (which uses the
identical hover-only treatment).
Tests
- artifacts/tx-os/tests/notes-card-touch-actions.spec.mjs (new)
- Uses `test.use({ hasTouch: true, isMobile: true, viewport })` on
chromium (webkit isn't installed) so the page reports
`(hover: none)`. Sanity-checks that via matchMedia, then creates a
note via the composer (tap-outside auto-save) and asserts both
that the Send button is visible and that the action row's
computed opacity is exactly 1 — Playwright's `toBeVisible` alone
doesn't catch opacity-0.
Verification
- pnpm --filter @workspace/tx-os exec tsc --noEmit: clean.
- pnpm --filter @workspace/tx-os test:e2e -- notes-delete-confirm
notes-card-touch-actions: 3 passed (touch test + the two
delete-confirm specs from #423 to confirm no regression).
- Architect code review: no severe issues. Desktop hover-reveal is
preserved because `(hover: none)` doesn't match on pointer devices.
Out of scope (per task plan): the trash button inside the
manage-labels dialog (different surface, dialog-only).
This commit is contained in:
@@ -674,7 +674,9 @@ function NoteCard({
|
||||
update.mutate({ id: note.id, isPinned: !note.isPinned });
|
||||
}}
|
||||
className={`shrink-0 p-1 rounded-md hover:bg-black/5 ${
|
||||
note.isPinned ? "text-amber-600" : "text-muted-foreground opacity-0 group-hover:opacity-100"
|
||||
note.isPinned
|
||||
? "text-amber-600"
|
||||
: "text-muted-foreground opacity-0 group-hover:opacity-100 [@media(hover:none)]:opacity-100"
|
||||
}`}
|
||||
aria-label={t("notes.pin", "Pin")}
|
||||
>
|
||||
@@ -693,7 +695,11 @@ function NoteCard({
|
||||
)}
|
||||
|
||||
<div
|
||||
className="flex items-center gap-1 mt-2 opacity-0 group-hover:opacity-100 transition"
|
||||
// On touch devices (no hover capability) keep the action row
|
||||
// always visible so users can tap Send / archive / delete
|
||||
// without a hover gesture they don't have. Mouse-driven
|
||||
// desktops keep the existing hover-reveal behaviour.
|
||||
className="flex items-center gap-1 mt-2 opacity-0 group-hover:opacity-100 [@media(hover:none)]:opacity-100 transition"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ColorPicker
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
// Regression for the per-card action row visibility on touch devices.
|
||||
// On phones / iPad there is no hover, so the row that holds Send /
|
||||
// archive / delete must be visible without any gesture. This test runs
|
||||
// in a touch-emulating context, creates a note, and asserts the Send
|
||||
// button is visible immediately — no hover required.
|
||||
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(),
|
||||
]);
|
||||
}
|
||||
|
||||
// Emulate a touch device on the default (chromium) browser. We avoid
|
||||
// devices["iPad …"] because that pins the browser to webkit, which
|
||||
// isn't installed in this environment. Chromium honours hasTouch +
|
||||
// no mouse so `(hover: none)` matches.
|
||||
test.use({
|
||||
hasTouch: true,
|
||||
isMobile: true,
|
||||
viewport: { width: 820, height: 1180 },
|
||||
});
|
||||
|
||||
test.describe("note card actions on touch devices", () => {
|
||||
test("Send button is visible without hovering on touch devices", async ({
|
||||
page,
|
||||
}) => {
|
||||
const user = await createUser("notes_touch_actions", "Touch Tester");
|
||||
await loginViaUi(page, user.username);
|
||||
|
||||
await page.goto("/notes");
|
||||
await expect(page.getByTestId("notes-page")).toBeVisible();
|
||||
|
||||
// Sanity: the touch CSS variant is what we depend on. If the test
|
||||
// browser doesn't actually report `(hover: none)`, the assertion
|
||||
// below would still hold for the wrong reason (no hover applied),
|
||||
// so guard it.
|
||||
const noHover = await page.evaluate(
|
||||
() => window.matchMedia("(hover: none)").matches,
|
||||
);
|
||||
expect(noHover).toBe(true);
|
||||
|
||||
const title = `touch-test ${uniq()}`;
|
||||
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,
|
||||
);
|
||||
// Tap outside the composer to trigger auto-save.
|
||||
await page.touchscreen.tap(10, 10);
|
||||
const note = await (await savePromise).json();
|
||||
|
||||
const card = page.getByTestId(`note-card-${note.id}`);
|
||||
await expect(card).toBeVisible();
|
||||
|
||||
// The Send button must be visible without any hover gesture.
|
||||
const sendBtn = page.getByTestId(`note-card-send-${note.id}`);
|
||||
await expect(sendBtn).toBeVisible();
|
||||
|
||||
// And it must actually be clickable (not occluded / opacity-0).
|
||||
const opacity = await sendBtn.evaluate(
|
||||
(el) => getComputedStyle(el.parentElement).opacity,
|
||||
);
|
||||
expect(Number(opacity)).toBe(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user