Moves phases 08–11 execution artifacts from .planning/phases/ to .planning/milestones/v0.2-phases/ to keep .planning/phases/ clean for the next milestone. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
272 lines
16 KiB
Markdown
272 lines
16 KiB
Markdown
---
|
|
phase: 10-ux-interaction
|
|
plan: 04
|
|
type: execute
|
|
wave: 0
|
|
depends_on: []
|
|
files_modified:
|
|
- frontend/src/stores/toast.js
|
|
- frontend/src/stores/__tests__/toast.test.js
|
|
- frontend/src/components/ui/ToastContainer.vue
|
|
- frontend/src/components/ui/__tests__/ToastContainer.test.js
|
|
- frontend/src/App.vue
|
|
autonomous: true
|
|
requirements: [UX-10]
|
|
must_haves:
|
|
truths:
|
|
- "Calling useToastStore().show('msg', 'success', 4000) appends a toast and auto-dismisses after the duration"
|
|
- "Calling dismiss(id) removes that toast from the array immediately"
|
|
- "Existing Phase 8 call sites (SettingsAccountTab, TotpEnrollment) work unchanged with the locked signature show(message, type, duration)"
|
|
- "ToastContainer renders one DOM node per toast, teleported to body, with bottom-right positioning"
|
|
- "ToastContainer is mounted at App.vue and visible across all routes (including admin layout)"
|
|
artifacts:
|
|
- path: "frontend/src/stores/toast.js"
|
|
provides: "Reactive toast store with toasts array + show + dismiss"
|
|
contains: "toasts"
|
|
- path: "frontend/src/components/ui/ToastContainer.vue"
|
|
provides: "Visual renderer for the toast stack"
|
|
- path: "frontend/src/stores/__tests__/toast.test.js"
|
|
provides: "Toast store tests (timers + signature)"
|
|
- path: "frontend/src/components/ui/__tests__/ToastContainer.test.js"
|
|
provides: "ToastContainer rendering tests"
|
|
key_links:
|
|
- from: "App.vue"
|
|
to: "ToastContainer.vue"
|
|
via: "<ToastContainer /> mounted alongside <router-view>"
|
|
pattern: "<ToastContainer"
|
|
- from: "ToastContainer.vue"
|
|
to: "useToastStore"
|
|
via: "store.toasts subscription"
|
|
pattern: "toastStore\\.toasts"
|
|
---
|
|
|
|
<objective>
|
|
Implement the toast notification system (per D-01..D-04): reactive store, visual container, and mount in App.vue. This replaces the Phase 8 no-op stub WITHOUT changing the locked `show(message, type, duration)` signature so that Phase 8 call sites (`SettingsAccountTab.vue`, `TotpEnrollment.vue`) continue to work.
|
|
|
|
Purpose: Wire UX-10 (toast notification system). Phase 8 only stubbed the store; Phase 10 makes toasts actually visible. Toasts appear bottom-right, stack upward (D-01), use colored left-border accent + inline icon per type (D-02), are teleported to body (D-03).
|
|
|
|
Output:
|
|
- `stores/toast.js` — reactive `toasts` array, `show` action (auto-dismiss via setTimeout), `dismiss` action
|
|
- `components/ui/ToastContainer.vue` — Teleport-to-body container with TransitionGroup
|
|
- `App.vue` — mount `<ToastContainer />`
|
|
- Vitest tests for both store and container
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@CLAUDE.md
|
|
@.planning/phases/10-ux-interaction/10-CONTEXT.md
|
|
@.planning/phases/10-ux-interaction/10-RESEARCH.md
|
|
@.planning/phases/10-ux-interaction/10-PATTERNS.md
|
|
@frontend/src/stores/toast.js
|
|
@frontend/src/App.vue
|
|
@frontend/src/components/ui/SearchableModelSelect.vue
|
|
@frontend/src/components/settings/SettingsAccountTab.vue
|
|
@frontend/src/components/auth/TotpEnrollment.vue
|
|
|
|
<interfaces>
|
|
**Toast store (setup-store form — D-04 LOCKED signature):**
|
|
```js
|
|
import { ref } from 'vue'
|
|
import { defineStore } from 'pinia'
|
|
|
|
export const useToastStore = defineStore('toast', () => {
|
|
const toasts = ref([]) // [{ id, message, type, duration }]
|
|
|
|
function show(message, type = 'success', duration = 4000) {
|
|
const id = Date.now() + Math.random()
|
|
toasts.value.push({ id, message, type, duration })
|
|
if (duration > 0) setTimeout(() => dismiss(id), duration)
|
|
}
|
|
|
|
function dismiss(id) {
|
|
toasts.value = toasts.value.filter(t => t.id !== id)
|
|
}
|
|
|
|
return { toasts, show, dismiss }
|
|
})
|
|
```
|
|
|
|
**Type → visual classes (D-02):**
|
|
| type | accent (left-bar) | icon name | icon color |
|
|
|------|-------------------|-----------|------------|
|
|
| success | bg-green-500 | checkCircle | text-green-500 |
|
|
| error | bg-red-500 | exclamationCircle | text-red-500 |
|
|
| warning | bg-amber-400 | warning | text-amber-500 |
|
|
| info | bg-sky-400 | exclamationCircle | text-sky-500 |
|
|
|
|
**Container layout (D-01, D-03):**
|
|
- Teleport target: `to="body"`
|
|
- Wrapper: `fixed bottom-4 right-4 z-[9999] flex flex-col-reverse gap-2 pointer-events-none`
|
|
- Each toast: `pointer-events-auto flex items-center gap-3 bg-white rounded-xl shadow-lg border border-gray-100 overflow-hidden max-w-sm min-w-[280px]` with `@click="dismiss(toast.id)"`
|
|
|
|
**App.vue mount point (current state):**
|
|
- App.vue uses `<script setup>` (Composition API exception per CLAUDE.md)
|
|
- `<router-view />` lives inside `<main>` for non-auth routes
|
|
- Add `<ToastContainer />` AFTER the layout `<div>` so it floats over all routes including AuthLayout
|
|
</interfaces>
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 1: Write failing tests for toast store + ToastContainer</name>
|
|
<files>frontend/src/stores/__tests__/toast.test.js, frontend/src/components/ui/__tests__/ToastContainer.test.js</files>
|
|
<read_first>
|
|
- frontend/src/stores/toast.js (current Phase 8 stub)
|
|
- frontend/src/stores/__tests__/cloudConnections.test.js (Pinia test setup pattern)
|
|
- .planning/phases/10-ux-interaction/10-PATTERNS.md §"toast.js" and §"ToastContainer.vue"
|
|
</read_first>
|
|
<behavior>
|
|
**toast.test.js (6 tests):**
|
|
- Test 1: `show(msg, type, duration) appends a toast with the given fields` — setActivePinia + create store, call show('Hi', 'success', 4000), assert store.toasts.length === 1 and the toast has message='Hi', type='success', duration=4000
|
|
- Test 2: `show defaults type to 'success' and duration to 4000` — call show('Hi'), assert toasts[0].type === 'success' and toasts[0].duration === 4000
|
|
- Test 3: `auto-dismisses after duration using fake timers` — use vi.useFakeTimers(), call show('Hi', 'success', 4000), assert length===1, advance time by 4000ms via vi.advanceTimersByTime(4000), assert length===0
|
|
- Test 4: `dismiss(id) removes the toast immediately` — show, capture id from toasts[0].id, call dismiss(id), assert length===0
|
|
- Test 5: `duration=0 disables auto-dismiss` — vi.useFakeTimers(), show('Hi', 'success', 0), advance 10000ms, assert length still === 1
|
|
- Test 6: `multiple toasts stack with unique ids` — call show three times, assert toasts.length === 3 and all ids are distinct
|
|
|
|
**ToastContainer.test.js (4 tests):**
|
|
- Test 1: `renders nothing when store.toasts is empty` — mount with empty store, assert wrapper.find('[data-test="toast"]').exists() === false (or assert no .bg-white toast cards)
|
|
- Test 2: `renders one element per toast` — populate store with 2 toasts, mount, assert wrapper.findAll('[data-test="toast"]').length === 2 (or count by class)
|
|
- Test 3: `clicking a toast calls dismiss(id)` — populate store with 1 toast, mount, click the toast, assert store.toasts.length === 0
|
|
- Test 4: `accent class matches type (success → bg-green-500)` — populate store with 1 success toast, mount, find the accent bar element, assert its classList includes 'bg-green-500'
|
|
</behavior>
|
|
<action>
|
|
Create both test files.
|
|
|
|
For `frontend/src/stores/__tests__/toast.test.js`: use `import { setActivePinia, createPinia } from 'pinia'` and `beforeEach(() => setActivePinia(createPinia()))`. Use `vi.useFakeTimers()` / `vi.useRealTimers()` for timer tests.
|
|
|
|
For `frontend/src/components/ui/__tests__/ToastContainer.test.js`: setActivePinia + createPinia in beforeEach. Mount ToastContainer with `global.stubs: { AppIcon: true, Teleport: true }` (Teleport stub disables the body-teleport so wrapper.find works on the rendered DOM). Add `data-test="toast"` attribute to the toast element template (Task 2). Use a manual fixture data-test selector OR identify the toast via a stable class like `pointer-events-auto`.
|
|
|
|
Tests fail until Task 2 implements the store + container.
|
|
</action>
|
|
<verify>
|
|
<automated>cd frontend && npm run test -- --run toast</automated>
|
|
Expected: tests collected; ~10 failures total across the two files.
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- `frontend/src/stores/__tests__/toast.test.js` exists with 6 `it(...)` blocks
|
|
- `frontend/src/components/ui/__tests__/ToastContainer.test.js` exists with 4 `it(...)` blocks
|
|
- Toast store tests use vi.useFakeTimers() correctly (no flaky time-based assertions)
|
|
- ToastContainer tests stub Teleport and AppIcon
|
|
- Running `cd frontend && npm run test -- --run toast` shows ~10 failing tests
|
|
</acceptance_criteria>
|
|
<done>10 failing tests covering store and container contracts.</done>
|
|
</task>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 2: Implement toast.js store + ToastContainer.vue + mount in App.vue</name>
|
|
<files>frontend/src/stores/toast.js, frontend/src/components/ui/ToastContainer.vue, frontend/src/App.vue</files>
|
|
<read_first>
|
|
- frontend/src/stores/toast.js (current stub — DO NOT change exported signature)
|
|
- frontend/src/stores/__tests__/toast.test.js (tests from Task 1)
|
|
- frontend/src/components/ui/__tests__/ToastContainer.test.js (tests from Task 1)
|
|
- frontend/src/App.vue (current state — uses <script setup>)
|
|
- frontend/src/components/ui/SearchableModelSelect.vue (Teleport pattern reference)
|
|
- .planning/phases/10-ux-interaction/10-PATTERNS.md §"toast.js" and §"ToastContainer.vue"
|
|
- frontend/src/components/settings/SettingsAccountTab.vue (Phase 8 call site — must keep working)
|
|
- frontend/src/components/auth/TotpEnrollment.vue (Phase 8 call site — must keep working)
|
|
</read_first>
|
|
<behavior>
|
|
1. `stores/toast.js`: setup-store form (D-04 LOCKED — keep `defineStore('toast', () => {...})`). Returns `{ toasts, show, dismiss }`. Signature MUST remain `show(message, type = 'success', duration = 4000)`.
|
|
2. `components/ui/ToastContainer.vue`: Options API, registers `AppIcon`. Computed maps for `accentClass(type)`, `iconName(type)`, `iconColorClass(type)` using the Type table in <interfaces>. Template wraps everything in `<Teleport to="body">` with `<TransitionGroup name="toast">`. Each toast div has `data-test="toast"`, `@click="toastStore.dismiss(toast.id)"`, and includes (in order) a left accent bar div, an `<AppIcon>`, and the `{{ toast.message }}` paragraph.
|
|
3. `App.vue`: Add `import ToastContainer from './components/ui/ToastContainer.vue'` to the `<script setup>` block. Add `<ToastContainer />` to the template — place it AFTER the existing `<div v-else>` so it floats over all routes. Add the `<style>` block (scoped or global) for `.toast-enter-active`, `.toast-enter-from`, `.toast-leave-active`, `.toast-leave-to` providing a simple fade+translate transition.
|
|
</behavior>
|
|
<action>
|
|
**Step A — toast.js:**
|
|
Replace the current stub with the implementation from `10-PATTERNS.md §"toast.js"`. Keep the file structure identical to the stub (default export `defineStore('toast', () => {...})`). Implementation:
|
|
```js
|
|
import { ref } from 'vue'
|
|
import { defineStore } from 'pinia'
|
|
|
|
export const useToastStore = defineStore('toast', () => {
|
|
const toasts = ref([])
|
|
function show(message, type = 'success', duration = 4000) {
|
|
const id = Date.now() + Math.random()
|
|
toasts.value.push({ id, message, type, duration })
|
|
if (duration > 0) setTimeout(() => dismiss(id), duration)
|
|
}
|
|
function dismiss(id) {
|
|
toasts.value = toasts.value.filter(t => t.id !== id)
|
|
}
|
|
return { toasts, show, dismiss }
|
|
})
|
|
```
|
|
|
|
**Step B — ToastContainer.vue:**
|
|
Create using Options API. Methods `accentClass(type)`, `iconName(type)`, `iconColorClass(type)` returning the table values in <interfaces>. The toast item template:
|
|
```
|
|
<div
|
|
v-for="toast in toastStore.toasts"
|
|
:key="toast.id"
|
|
data-test="toast"
|
|
class="pointer-events-auto flex items-center gap-3 bg-white rounded-xl shadow-lg border border-gray-100 overflow-hidden max-w-sm min-w-[280px] cursor-pointer"
|
|
@click="toastStore.dismiss(toast.id)"
|
|
>
|
|
<div class="w-1 self-stretch shrink-0" :class="accentClass(toast.type)"></div>
|
|
<AppIcon :name="iconName(toast.type)" class="w-5 h-5 shrink-0" :class="iconColorClass(toast.type)" />
|
|
<p class="text-sm text-gray-800 flex-1 py-3 pr-4">{{ toast.message }}</p>
|
|
</div>
|
|
```
|
|
Wrap in `<Teleport to="body"><div class="fixed bottom-4 right-4 z-[9999] flex flex-col-reverse gap-2 pointer-events-none"><TransitionGroup name="toast">...</TransitionGroup></div></Teleport>`.
|
|
|
|
Setup the store via `import { useToastStore } from '../../stores/toast.js'` and expose it as `toastStore` in `data() { return { toastStore: useToastStore() } }` (Options API store wiring; project uses relative paths — no `@/` alias configured).
|
|
|
|
Add a `<style scoped>` block defining `.toast-enter-active`, `.toast-leave-active { transition: all 0.2s ease }`, `.toast-enter-from, .toast-leave-to { opacity: 0; transform: translateX(20px) }`.
|
|
|
|
**Step C — App.vue:**
|
|
Modify App.vue (currently `<script setup>`). Add to imports:
|
|
```js
|
|
import ToastContainer from './components/ui/ToastContainer.vue'
|
|
```
|
|
Add to template AFTER the closing `</div>` of the layout wrapper:
|
|
```
|
|
<ToastContainer />
|
|
```
|
|
Keep the existing structure (AuthLayout v-if / div v-else / AppSidebar / main / router-view) entirely intact. Do NOT remove or rename anything in App.vue beyond adding the import and the `<ToastContainer />` element.
|
|
|
|
NO comments in any file.
|
|
</action>
|
|
<verify>
|
|
<automated>cd frontend && npm run test -- --run toast && cd frontend && npm run test -- --run ToastContainer</automated>
|
|
Expected: 10 tests pass (6 store + 4 container). The pre-existing tests `SettingsAccountTab.test.js` and `TotpEnrollment.test.js` continue to pass because the `show` signature is unchanged.
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- `frontend/src/stores/toast.js` contains `ref([])` and `setTimeout(() => dismiss(id), duration)` lines
|
|
- `frontend/src/stores/toast.js` exports `useToastStore` with `{ toasts, show, dismiss }` returned
|
|
- The `show` function signature is unchanged: `show(message, type = 'success', duration = 4000)` (grep: `grep -E "function show\\(message,\\s*type\\s*=\\s*'success',\\s*duration\\s*=\\s*4000\\)" frontend/src/stores/toast.js` returns 1)
|
|
- `frontend/src/components/ui/ToastContainer.vue` exists, imports AppIcon, uses `<Teleport to="body">`, uses `<TransitionGroup name="toast">`, includes `data-test="toast"` attribute on the toast element
|
|
- `frontend/src/App.vue` contains `import ToastContainer` and `<ToastContainer />` in the template (grep: `grep -c "ToastContainer" frontend/src/App.vue` returns ≥ 2)
|
|
- `cd frontend && npm run test -- --run toast` exits 0
|
|
- `cd frontend && npm run test -- --run ToastContainer` exits 0
|
|
- `cd frontend && npm run test -- --run SettingsAccountTab` still exits 0 (Phase 8 regression check)
|
|
- `cd frontend && npm run test -- --run TotpEnrollment` still exits 0 (Phase 8 regression check)
|
|
</acceptance_criteria>
|
|
<done>Toast store reactive, ToastContainer rendering, App.vue mounted, all tests green, Phase 8 sites unchanged.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<verification>
|
|
- `cd frontend && npm run test -- --run toast` exits 0
|
|
- `cd frontend && npm run test -- --run ToastContainer` exits 0
|
|
- `cd frontend && npm run test -- --run SettingsAccountTab` exits 0 (regression)
|
|
- `cd frontend && npm run test -- --run TotpEnrollment` exits 0 (regression)
|
|
- `grep -E "show\\(message,\\s*type\\s*=\\s*'success',\\s*duration\\s*=\\s*4000\\)" frontend/src/stores/toast.js` returns 1 (locked signature preserved)
|
|
- `grep -E "<ToastContainer" frontend/src/App.vue` returns 1
|
|
- `grep -E "Teleport to=\"body\"" frontend/src/components/ui/ToastContainer.vue` returns 1
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
The toast system is fully wired and visible across the app. Wave 1 plan 10-09 can add `useToastStore().show('Document moved', 'success')` calls inside `FileManagerView.vue` action handlers and a real toast will appear bottom-right. Phase 8 call sites (`SettingsAccountTab`, `TotpEnrollment`) work unchanged.
|
|
</success_criteria>
|
|
|
|
<output>
|
|
Create `.planning/phases/10-ux-interaction/10-04-SUMMARY.md` when done.
|
|
</output>
|