diff --git a/frontend/src/__tests__/pages/TouchReachableActions.test.tsx b/frontend/src/__tests__/pages/TouchReachableActions.test.tsx
new file mode 100644
index 000000000..1253b7e6e
--- /dev/null
+++ b/frontend/src/__tests__/pages/TouchReachableActions.test.tsx
@@ -0,0 +1,136 @@
+/**
+ * Card and row actions must stay reachable without a hover-capable pointer (#2865).
+ *
+ * Tailwind v4 compiles `group-hover:` inside `@media (hover: hover)`, so on a
+ * touch-only device the reveal rule is never applied and a control written as
+ * `opacity-0 group-hover:opacity-100` is invisible for good — which is how the
+ * project card's action menu and the File Manager's folder actions became
+ * unusable on a phone. The fix moves the HIDING half behind a `can-hover`
+ * variant, so with no such pointer the control simply keeps its own opacity.
+ *
+ * jsdom does not evaluate media queries, so these tests pin the class contract
+ * rather than the computed style: a bare `opacity-0` is the defect, because it
+ * applies unconditionally while everything that undoes it does not.
+ */
+
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { http, HttpResponse } from 'msw';
+import { render } from '../utils';
+import { server } from '../mocks/server';
+import { ProjectsPage } from '../../pages/ProjectsPage';
+import { FileManagerPage } from '../../pages/FileManagerPage';
+import { setAuthToken } from '../../api/client';
+
+/** `opacity-0` on its own — with no variant in front of it. */
+const UNCONDITIONALLY_HIDDEN = /(^|\s)opacity-0(\s|$)/;
+
+const mockProjects = [
+ {
+ id: 1,
+ name: 'Functional Parts',
+ description: 'Useful household items',
+ color: '#00ae42',
+ archive_count: 10,
+ total_print_time_seconds: 36000,
+ total_filament_grams: 500,
+ created_at: '2024-01-01T00:00:00Z',
+ updated_at: '2024-01-15T00:00:00Z',
+ },
+];
+
+const mockFolders = [
+ {
+ id: 1,
+ name: 'Brackets',
+ parent_id: null,
+ file_count: 0,
+ project_id: null,
+ archive_id: null,
+ project_name: null,
+ archive_name: null,
+ is_external: false,
+ children: [],
+ },
+];
+
+describe('actions that were hover-only (#2865)', () => {
+ afterEach(() => {
+ setAuthToken(null);
+ });
+
+ describe('project card', () => {
+ beforeEach(() => {
+ server.use(http.get('/api/v1/projects/', () => HttpResponse.json(mockProjects)));
+ });
+
+ it('does not hide the action menu from a pointer that cannot hover', async () => {
+ render(