fix(phase-11): close mobile storage UAT gaps

This commit is contained in:
curo1305
2026-06-17 10:48:35 +02:00
parent c48ebf152c
commit 595b33a68c
8 changed files with 283 additions and 50 deletions
+4 -3
View File
@@ -696,8 +696,9 @@ _Started: 2026-06-07_
- `CODE-07` dead-code deletion sweeps all unreferenced files, components, stores, and unused imports. Deletion happens after all other changes in this phase are complete — delete only what is confirmed unreferenced after all additions.
- `VISUAL-02` requires the `@tailwindcss/forms` plugin (already listed in PERF-01) to be configured in `tailwind.config.js`.
- `RESP-02`: `StorageBrowser`'s grid hides the Size column below `md` and Modified below `sm`. Icon, name, and actions are always visible.
- `RESP-03`: inline icon action buttons have minimum 36×36px touch target below `md`.
- `RESP-03`: inline icon action buttons have minimum 36×36px touch target below `md`; storage row actions are visible without hover on touch devices.
- `RESP-04`: all modal dialogs are scrollable on viewports below 640px.
- UAT gap closure 11-07: below `sm`, StorageBrowser search/sort/new-folder collapse into compact icon controls so the toolbar fits below ~550px without horizontal scrolling.
**Success Criteria** (what must be TRUE):
1. At 375px viewport width, the app is fully usable: the sidebar is hidden, the hamburger opens an overlay drawer, the document list shows only icon/name/actions columns, and all touch targets are at least 36×36px
@@ -705,7 +706,7 @@ _Started: 2026-06-07_
3. Every button, card row, and interactive link has a visible `focus-visible:` ring and a distinct hover state; no interactive element is style-identical in focused and unfocused states
4. No arbitrary pixel value (`px-[13px]`, `style="margin: 5px"`, etc.) appears in any template; all spacing is from the Tailwind scale
5. Two bundle analysis reports are committed to `.planning/` — one baseline and one post-optimization; all non-initial-render routes are lazy-loaded and appear as separate chunks in the report
**Plans**: TBD
**Plans**: 11-01 through 11-07 complete
**UI hint**: yes
---
@@ -717,7 +718,7 @@ _Started: 2026-06-07_
| 8. Stack Upgrade & Backend Decomposition | 4/8 | In Progress| |
| 9. Admin Panel Rearchitecture | 5/5 | Complete | 2026-06-13 |
| 10. UX & Interaction | 13/13 | Complete | 2026-06-16 |
| 11. Visual Design, Responsive Layout & Cleanup | 6/6 | Complete | 2026-06-17 |
| 11. Visual Design, Responsive Layout & Cleanup | 7/7 | Complete | 2026-06-17 |
---
@@ -0,0 +1,88 @@
---
phase: 11-visual-design-responsive-layout-cleanup
plan: 7
subsystem: frontend
tags: [gap-closure, mobile, responsive, storage-browser, uat]
dependency_graph:
requires: [11-UAT]
provides: [RESP-02, RESP-03, VISUAL-03-gap-closure]
affects: [frontend/src/components/storage, frontend/src/components/documents, docs]
tech_stack:
added: []
patterns:
- "StorageBrowser remains the single file browser surface"
- "Mobile toolbar uses icon buttons below sm; full controls remain at sm+"
key_files:
modified:
- frontend/src/components/storage/StorageBrowser.vue
- frontend/src/components/storage/__tests__/StorageBrowser.skeleton.test.js
- frontend/src/components/documents/SearchBar.vue
- backend/main.py
- frontend/package.json
- frontend/package-lock.json
- README.md
- AGENTS.md
- .planning/ROADMAP.md
created: []
metrics:
completed: 2026-06-17
tests_after: 277
version: 0.1.4
---
# Phase 11 Plan 7: Mobile Storage Toolbar and Action Visibility Summary
Closed the Phase 11 UAT mobile storage gaps found during `/gsd:verify-work 11`.
## Changes
### Touch-safe row actions
- Removed hover-only visibility from folder and file row action containers.
- Row action buttons are now visible by default on touch devices.
- Preserved the 36px minimum touch target classes below `md`.
- Widened the mobile action grid column to fit three visible file actions without requiring horizontal scroll.
### Compact mobile toolbar
- Split `StorageBrowser`'s sticky header into desktop and mobile control groups.
- Kept the full SearchBar, SortControls, and "New folder" text button at `sm` and above.
- Added mobile icon buttons for Search, Sort, and New folder below `sm`.
- Search opens a full-width mobile search row.
- Sort opens a compact mobile sort panel and emits the existing `sort-change` event shape.
- New folder icon emits the existing `new-folder` event.
### Responsive search width
- Updated `SearchBar.vue` so the input is `w-full` on mobile and keeps `sm:w-56` at larger breakpoints.
## Verification
- `cd frontend && npm run test -- --run src/components/storage/__tests__/StorageBrowser.skeleton.test.js`
- PASS: 1 file, 22 tests
- `cd frontend && npm run test -- --run`
- PASS: 36 files, 277 tests
- `cd frontend && npm run build`
- PASS: production build succeeds
- Existing Vite warning remains: `auth.js` is both dynamically and statically imported.
## UAT Gap Mapping
| Gap | Resolution |
|-----|------------|
| Row actions are hover-only and unsafe on touchscreens | Actions are visible without hover; tests assert no `opacity-0` / `group-hover:opacity-100` classes remain on action containers |
| Toolbar shifts out of the viewport below ~550px | Mobile controls collapse into icon buttons below `sm`; expanded search/sort content uses full-width rows |
| Search/sort/new-folder should morph into icons | Implemented icon controls with accessible labels and regression coverage |
## Deviations
None.
## Self-Check
- [x] StorageBrowser remains the single local/cloud file browser
- [x] No backend/API behavior changed beyond patch version bump
- [x] Mobile row actions visible without hover
- [x] Mobile search/sort/new-folder controls fit without horizontal scrolling
- [x] Focus-visible classes retained on new icon buttons
- [x] Tests and build pass
+2 -1
View File
@@ -1,6 +1,6 @@
# DocuVault
**Version 0.1.0 — Alpha**
**Version 0.1.4 — Alpha**
> **Not production-ready.** DocuVault is functional for local and self-hosted use but has not been audited or hardened for public internet exposure. APIs, environment variables, and the database schema may change without notice until a stable 1.0 release is declared.
@@ -13,6 +13,7 @@ A self-hosted, multi-user document management platform with AI-powered topic cla
- **Document management** — upload PDF, DOCX, images, and plain text; full-text extraction stored in PostgreSQL
- **AI classification** — automatic topic tagging via LM Studio (local), Ollama, OpenAI, Anthropic, or any OpenAI-compatible provider (Groq, xAI, DeepSeek, OpenRouter, Gemini, Mistral)
- **Folder organisation** — hierarchical folders with move, rename, and delete; breadcrumb navigation
- **Responsive file browser** — mobile sidebar drawer, compact search/sort/new-folder controls, visible touch-safe row actions, and responsive document columns
- **Document sharing** — share by user handle with view or edit permission; "Shared with me" virtual folder; per-recipient revocation
- **Storage quota** — per-user limit enforced atomically; amber/red quota bar at 80 % / 95 %; quota decremented on delete
- **Cloud storage backends** — connect OneDrive, Google Drive, Nextcloud, or any WebDAV server as a personal storage backend; credentials encrypted with HKDF per-user keys
+1 -1
View File
@@ -244,7 +244,7 @@ async def lifespan(app: FastAPI):
# ── Application factory ───────────────────────────────────────────────────────
app = FastAPI(title="Document Scanner API", version="0.1.3", lifespan=lifespan)
app = FastAPI(title="Document Scanner API", version="0.1.4", lifespan=lifespan)
# Rate limiter state (slowapi)
app.state.limiter = auth_limiter
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "document-scanner-frontend",
"version": "0.1.3",
"version": "0.1.4",
"type": "module",
"scripts": {
"dev": "vite",
@@ -1,12 +1,12 @@
<template>
<div role="search">
<div role="search" class="w-full sm:w-auto">
<input
ref="inputEl"
:value="modelValue"
type="search"
:placeholder="placeholder"
aria-label="Search documents"
class="border border-gray-300 rounded-lg px-3 py-2 text-sm w-56 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
class="border border-gray-300 rounded-lg px-3 py-2 text-sm w-full sm:w-56 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
@input="emit('update:modelValue', $event.target.value)"
@keydown.escape.prevent.stop="emit('update:modelValue', '')"
/>
@@ -2,40 +2,92 @@
<div class="flex flex-col h-full">
<div class="sticky top-0 z-10 bg-white border-b border-gray-100">
<div class="px-6 py-3 flex items-center gap-3 flex-wrap">
<BreadcrumbBar
:segments="breadcrumb"
:root-label="mode === 'cloud' ? 'Cloud' : 'Home'"
@navigate="$emit('breadcrumb-navigate', $event)"
/>
<div class="ml-auto flex items-center gap-2 shrink-0">
<SearchBar v-if="showSearch" ref="searchBarRef" :model-value="searchQuery" @update:modelValue="$emit('search-change', $event)" />
<div class="px-4 sm:px-6 py-3 space-y-2">
<div class="flex items-center gap-3 min-w-0">
<div class="min-w-0 flex-1">
<BreadcrumbBar
:segments="breadcrumb"
:root-label="mode === 'cloud' ? 'Cloud' : 'Home'"
@navigate="$emit('breadcrumb-navigate', $event)"
/>
</div>
<div class="hidden sm:flex items-center gap-2 shrink-0" data-test="desktop-toolbar">
<SearchBar v-if="showSearch" ref="searchBarRef" :model-value="searchQuery" @update:modelValue="$emit('search-change', $event)" />
<SortControls
v-if="showSearch"
:sort="sortField"
:order="sortOrder"
@change="handleSortChange"
/>
<button
v-if="mode === 'local'"
@click="$emit('new-folder')"
class="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-indigo-600 border border-indigo-200 hover:bg-indigo-50 active:bg-indigo-100 rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
>
<AppIcon name="plus" class="w-4 h-4" />
New folder
</button>
</div>
<div class="sm:hidden flex items-center gap-1 shrink-0" data-test="mobile-toolbar">
<button
v-if="showSearch"
type="button"
title="Search"
aria-label="Search"
:aria-expanded="mobileSearchOpen"
class="w-9 h-9 rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 active:bg-gray-100 flex items-center justify-center transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
@click="mobileSearchOpen = !mobileSearchOpen"
>
<AppIcon name="search" class="w-4 h-4" />
</button>
<button
v-if="showSearch"
type="button"
title="Sort"
aria-label="Sort"
:aria-expanded="mobileSortOpen"
class="w-9 h-9 rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 active:bg-gray-100 flex items-center justify-center transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
@click="mobileSortOpen = !mobileSortOpen"
>
<AppIcon name="chartBar" class="w-4 h-4" />
</button>
<button
v-if="mode === 'local'"
type="button"
title="New folder"
aria-label="New folder"
class="w-9 h-9 rounded-lg border border-indigo-200 text-indigo-600 hover:bg-indigo-50 active:bg-indigo-100 flex items-center justify-center transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
@click="$emit('new-folder')"
>
<AppIcon name="plus" class="w-4 h-4" />
</button>
</div>
</div>
<div v-if="showSearch && mobileSearchOpen" class="sm:hidden w-full" data-test="mobile-search-row">
<SearchBar ref="searchBarRef" :model-value="searchQuery" @update:modelValue="$emit('search-change', $event)" />
</div>
<div
v-if="showSearch && mobileSortOpen"
class="sm:hidden w-full rounded-lg border border-gray-200 bg-gray-50 px-2 py-2"
data-test="mobile-sort-panel"
>
<SortControls
v-if="showSearch"
:sort="sortField"
:order="sortOrder"
@change="$emit('sort-change', $event)"
@change="handleSortChange"
/>
<button
v-if="mode === 'local'"
@click="$emit('new-folder')"
class="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-indigo-600 border border-indigo-200 hover:bg-indigo-50 active:bg-indigo-100 rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
>
<AppIcon name="plus" class="w-4 h-4" />
New folder
</button>
</div>
</div>
</div>
<div class="flex-1 overflow-y-auto flex flex-col">
<div class="px-6 pt-5 pb-3">
<div class="px-4 sm:px-6 pt-5 pb-3">
<DropZone ref="dropZoneRef" @files-selected="$emit('upload', $event)" />
<UploadProgress :items="uploadQueue" />
</div>
<div class="mx-6 px-4 py-2 grid grid-cols-[2rem_1fr_6rem] sm:grid-cols-[2rem_1fr_8rem_6rem] md:grid-cols-[2rem_1fr_6rem_8rem_6rem] gap-3 items-center rounded-lg bg-gray-50 text-xs font-semibold text-gray-400 uppercase tracking-wider select-none" data-test="list-header">
<div class="mx-4 sm:mx-6 px-4 py-2 grid grid-cols-[2rem_minmax(0,1fr)_7rem] sm:grid-cols-[2rem_minmax(0,1fr)_8rem_7rem] md:grid-cols-[2rem_minmax(0,1fr)_6rem_8rem_7rem] gap-3 items-center rounded-lg bg-gray-50 text-xs font-semibold text-gray-400 uppercase tracking-wider select-none" data-test="list-header">
<span></span>
<span>Name</span>
<span class="text-right hidden md:block">Size</span>
@@ -43,17 +95,17 @@
<span></span>
</div>
<div v-if="showNewFolderInput" class="mx-6 mt-1 px-4 py-2.5 grid grid-cols-[2rem_1fr_6rem] sm:grid-cols-[2rem_1fr_8rem_6rem] md:grid-cols-[2rem_1fr_6rem_8rem_6rem] gap-3 items-center rounded-lg border border-amber-200 bg-amber-50/40">
<div v-if="showNewFolderInput" class="mx-4 sm:mx-6 mt-1 px-4 py-2.5 grid grid-cols-[2rem_minmax(0,1fr)_7rem] sm:grid-cols-[2rem_minmax(0,1fr)_8rem_7rem] md:grid-cols-[2rem_minmax(0,1fr)_6rem_8rem_7rem] gap-3 items-center rounded-lg border border-amber-200 bg-amber-50/40">
<div class="w-7 h-7 bg-amber-50 rounded-lg flex items-center justify-center">
<AppIcon name="folder" class="w-4 h-4 text-amber-400" />
</div>
<div class="flex items-center gap-2 col-span-4">
<div class="flex items-center gap-2 col-span-2 sm:col-span-3 md:col-span-4 min-w-0 flex-wrap">
<input
ref="newFolderInputRef"
v-model="newFolderName"
type="text"
placeholder="Folder name"
class="border border-gray-300 rounded-lg px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
class="border border-gray-300 rounded-lg px-2 py-1 text-sm min-w-0 flex-1 focus:outline-none focus:ring-2 focus:ring-indigo-500"
@keydown.enter="submitNewFolder"
@keydown.escape="cancelNewFolder"
/>
@@ -63,12 +115,12 @@
</div>
</div>
<div class="mx-6 mt-1 mb-6 flex flex-col divide-y divide-gray-100 border border-gray-100 rounded-xl overflow-hidden">
<div class="mx-4 sm:mx-6 mt-1 mb-6 flex flex-col divide-y divide-gray-100 border border-gray-100 rounded-xl overflow-hidden">
<div
v-for="folder in folders"
:key="`f-${folder.id}`"
class="px-4 py-2.5 grid grid-cols-[2rem_1fr_6rem] sm:grid-cols-[2rem_1fr_8rem_6rem] md:grid-cols-[2rem_1fr_6rem_8rem_6rem] gap-3 items-center hover:bg-gray-50 group cursor-pointer transition-colors"
class="px-4 py-2.5 grid grid-cols-[2rem_minmax(0,1fr)_7rem] sm:grid-cols-[2rem_minmax(0,1fr)_8rem_7rem] md:grid-cols-[2rem_minmax(0,1fr)_6rem_8rem_7rem] gap-3 items-center hover:bg-gray-50 group cursor-pointer transition-colors"
:class="{
'bg-amber-50 ring-2 ring-inset ring-amber-300': dragOverFolderId === folder.id,
'bg-gray-50': renamingId === folder.id,
@@ -97,7 +149,7 @@
<span class="text-right text-xs text-gray-400 hidden md:block"></span>
<span class="text-right text-xs text-gray-400 hidden sm:block">{{ formatDate(folder.created_at) }}</span>
<div class="flex justify-end gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity" @click.stop>
<div class="flex justify-end gap-0.5" data-test="folder-row-actions" @click.stop>
<template v-if="mode === 'local'">
<button @click.stop="startRename(folder)" title="Rename"
class="p-1.5 md:p-1.5 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 rounded hover:bg-gray-200 active:bg-gray-300 text-gray-400 hover:text-gray-700 transition-colors flex items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1">
@@ -116,7 +168,7 @@
v-for="file in files"
:key="`d-${file.id}`"
:draggable="mode === 'local'"
class="px-4 py-2.5 grid grid-cols-[2rem_1fr_6rem] sm:grid-cols-[2rem_1fr_8rem_6rem] md:grid-cols-[2rem_1fr_6rem_8rem_6rem] gap-3 items-center hover:bg-gray-50 group cursor-pointer transition-colors select-none"
class="px-4 py-2.5 grid grid-cols-[2rem_minmax(0,1fr)_7rem] sm:grid-cols-[2rem_minmax(0,1fr)_8rem_7rem] md:grid-cols-[2rem_minmax(0,1fr)_6rem_8rem_7rem] gap-3 items-center hover:bg-gray-50 group cursor-pointer transition-colors select-none"
:class="{ 'opacity-50': draggingFile?.id === file.id }"
@click="draggingFile ? null : $emit('file-open', file)"
@dragstart="mode === 'local' ? onFileDragStart(file, $event) : null"
@@ -144,7 +196,7 @@
<span class="text-right text-xs text-gray-400 hidden md:block">{{ formatSize(file.size_bytes ?? file.size) }}</span>
<span class="text-right text-xs text-gray-400 hidden sm:block">{{ formatDate(file.created_at) }}</span>
<div class="flex justify-end gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity" @click.stop>
<div class="flex justify-end gap-0.5" data-test="file-row-actions" @click.stop>
<template v-if="mode === 'local'">
<button @click.stop="$emit('file-share', file)" title="Share"
class="p-1.5 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 rounded hover:bg-gray-200 active:bg-gray-300 text-gray-400 hover:text-gray-700 transition-colors flex items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1">
@@ -171,7 +223,7 @@
<div
v-for="n in 5"
:key="`sk-${n}`"
class="px-4 py-2.5 grid grid-cols-[2rem_1fr_6rem] sm:grid-cols-[2rem_1fr_8rem_6rem] md:grid-cols-[2rem_1fr_6rem_8rem_6rem] gap-3 items-center border-b border-gray-100"
class="px-4 py-2.5 grid grid-cols-[2rem_minmax(0,1fr)_7rem] sm:grid-cols-[2rem_minmax(0,1fr)_8rem_7rem] md:grid-cols-[2rem_minmax(0,1fr)_6rem_8rem_7rem] gap-3 items-center border-b border-gray-100"
>
<div class="w-7 h-7 bg-gray-100 rounded-lg animate-pulse"></div>
<div class="h-4 bg-gray-100 rounded animate-pulse w-2/3"></div>
@@ -292,6 +344,8 @@ function topicColor(name) {
const dropZoneRef = ref(null)
const searchBarRef = ref(null)
const mobileSearchOpen = ref(false)
const mobileSortOpen = ref(false)
const showNewFolderInput = ref(false)
const newFolderName = ref('')
@@ -310,6 +364,11 @@ function cancelNewFolder() {
newFolderError.value = ''
}
function handleSortChange(payload) {
emit('sort-change', payload)
mobileSortOpen.value = false
}
async function submitNewFolder() {
const name = newFolderName.value.trim()
if (!name) {
@@ -46,23 +46,21 @@ describe('UX-02: StorageBrowser shows skeleton rows during loading', () => {
expect(pulseEls.length).toBe(0)
})
it('skeleton row has mobile base grid-cols-[2rem_1fr_6rem] class', () => {
it('skeleton row has mobile base icon/name/actions grid class', () => {
const wrapper = mount(StorageBrowser, {
props: { loading: true, folders: [], files: [] },
global: { stubs: globalStubs },
})
// Responsive grid: mobile base class (replaces old fixed grid-cols-[2rem_1fr_6rem_8rem_6rem])
const skeletonGrids = wrapper.findAll('.grid-cols-\\[2rem_1fr_6rem\\]')
const skeletonGrids = wrapper.findAll('.grid-cols-\\[2rem_minmax\\(0\\,1fr\\)_7rem\\]')
expect(skeletonGrids.length).toBeGreaterThanOrEqual(1)
})
it('skeleton row has md breakpoint full grid-cols-[2rem_1fr_6rem_8rem_6rem] class', () => {
it('skeleton row has md breakpoint full grid class', () => {
const wrapper = mount(StorageBrowser, {
props: { loading: true, folders: [], files: [] },
global: { stubs: globalStubs },
})
// Responsive grid: full-desktop class at md breakpoint
const skeletonGrids = wrapper.findAll('.md\\:grid-cols-\\[2rem_1fr_6rem_8rem_6rem\\]')
const skeletonGrids = wrapper.findAll('.md\\:grid-cols-\\[2rem_minmax\\(0\\,1fr\\)_6rem_8rem_7rem\\]')
expect(skeletonGrids.length).toBeGreaterThanOrEqual(1)
})
})
@@ -84,14 +82,14 @@ describe('RESP-02/RESP-03: StorageBrowser responsive column classes and touch ta
setActivePinia(createPinia())
})
it('list header has mobile base grid-cols-[2rem_1fr_6rem] class', () => {
it('list header has mobile base icon/name/actions grid class', () => {
const wrapper = mount(StorageBrowser, {
props: { mode: 'local', folders: [SAMPLE_FOLDER], files: [], loading: false },
global: { stubs: globalStubs },
})
const header = wrapper.find('[data-test="list-header"]')
expect(header.exists()).toBe(true)
expect(header.classes()).toContain('grid-cols-[2rem_1fr_6rem]')
expect(header.classes()).toContain('grid-cols-[2rem_minmax(0,1fr)_7rem]')
})
it('list header has md breakpoint full-column class', () => {
@@ -100,25 +98,25 @@ describe('RESP-02/RESP-03: StorageBrowser responsive column classes and touch ta
global: { stubs: globalStubs },
})
const header = wrapper.find('[data-test="list-header"]')
expect(header.classes()).toContain('md:grid-cols-[2rem_1fr_6rem_8rem_6rem]')
expect(header.classes()).toContain('md:grid-cols-[2rem_minmax(0,1fr)_6rem_8rem_7rem]')
})
it('folder row has mobile base grid-cols-[2rem_1fr_6rem] class', () => {
it('folder row has mobile base icon/name/actions grid class', () => {
const wrapper = mount(StorageBrowser, {
props: { mode: 'local', folders: [SAMPLE_FOLDER], files: [], loading: false },
global: { stubs: globalStubs },
})
const folderRows = wrapper.findAll('.grid-cols-\\[2rem_1fr_6rem\\]')
const folderRows = wrapper.findAll('.grid-cols-\\[2rem_minmax\\(0\\,1fr\\)_7rem\\]')
// At least the folder row and header exist
expect(folderRows.length).toBeGreaterThanOrEqual(1)
})
it('file row has mobile base grid-cols-[2rem_1fr_6rem] class', () => {
it('file row has mobile base icon/name/actions grid class', () => {
const wrapper = mount(StorageBrowser, {
props: { mode: 'local', folders: [], files: [SAMPLE_FILE], loading: false },
global: { stubs: globalStubs },
})
const fileRows = wrapper.findAll('.grid-cols-\\[2rem_1fr_6rem\\]')
const fileRows = wrapper.findAll('.grid-cols-\\[2rem_minmax\\(0\\,1fr\\)_7rem\\]')
expect(fileRows.length).toBeGreaterThanOrEqual(1)
})
@@ -165,6 +163,92 @@ describe('RESP-02/RESP-03: StorageBrowser responsive column classes and touch ta
expect(btn.classes()).toContain('min-h-[36px]')
}
})
it('file row actions are visible without hover-only opacity classes', () => {
const wrapper = mount(StorageBrowser, {
props: { mode: 'local', folders: [], files: [SAMPLE_FILE], loading: false },
global: { stubs: globalStubs },
})
const actions = wrapper.find('[data-test="file-row-actions"]')
expect(actions.exists()).toBe(true)
expect(actions.classes()).not.toContain('opacity-0')
expect(actions.classes()).not.toContain('group-hover:opacity-100')
})
it('folder row actions are visible without hover-only opacity classes', () => {
const wrapper = mount(StorageBrowser, {
props: { mode: 'local', folders: [SAMPLE_FOLDER], files: [], loading: false },
global: { stubs: globalStubs },
})
const actions = wrapper.find('[data-test="folder-row-actions"]')
expect(actions.exists()).toBe(true)
expect(actions.classes()).not.toContain('opacity-0')
expect(actions.classes()).not.toContain('group-hover:opacity-100')
})
})
describe('Plan 11-07: StorageBrowser compact mobile toolbar', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('renders icon-only mobile controls and hides full toolbar below sm', () => {
const wrapper = mount(StorageBrowser, {
props: { mode: 'local', folders: [], files: [], loading: false },
global: { stubs: globalStubs },
})
const mobileToolbar = wrapper.find('[data-test="mobile-toolbar"]')
const desktopToolbar = wrapper.find('[data-test="desktop-toolbar"]')
expect(mobileToolbar.exists()).toBe(true)
expect(mobileToolbar.classes()).toContain('sm:hidden')
expect(desktopToolbar.exists()).toBe(true)
expect(desktopToolbar.classes()).toContain('hidden')
expect(desktopToolbar.classes()).toContain('sm:flex')
expect(wrapper.find('button[aria-label="Search"]').exists()).toBe(true)
expect(wrapper.find('button[aria-label="Sort"]').exists()).toBe(true)
expect(wrapper.find('button[aria-label="New folder"]').exists()).toBe(true)
})
it('search icon toggles a full-width mobile search row', async () => {
const wrapper = mount(StorageBrowser, {
props: { mode: 'local', folders: [], files: [], loading: false },
global: { stubs: globalStubs },
})
expect(wrapper.find('[data-test="mobile-search-row"]').exists()).toBe(false)
await wrapper.find('button[aria-label="Search"]').trigger('click')
await nextTick()
const row = wrapper.find('[data-test="mobile-search-row"]')
expect(row.exists()).toBe(true)
expect(row.classes()).toContain('w-full')
})
it('sort icon opens mobile sort options and emits sort-change', async () => {
const wrapper = mount(StorageBrowser, {
props: { mode: 'local', folders: [], files: [], loading: false },
global: { stubs: { ...globalStubs, SortControls: false } },
})
await wrapper.find('button[aria-label="Sort"]').trigger('click')
await nextTick()
const panel = wrapper.find('[data-test="mobile-sort-panel"]')
expect(panel.exists()).toBe(true)
await panel.findAll('button').find(btn => btn.text().includes('Name')).trigger('click')
expect(wrapper.emitted('sort-change')).toEqual([[{ sort: 'name', order: 'desc' }]])
expect(wrapper.find('[data-test="mobile-sort-panel"]').exists()).toBe(false)
})
it('new-folder icon emits the existing new-folder event', async () => {
const wrapper = mount(StorageBrowser, {
props: { mode: 'local', folders: [], files: [], loading: false },
global: { stubs: globalStubs },
})
await wrapper.find('button[aria-label="New folder"]').trigger('click')
expect(wrapper.emitted('new-folder')).toHaveLength(1)
})
})
describe('UX-13: StorageBrowser folder picker uses Teleport + getBoundingClientRect', () => {