From 06c453a6f8253727ff0f4710d34cf8b68fdf5fac Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Thu, 25 Jun 2026 08:09:02 +0900 Subject: [PATCH 1/4] feat: BrainDump completion toast close button + display-duration preference (#109) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a sonner close (✕) button to the BrainDump completion toast and a persisted, configurable display duration (braindumpToastDurationMs) that controls how long the toast lingers before it auto-dismisses. - New preference braindumpToastDurationMs (default 5000ms, clamped [2000,10000]) mirrors the #108 11-file preference pattern: constant, Zod schema field (.finite().transform(clamp).catch(default)), slice reducer+selector+action, cross-window sync allowlist, BrainDumpAppearance slider, BrainDumpEditor consume. - showCompletionToast helper centralizes toast.success with closeButton:true, duration, action(Undo), and onAutoClose/onDismiss callbacks for both the complete-only (site A) and clear-on-complete (site B) paths. - Site B disambiguates Undo-via-onDismiss from a real dismiss with a wasUndoCalled guard so the restored line survives a close click. - scheduleDeferredClear clamps the effective clear delay to the toast duration (min) so a line never outlives its own Undo toast. - Toaster sets closeButtonAriaLabel: 'Dismiss' (Toaster-level per sonner 2.x). Tests: schema defaults/clamp/NaN-heal, slice reducer+selector, cross-window propagation, appearance slider (track/readout/always-enabled/step), and editor toast (closeButton+duration, description copy, Undo-restore, min clamp). Closes #109 --- .../braindump/BrainDumpEditor.test.tsx | 146 +++++++++++++++++ src/components/braindump/BrainDumpEditor.tsx | 153 +++++++++++++----- .../electron/BrainDumpAppearance.test.tsx | 60 ++++++- .../electron/BrainDumpAppearance.tsx | 57 +++++++ src/components/ui/sonner.tsx | 7 +- src/lib/constants/braindump.ts | 36 +++++ src/lib/preferences-sync-channel.test.ts | 15 ++ src/lib/preferences-sync-channel.ts | 2 + src/lib/redux/slices/preferencesSlice.test.ts | 44 +++++ src/lib/redux/slices/preferencesSlice.ts | 31 ++++ src/lib/schemas/preferences.test.ts | 34 ++++ src/lib/schemas/preferences.ts | 17 ++ 12 files changed, 556 insertions(+), 46 deletions(-) diff --git a/src/components/braindump/BrainDumpEditor.test.tsx b/src/components/braindump/BrainDumpEditor.test.tsx index 2c9db6d..cdeac4e 100644 --- a/src/components/braindump/BrainDumpEditor.test.tsx +++ b/src/components/braindump/BrainDumpEditor.test.tsx @@ -1157,3 +1157,149 @@ describe('BrainDumpEditor clear-on-complete (deferred linger)', () => { ) }) }) + +describe('BrainDumpEditor completion toast — close button + display duration (#109)', () => { + beforeEach(() => { + vi.clearAllMocks() + completedMutateAsync.mockResolvedValue({ id: 1 }) + // Reset the active floating category — the clamp spec leaves it on 1, but be + // explicit so a future cross-category spec here can't bleed state. + selectedCategoryRef.current = 1 + }) + + it('shows the completion toast with a close button and the configured display duration', async () => { + // Arrange — clear-on-complete OFF (the always-shown toast path), with an + // 8 s display duration saved. + installBrainDumpAPI({ + getVisibleOnAllWorkspaces: vi.fn().mockResolvedValue(false), + setVisibleOnAllWorkspaces: vi.fn().mockResolvedValue(true), + }) + renderEditor({ braindumpToastDurationMs: 8000 }) + const noteField = await screen.findByRole('textbox') + + // Act — complete a plain line. + fireCompleteCommandOnFirstLine(noteField, 'buy milk') + + // Assert — the success toast carries the ✕ (closeButton) and stays for the + // saved 8000 ms, not the old fixed 5 s. + await waitFor(() => { + expect(toast.success).toHaveBeenCalled() + }) + const toastOptions = vi.mocked(toast.success).mock.calls.at(-1)?.[1] + expect(toastOptions?.closeButton).toBe(true) + expect(toastOptions?.duration).toBe(8000) + }) + + it('phrases the Undo-window copy for the configured display duration', async () => { + // Arrange — an 8 s duration must read "within 8 s", not a hardcoded "5 s". + installBrainDumpAPI({ + getVisibleOnAllWorkspaces: vi.fn().mockResolvedValue(false), + setVisibleOnAllWorkspaces: vi.fn().mockResolvedValue(true), + }) + renderEditor({ braindumpToastDurationMs: 8000 }) + const noteField = await screen.findByRole('textbox') + + // Act + fireCompleteCommandOnFirstLine(noteField, 'buy milk') + + // Assert — the description names the actual undo window in seconds. + await waitFor(() => { + expect(toast.success).toHaveBeenCalled() + }) + const toastOptions = vi.mocked(toast.success).mock.calls.at(-1)?.[1] + expect(toastOptions?.description).toBe('Tap Undo within 8 s to revert.') + }) + + it('keeps the close button and configured duration on the clear-on-complete toast', async () => { + // Arrange — clear-on-complete ON with instant clear and a 6 s duration: the + // SAME helper must wire the ✕ + duration on this second completion path too. + installBrainDumpAPI({ + getVisibleOnAllWorkspaces: vi.fn().mockResolvedValue(false), + setVisibleOnAllWorkspaces: vi.fn().mockResolvedValue(true), + }) + renderEditor({ + braindumpClearOnComplete: true, + braindumpClearDelayMs: 0, + braindumpToastDurationMs: 6000, + }) + const noteField = await screen.findByRole('textbox') + + // Act — complete the only line (it clears instantly). + fireCompleteCommandOnFirstLine(noteField, 'buy milk') + await waitFor(() => { + expect(noteField).toHaveValue('') + }) + + // Assert — the clear toast also has the ✕ and the saved 6000 ms duration. + const toastOptions = vi.mocked(toast.success).mock.calls.at(-1)?.[1] + expect(toastOptions?.closeButton).toBe(true) + expect(toastOptions?.duration).toBe(6000) + }) + + it('still restores the cleared line on Undo even though the toast now fires onDismiss on close', async () => { + // Arrange — clear-on-complete ON; the ✕ adds an onDismiss that BOTH a ✕ and an + // Undo trigger. Undo must still revert, and the trailing onDismiss must NOT + // confirm the win away (the call-site wasUndoCalled guard — CEO-D4). + installBrainDumpAPI({ + getVisibleOnAllWorkspaces: vi.fn().mockResolvedValue(false), + setVisibleOnAllWorkspaces: vi.fn().mockResolvedValue(true), + }) + renderEditor({ + braindumpClearOnComplete: true, + braindumpClearDelayMs: 0, + braindumpToastDurationMs: 6000, + }) + const noteField = await screen.findByRole('textbox') + const value = 'keep me\n- [ ] buy milk' + fireEvent.change(noteField, { target: { value } }) + const caret = value.length // caret at end of the second line + noteField.selectionStart = caret + noteField.selectionEnd = caret + fireEvent.keyDown(noteField, { key: 'Enter', metaKey: true }) + await waitFor(() => { + expect(noteField).toHaveValue('keep me') + }) + + // Act — tap Undo, THEN let sonner fire onDismiss (it dismisses after the + // action runs); the guard must keep the restored line in place. + const toastOptions = vi.mocked(toast.success).mock.calls.at(-1)?.[1] + const undoAction = toastOptions?.action as + | { onClick: () => void } + | undefined + await act(async () => { + undoAction?.onClick() + }) + act(() => { + toastOptions?.onDismiss?.({} as ToastT) + }) + + // Assert — the verbatim line returns at index 1 and stays there. + expect(noteField).toHaveValue('keep me\n- [ ] buy milk') + }) + + it('clamps the clear linger down to the shorter toast duration so a line never outlasts its Undo', async () => { + // Arrange — a clear delay (300 ms) LONGER than the toast duration (100 ms). + // The runtime min() must remove the line when the toast (and its Undo) closes + // at 100 ms, never letting it linger the full 300 ms (#109 replaces #108's + // fixed ceiling). In production this is the clearDelay-5000 vs toast-2000 case. + installBrainDumpAPI({ + getVisibleOnAllWorkspaces: vi.fn().mockResolvedValue(false), + setVisibleOnAllWorkspaces: vi.fn().mockResolvedValue(true), + }) + renderEditor({ + braindumpClearOnComplete: true, + braindumpClearDelayMs: 300, + braindumpToastDurationMs: 100, + }) + const noteField = await screen.findByRole('textbox') + + // Act — complete line 0; the deferred path leaves it on screen for now. + fireCompleteCommandOnFirstLine(noteField, 'buy milk\nkeep me') + expect(noteField).toHaveValue('buy milk\nkeep me') + + // Assert — after 150 ms (past the 100 ms toast, before the 300 ms delay) the + // line is already gone: the clamp picked the shorter toast duration. + await new Promise((resolve) => setTimeout(resolve, 150)) + expect(noteField).toHaveValue('keep me') + }) +}) diff --git a/src/components/braindump/BrainDumpEditor.tsx b/src/components/braindump/BrainDumpEditor.tsx index 288c3f1..2dd6982 100644 --- a/src/components/braindump/BrainDumpEditor.tsx +++ b/src/components/braindump/BrainDumpEditor.tsx @@ -27,7 +27,6 @@ import { BRAINDUMP_OPACITY_MAX, BRAINDUMP_OPACITY_MIN, BRAINDUMP_OPACITY_STEP, - BRAINDUMP_TOAST_UNDO_MS, } from '@/lib/constants/braindump' import { log } from '@/lib/logger' import { orpc } from '@/lib/orpc/client-query' @@ -38,6 +37,7 @@ import { selectBraindumpFontFamily, selectBraindumpFontSize, selectBraindumpTextColor, + selectBraindumpToastDurationMs, } from '@/lib/redux/slices/preferencesSlice' import { broadcastTodoSync } from '@/lib/todo-sync-channel' import type { Category, CategoryWithCount } from '@/server/schemas/category' @@ -178,6 +178,62 @@ function findCheckedLineIndexByTitle( return null } +/** + * Build the BrainDump completion toast — the `Completed: ` success toast + * with an Undo action AND a close (✕) button. Both completion paths + * (`promoteLineToCompleted`, `completeAndClearLine`) call this so the ✕, the + * configurable display duration, the dynamic Undo-window copy, and the Undo + * wiring live in ONE place and can't drift between the two sites (#109). + * + * The two sites differ only in their dismiss bookkeeping, so `onAutoClose` / + * `onDismiss` are passed in: site A ties its line-clear to `onAutoClose` and has + * no `onDismiss`; site B's clear is timer-independent and runs the same cleanup + * on BOTH auto-close and a ✕-close. Sonner can't distinguish a ✕-close from an + * Undo-close (both fire `onDismiss`), so that disambiguation is owned by the call + * site (which captures the Undo handler), never by this helper. + * + * @param params - Toast inputs. + * @param params.title - Already-normalised completed title (rendered `Completed: <title>`). + * @param params.durationMs - How long the toast stays before auto-dismiss (the user pref). + * @param params.onUndo - Runs when the user clicks Undo; the toast is dismissed right after. + * @param params.onAutoClose - Optional: runs when the toast times out (NOT on ✕/Undo). + * @param params.onDismiss - Optional: runs on a manual close (✕ OR Undo — caller guards). + * @returns The sonner toast id, so the caller can dismiss it later (e.g. on a late create failure). + * @example + * const id = showCompletionToast({ title, durationMs: 5000, onUndo: () => revert() }) + */ +function showCompletionToast({ + title, + durationMs, + onUndo, + onAutoClose, + onDismiss, +}: { + title: BrainDumpCompletedTitle + durationMs: number + onUndo: () => void + onAutoClose?: () => void + onDismiss?: () => void +}): string | number { + const toastId = toast.success(`Completed: ${title}`, { + // Dynamic Undo-window copy — once the duration is user-configurable a fixed + // "5 s" would be wrong at every non-default setting (#109). + description: `Tap Undo within ${Math.round(durationMs / 1000)} s to revert.`, + duration: durationMs, + closeButton: true, + action: { + label: 'Undo', + onClick: () => { + onUndo() + toast.dismiss(toastId) + }, + }, + onAutoClose, + onDismiss, + }) + return toastId +} + /** * BrainDumpEditor — frameless transparent panel that pairs a category picker * with a freeform textarea using markdown checkboxes. @@ -233,6 +289,9 @@ export const BrainDumpEditor = function BrainDumpEditor({ // When ON, a finished line is dropped once its undo window closes (see the // toast's onAutoClose in promoteLineToCompleted). Default OFF keeps every line. const clearOnComplete = useAppSelector(selectBraindumpClearOnComplete) + const braindumpToastDurationMs = useAppSelector( + selectBraindumpToastDurationMs, + ) // How long the finished line lingers before it's removed (clear-on-complete ON // path). 0 = remove instantly (prior behaviour); >0 defers the removal by this // many ms so the eye registers the completion first (#108). Clamped ≤ the Undo @@ -664,29 +723,26 @@ export const BrainDumpEditor = function BrainDumpEditor({ }) await syncCompletedAcrossViews() - const undoToastId = toast.success(`Completed: ${safeTitle}`, { - description: 'Tap Undo within 5 s to revert.', - duration: BRAINDUMP_TOAST_UNDO_MS, - action: { - label: 'Undo', - onClick: () => { - // Read latest text via ref so the user's keystrokes between - // creation and undo are preserved. Pass the captured title so - // undoCompleted can re-resolve the current line index even if - // lines have drifted. - void undoCompleted( - safeTitle, - completedId, - noteTextRef.current, - lineIndex, - ) - toast.dismiss(undoToastId) - }, + showCompletionToast({ + title: safeTitle, + durationMs: braindumpToastDurationMs, + // Read latest text via ref so the user's keystrokes between creation and + // undo are preserved. Pass the captured title so undoCompleted can + // re-resolve the current line index even if lines have drifted. + onUndo: () => { + void undoCompleted( + safeTitle, + completedId, + noteTextRef.current, + lineIndex, + ) }, // Clear-on-complete: when the toast auto-closes (the undo window elapsed // without an Undo), drop the finished line so the scratchpad clears as - // you go. Sonner fires onAutoClose ONLY on the timeout — an Undo click - // dismisses (onDismiss) instead, so undone completions are never cleared. + // you go. Sonner fires onAutoClose ONLY on the timeout — an Undo OR a ✕ + // fires onDismiss instead (site A wires no onDismiss), so an undone or + // ✕-closed completion is never cleared here; the ✕ just hides the toast + // early (this site's clear is toast-tied, so dismissing simply skips it). onAutoClose: clearOnComplete ? () => { // Tie the clear to THIS completion via its completedId entry in @@ -870,6 +926,15 @@ export const BrainDumpEditor = function BrainDumpEditor({ * scheduleDeferredClear(entry) // drops entry's line after clearDelayMs, unless undone/edited */ const scheduleDeferredClear = (entry: ClearedLineMemory): void => { + // Clamp the linger to the toast duration so a finished line can never outlast + // its own Undo: once the toast (carrying that Undo) auto-closes, the line must + // already be gone. #108 used a FIXED ceiling (BRAINDUMP_CLEAR_DELAY_MAX_MS); + // now that the toast duration is user-configurable (#109) this live `min()` + // does that job — the clear-delay slider keeps its own fixed [0,5000] bounds. + const effectiveClearDelayMs = Math.min( + clearDelayMs, + braindumpToastDurationMs, + ) const removalTimerId = window.setTimeout(() => { // No longer pending — drop it from tracking before doing anything else. pendingClearTimersRef.current.delete(entry.token) @@ -908,7 +973,7 @@ export const BrainDumpEditor = function BrainDumpEditor({ pendingEntry.originalLineIndex -= 1 } } - }, clearDelayMs) + }, effectiveClearDelayMs) entry.removalTimerId = removalTimerId pendingClearTimersRef.current.set(entry.token, entry) } @@ -1050,24 +1115,34 @@ export const BrainDumpEditor = function BrainDumpEditor({ // 4) Immediate optimistic toast — feedback at keypress, not after the // round-trip. Undo re-inserts the line; auto-close just closes the // affordance (the line is already gone). - entry.toastId = toast.success(`Completed: ${safeTitle}`, { - description: 'Tap Undo within 5 s to revert.', - duration: BRAINDUMP_TOAST_UNDO_MS, - action: { - label: 'Undo', - onClick: () => { - void undoClearedCompletion(entry, createPromise) - if (entry.toastId !== undefined) toast.dismiss(entry.toastId) - }, + // Confirm-cleanup: the undo window closed WITHOUT an undo (a timeout, OR a + // manual ✕ close) → mark the outcome confirmed and drop the map entry. The + // create promise's closure still holds the reinsert info for a late failure, + // so this is safe. (The deferred-clear timer is independent and still fires.) + const confirmClearedCompletion = (): void => { + if (entry.outcome === 'pending') entry.outcome = 'confirmed' + clearedLinesRef.current.delete(token) + } + // A manual ✕ close and an Undo BOTH fire sonner's onDismiss, but only the ✕ + // should run confirmClearedCompletion (Undo already reverts via + // undoClearedCompletion). This call-site flag — set by onUndo, read by + // onDismiss — is what tells the two dismiss paths apart; the helper can't, + // since sonner reports both as a plain dismiss (#109 / CEO-D4). Without it, a + // ✕ would leak a stale clearedLinesRef entry and leave outcome stuck 'pending'. + let wasUndoCalled = false + entry.toastId = showCompletionToast({ + title: safeTitle, + durationMs: braindumpToastDurationMs, + onUndo: () => { + wasUndoCalled = true + void undoClearedCompletion(entry, createPromise) }, - // Sonner fires onAutoClose ONLY on the timeout — an Undo click dismisses - // (onDismiss) instead, so this never runs for an undone completion. - onAutoClose: () => { - // Undo window elapsed with no Undo → confirm and drop the map entry. The - // create promise's closure still holds the reinsert info for a late - // failure, so this cleanup is safe. - if (entry.outcome === 'pending') entry.outcome = 'confirmed' - clearedLinesRef.current.delete(token) + // Timeout (no Undo, no ✕) → confirm + cleanup. + onAutoClose: confirmClearedCompletion, + // Manual close: a ✕ runs the SAME cleanup as a timeout; an Undo does not + // (it already reverted — the flag guards the double-run). + onDismiss: () => { + if (!wasUndoCalled) confirmClearedCompletion() }, }) } diff --git a/src/components/electron/BrainDumpAppearance.test.tsx b/src/components/electron/BrainDumpAppearance.test.tsx index 5c73e1f..9396461 100644 --- a/src/components/electron/BrainDumpAppearance.test.tsx +++ b/src/components/electron/BrainDumpAppearance.test.tsx @@ -29,15 +29,17 @@ function renderBrainDumpAppearance(overrides: Partial<PreferencesState> = {}) { } /** - * Find a slider thumb by its track max. The two sliders here (font size, max 24; - * clear delay, max 5000) can't be told apart by accessible name — the thumb - * (role="slider") carries none, because shadcn forwards aria-label to the slider - * ROOT, not the thumb — so the distinct track max is the stable discriminator. + * Find a slider thumb by its track max. The three sliders here (font size, max 24; + * clear delay, max 5000; toast duration, max 10000) can't be told apart by + * accessible name — the thumb (role="slider") carries none, because shadcn forwards + * aria-label to the slider ROOT, not the thumb — so the distinct track max is the + * stable discriminator. * - * @param max - The aria-valuemax to match ('24' = font size, '5000' = clear delay). + * @param max - The aria-valuemax to match ('24' = font size, '5000' = clear delay, + * '10000' = toast duration). * @returns The matching slider thumb element. * @example - * getSliderByMax('5000') // the clear-delay slider + * getSliderByMax('10000') // the toast-duration slider */ function getSliderByMax(max: string): HTMLElement { const slider = screen @@ -228,4 +230,50 @@ describe('BrainDumpAppearance — editor presentation', () => { // Assert — the delay rose by one 100 ms step in the slice. expect(store.getState().preferences.braindumpClearDelayMs).toBe(600) }) + + it('shows the BrainDump toast-duration slider at the saved duration on its [2000,10000] track', () => { + // Arrange / Act — a non-default saved confirmation duration. + renderBrainDumpAppearance({ braindumpToastDurationMs: 6000 }) + + // Assert — the toast-duration slider (the [2000,10000] track) reflects the + // saved ms. + const toastDurationSlider = getSliderByMax('10000') + expect(toastDurationSlider).toHaveAttribute('aria-valuenow', '6000') + expect(toastDurationSlider).toHaveAttribute('aria-valuemin', '2000') + expect(toastDurationSlider).toHaveAttribute('aria-valuemax', '10000') + }) + + it('reads out the BrainDump toast duration in milliseconds', () => { + // Arrange / Act + renderBrainDumpAppearance({ braindumpToastDurationMs: 6000 }) + + // Assert — the numeric readout names the exact duration in ms. + expect(screen.getByText('6000 ms')).toBeInTheDocument() + }) + + it('keeps the toast-duration slider enabled even when clear-on-complete is off', () => { + // Arrange / Act — the toast shows on EVERY completion, so its duration is + // always meaningful, unlike the clear delay which is moot when lines stay. + renderBrainDumpAppearance({ braindumpClearOnComplete: false }) + + // Assert — the slider is interactive (no disabled marker) regardless. + const toastDurationSlider = getSliderByMax('10000') + expect(toastDurationSlider).not.toHaveAttribute('data-disabled') + }) + + it('raises the saved toast duration by one 500 ms step when the slider is nudged right', () => { + // Arrange — a known 6000 ms so a single step lands on 6500. + const { store } = renderBrainDumpAppearance({ + braindumpToastDurationMs: 6000, + }) + const toastDurationSlider = getSliderByMax('10000') + + // Act — keyboard-nudge the thumb one step to the right (layout-free, unlike a + // pointer drag which jsdom can't measure). + toastDurationSlider.focus() + fireEvent.keyDown(toastDurationSlider, { key: 'ArrowRight' }) + + // Assert — the duration rose by one 500 ms step in the slice. + expect(store.getState().preferences.braindumpToastDurationMs).toBe(6500) + }) }) diff --git a/src/components/electron/BrainDumpAppearance.tsx b/src/components/electron/BrainDumpAppearance.tsx index 4e01857..5d10a1b 100644 --- a/src/components/electron/BrainDumpAppearance.tsx +++ b/src/components/electron/BrainDumpAppearance.tsx @@ -29,6 +29,9 @@ import { BRAINDUMP_FONT_SIZE_MIN_PX, BRAINDUMP_FONT_SIZE_STEP_PX, BRAINDUMP_TEXT_COLOR_PRESETS, + BRAINDUMP_TOAST_DURATION_MAX_MS, + BRAINDUMP_TOAST_DURATION_MIN_MS, + BRAINDUMP_TOAST_DURATION_STEP_MS, } from '@/lib/constants/braindump' import { useAppDispatch, useAppSelector } from '@/lib/redux/hooks' import { @@ -37,11 +40,13 @@ import { selectBraindumpFontFamily, selectBraindumpFontSize, selectBraindumpTextColor, + selectBraindumpToastDurationMs, setBraindumpClearDelayMs, setBraindumpClearOnComplete, setBraindumpFontFamily, setBraindumpFontSize, setBraindumpTextColor, + setBraindumpToastDurationMs, } from '@/lib/redux/slices/preferencesSlice' /** A 6-digit `#rrggbb` — the only shape a native `<input type="color">` accepts, so @@ -69,10 +74,14 @@ export const BrainDumpAppearance = selectBraindumpClearOnComplete, ) const braindumpClearDelayMs = useAppSelector(selectBraindumpClearDelayMs) + const braindumpToastDurationMs = useAppSelector( + selectBraindumpToastDurationMs, + ) // Radix Slider wants a stable single-thumb array; rebuild only on change. const fontSizeSliderValue = [braindumpFontSize] const clearDelaySliderValue = [braindumpClearDelayMs] + const toastDurationSliderValue = [braindumpToastDurationMs] // The active color is "custom" when it matches none of the themed presets — // then no preset radio is selected and the native picker owns the choice. @@ -115,6 +124,14 @@ export const BrainDumpAppearance = } } + const handleBraindumpToastDurationChange = (values: number[]): void => { + // Guard the first thumb value before dispatching. + const nextDuration = values[0] + if (typeof nextDuration === 'number') { + dispatch(setBraindumpToastDurationMs(nextDuration)) + } + } + const handleBraindumpTextColorPresetChange = (value: string): void => { // RadioGroup values ARE the preset cssValue strings, stored verbatim. dispatch(setBraindumpTextColor(value)) @@ -184,6 +201,46 @@ export const BrainDumpAppearance = ) : null} </div> + {/* Confirmation duration — how long the “Completed” toast (with its Undo) + stays before it fades. Unlike the clear delay, this ALWAYS applies + (every completion shows the toast), so it's never disabled. The new ✕ + on the toast dismisses it sooner (#109). Readout is raw ms — the house + convention (font-size shows raw px); the MIN of 2 s never hits a 0 + "Instant" floor, so no special label is needed. */} + <div className="space-y-2"> + <div className="flex items-center justify-between"> + <Label + htmlFor="braindump-toast-duration" + className="text-sm font-medium" + > + Confirmation duration + </Label> + <span className="text-xs tabular-nums text-muted-foreground"> + {`${braindumpToastDurationMs} ms`} + </span> + </div> + <Slider + id="braindump-toast-duration" + aria-label="BrainDump completion toast duration" + min={BRAINDUMP_TOAST_DURATION_MIN_MS} + max={BRAINDUMP_TOAST_DURATION_MAX_MS} + step={BRAINDUMP_TOAST_DURATION_STEP_MS} + value={toastDurationSliderValue} + onValueChange={handleBraindumpToastDurationChange} + /> + {/* End-labels orient the extremes; the DESIGN.md Caption tier (12px, + medium, uppercase, 0.05em) — muted so the slider still leads. "Quick" + (not "Instant" — that's the clear-delay 0-floor above) ↔ "Linger". */} + <div className="flex justify-between text-xs font-medium uppercase tracking-wider text-muted-foreground"> + <span>Quick</span> + <span>Linger</span> + </div> + <p className="text-xs text-muted-foreground"> + How long the “Completed” confirmation — with its Undo — stays before + it fades. Press the ✕ to dismiss it sooner. + </p> + </div> + {/* Font family — the three brand fonts; each label previews its face. */} <div className="space-y-2"> <span className="text-sm font-medium">Font</span> diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx index ba674e8..aed2445 100644 --- a/src/components/ui/sonner.tsx +++ b/src/components/ui/sonner.tsx @@ -7,7 +7,7 @@ import { Toaster as Sonner } from 'sonner' import { getThemeMode } from '@/lib/themes/registry' -function Toaster({ ...props }: ToasterProps) { +function Toaster({ toastOptions, ...props }: ToasterProps) { const { theme } = useTheme() // Sonner accepts only 'light' | 'dark' | 'system'. next-themes returns the raw // stored data-theme id (today 'light'/'dark'; a future '*-dark' from T7), so @@ -27,6 +27,11 @@ function Toaster({ ...props }: ToasterProps) { '--normal-border': 'var(--border)', } as React.CSSProperties } + // The close ✕ is a per-toast opt-in (`closeButton: true`), but its aria-label + // is a Toaster-level setting in sonner v2 (not a per-toast option), so it + // lives here. Only the BrainDump completion toast opts into the ✕ today, so + // this label applies just to it (#109). A caller's own toastOptions win. + toastOptions={{ closeButtonAriaLabel: 'Dismiss', ...toastOptions }} {...props} /> ) diff --git a/src/lib/constants/braindump.ts b/src/lib/constants/braindump.ts index df1d932..31c8eb8 100644 --- a/src/lib/constants/braindump.ts +++ b/src/lib/constants/braindump.ts @@ -223,3 +223,39 @@ export const DEFAULT_BRAINDUMP_CLEAR_DELAY_MS = 500 * const instant: BrainDumpClearDelayMs = 0 */ export type BrainDumpClearDelayMs = number + +/* -------------------------------------------------------------------------- */ +/* BrainDump completion-toast display duration (#109) */ +/* -------------------------------------------------------------------------- */ + +/** + * Default completion-toast display duration — aliases {@link BRAINDUMP_TOAST_UNDO_MS} + * (5000 ms) so a fresh install keeps today's Undo-window behaviour exactly. #109 + * lifts this once-fixed window into a user pref; the #108 clear-delay ceiling now + * tracks it at the consumption site via `min(clearDelay, toastDuration)` rather + * than the old fixed constant, so a line can still never outlast its own Undo. + */ +export const DEFAULT_BRAINDUMP_TOAST_DURATION_MS = BRAINDUMP_TOAST_UNDO_MS + +/** + * Shortest selectable toast duration — 2000 ms. A 1 s toast with an Undo CTA reads + * anxious, not calm; 2 s is the regret-safe floor to read the line and still reach + * Undo (DESIGN.md "quiet companion, not coach"). + */ +export const BRAINDUMP_TOAST_DURATION_MIN_MS = 2000 + +/** Longest selectable toast duration — 10 s, the outer edge of "let it linger". */ +export const BRAINDUMP_TOAST_DURATION_MAX_MS = 10000 + +/** Toast-duration slider granularity — 500 ms steps read cleanly without jitter. */ +export const BRAINDUMP_TOAST_DURATION_STEP_MS = 500 + +/** + * BrainDump completion-toast display duration in ms, within + * [{@link BRAINDUMP_TOAST_DURATION_MIN_MS}, {@link BRAINDUMP_TOAST_DURATION_MAX_MS}]. + * Type alias documents intent without changing the runtime shape. + * + * @example + * const duration: BrainDumpToastDurationMs = 5000 + */ +export type BrainDumpToastDurationMs = number diff --git a/src/lib/preferences-sync-channel.test.ts b/src/lib/preferences-sync-channel.test.ts index e837a71..0ace986 100644 --- a/src/lib/preferences-sync-channel.test.ts +++ b/src/lib/preferences-sync-channel.test.ts @@ -15,6 +15,7 @@ import preferencesReducer, { setBraindumpFontFamily, setBraindumpFontSize, setBraindumpTextColor, + setBraindumpToastDurationMs, setSoundMoment, setSoundTimbre, setSoundVolume, @@ -209,6 +210,20 @@ describe('preferences cross-window sync', () => { expect(windowB.getState().preferences.braindumpClearDelayMs).toBe(1500) }) + it('propagates a BrainDump toast-duration change to another window', () => { + // Arrange + const windowA = makeWindowStore() + const windowB = makeWindowStore() + + // Act — move the completion-toast display time off the default 5000 ms in + // window A. + windowA.dispatch(setBraindumpToastDurationMs(8000)) + + // Assert — window B reflects the new duration without a reload (the action is + // in the broadcast allowlist; a NEW set* action would stay silent until added). + expect(windowB.getState().preferences.braindumpToastDurationMs).toBe(8000) + }) + it('clamps an out-of-range inbound volume when applying a raw broadcast', () => { // Arrange — a window plus a raw sender on the same wire protocol. const windowB = makeWindowStore() diff --git a/src/lib/preferences-sync-channel.ts b/src/lib/preferences-sync-channel.ts index 32f8731..03e4515 100644 --- a/src/lib/preferences-sync-channel.ts +++ b/src/lib/preferences-sync-channel.ts @@ -9,6 +9,7 @@ import { setBraindumpFontFamily, setBraindumpFontSize, setBraindumpTextColor, + setBraindumpToastDurationMs, setCompletionSound, setRetainCompletedInList, setSoundMoment, @@ -50,6 +51,7 @@ const BROADCASTABLE_ACTION_TYPES = new Set<string>([ setBraindumpTextColor.type, setBraindumpClearOnComplete.type, setBraindumpClearDelayMs.type, + setBraindumpToastDurationMs.type, ]) /** diff --git a/src/lib/redux/slices/preferencesSlice.test.ts b/src/lib/redux/slices/preferencesSlice.test.ts index fe5db18..c30366b 100644 --- a/src/lib/redux/slices/preferencesSlice.test.ts +++ b/src/lib/redux/slices/preferencesSlice.test.ts @@ -13,6 +13,7 @@ import reducer, { selectBraindumpFontFamily, selectBraindumpFontSize, selectBraindumpTextColor, + selectBraindumpToastDurationMs, selectCompletionSound, selectPreferences, selectRetainCompletedInList, @@ -24,6 +25,7 @@ import reducer, { setBraindumpFontFamily, setBraindumpFontSize, setBraindumpTextColor, + setBraindumpToastDurationMs, setCompletionSound, setRetainCompletedInList, setSoundMoment, @@ -55,6 +57,7 @@ describe('preferencesSlice', () => { braindumpTextColor: 'var(--foreground)', braindumpClearOnComplete: false, braindumpClearDelayMs: 500, + braindumpToastDurationMs: 5000, }) }) @@ -178,6 +181,7 @@ describe('preferencesSlice', () => { braindumpTextColor: 'var(--primary)', braindumpClearOnComplete: true, braindumpClearDelayMs: 1200, + braindumpToastDurationMs: 8000, } // Act @@ -200,6 +204,7 @@ describe('preferencesSlice', () => { braindumpTextColor: '#abcdef', braindumpClearOnComplete: true, braindumpClearDelayMs: 2000, + braindumpToastDurationMs: 7000, } // Act @@ -217,6 +222,7 @@ describe('preferencesSlice', () => { braindumpTextColor: 'var(--foreground)', braindumpClearOnComplete: false, braindumpClearDelayMs: 500, + braindumpToastDurationMs: 5000, }) }) @@ -282,6 +288,7 @@ describe('preferencesSlice', () => { braindumpTextColor: 'var(--foreground)', braindumpClearOnComplete: false, braindumpClearDelayMs: 500, + braindumpToastDurationMs: 5000, }) }) @@ -405,4 +412,41 @@ describe('preferencesSlice', () => { // Act / Assert — the selector coalesces to the 500 ms default, never undefined. expect(selectBraindumpClearDelayMs(legacyState)).toBe(500) }) + + it('sets the BrainDump toast duration when setBraindumpToastDurationMs is dispatched', () => { + // Act + const next = reducer(initialState, setBraindumpToastDurationMs(6000)) + + // Assert + expect(next.braindumpToastDurationMs).toBe(6000) + }) + + it('clamps an out-of-range BrainDump toast duration into the bounds [2000,10000]', () => { + // Act — above the 10 s ceiling clamps down, below the 2 s floor clamps up, + // in-range passes through. + const tooLong = reducer(initialState, setBraindumpToastDurationMs(99000)) + const tooShort = reducer(initialState, setBraindumpToastDurationMs(500)) + const inRange = reducer(initialState, setBraindumpToastDurationMs(6000)) + + // Assert + expect(tooLong.braindumpToastDurationMs).toBe(10000) + expect(tooShort.braindumpToastDurationMs).toBe(2000) + expect(inRange.braindumpToastDurationMs).toBe(6000) + }) + + it('guards a NaN BrainDump toast duration to the default instead of poisoning the slider', () => { + // Act — a non-finite value (e.g. a stray empty slider event) must not stick. + const next = reducer(initialState, setBraindumpToastDurationMs(Number.NaN)) + + // Assert + expect(next.braindumpToastDurationMs).toBe(5000) + }) + + it('falls back to the default toast duration for a slice that predates the field', () => { + // Arrange — a persisted slice from before the toast duration existed. + const legacyState = stateWith({ completionSound: false }) + + // Act / Assert — the selector coalesces to the 5000 ms default, never undefined. + expect(selectBraindumpToastDurationMs(legacyState)).toBe(5000) + }) }) diff --git a/src/lib/redux/slices/preferencesSlice.ts b/src/lib/redux/slices/preferencesSlice.ts index f8bbecf..1656296 100644 --- a/src/lib/redux/slices/preferencesSlice.ts +++ b/src/lib/redux/slices/preferencesSlice.ts @@ -30,6 +30,8 @@ import { BRAINDUMP_FONT_SIZE_MAX_PX, BRAINDUMP_FONT_SIZE_MIN_PX, BRAINDUMP_TEXT_COLOR_PATTERN, + BRAINDUMP_TOAST_DURATION_MAX_MS, + BRAINDUMP_TOAST_DURATION_MIN_MS, type BrainDumpFontFamilyId, } from '@/lib/constants/braindump' import { DEFAULT_PREFERENCES } from '@/lib/constants/preferences' @@ -224,6 +226,24 @@ export const preferencesSlice = createSlice({ : DEFAULT_PREFERENCES.braindumpClearDelayMs }, + /** + * Sets the BrainDump completion-toast display duration (ms), clamped to the + * slider range so a stray programmatic value can't exceed it; guards + * NaN/±Infinity to the default (mirrors setBraindumpClearDelayMs). Governs how + * long the completion toast (with its Undo + close ✕) stays before auto-close. + * @param state - Current state + * @param action - Payload containing the new toast duration in ms. + */ + setBraindumpToastDurationMs: (state, action: PayloadAction<number>) => { + const requestedDuration = action.payload + state.braindumpToastDurationMs = Number.isFinite(requestedDuration) + ? Math.min( + BRAINDUMP_TOAST_DURATION_MAX_MS, + Math.max(BRAINDUMP_TOAST_DURATION_MIN_MS, requestedDuration), + ) + : DEFAULT_PREFERENCES.braindumpToastDurationMs + }, + /** * Replaces the whole preferences state. Used by the cross-window sync to * apply preferences received from another window without re-broadcasting. @@ -257,6 +277,7 @@ export const { setBraindumpTextColor, setBraindumpClearOnComplete, setBraindumpClearDelayMs, + setBraindumpToastDurationMs, hydratePreferences, resetPreferences, } = preferencesSlice.actions @@ -376,6 +397,15 @@ export const selectBraindumpClearDelayMs = (state: RootState): number => state.preferences.braindumpClearDelayMs ?? DEFAULT_PREFERENCES.braindumpClearDelayMs +/** + * Selects the BrainDump completion-toast display duration (ms). + * @param state - Root state + * @returns The toast duration in ms (default 5000) + */ +export const selectBraindumpToastDurationMs = (state: RootState): number => + state.preferences.braindumpToastDurationMs ?? + DEFAULT_PREFERENCES.braindumpToastDurationMs + /** * Selects the full preferences state (every field coalesced/migrated to its * effective value) — the snapshot the cross-window sync broadcasts. @@ -397,6 +427,7 @@ export const selectPreferences = (state: RootState): PreferencesState => ({ braindumpTextColor: selectBraindumpTextColor(state), braindumpClearOnComplete: selectBraindumpClearOnComplete(state), braindumpClearDelayMs: selectBraindumpClearDelayMs(state), + braindumpToastDurationMs: selectBraindumpToastDurationMs(state), }) export default preferencesSlice.reducer diff --git a/src/lib/schemas/preferences.test.ts b/src/lib/schemas/preferences.test.ts index 674e5d5..3432515 100644 --- a/src/lib/schemas/preferences.test.ts +++ b/src/lib/schemas/preferences.test.ts @@ -21,6 +21,7 @@ describe('PreferencesStateSchema', () => { braindumpTextColor: 'var(--foreground)', braindumpClearOnComplete: false, braindumpClearDelayMs: 500, + braindumpToastDurationMs: 5000, }) }) @@ -43,6 +44,7 @@ describe('PreferencesStateSchema', () => { braindumpTextColor: 'var(--foreground)', braindumpClearOnComplete: false, braindumpClearDelayMs: 500, + braindumpToastDurationMs: 5000, }) }) @@ -90,6 +92,38 @@ describe('PreferencesStateSchema', () => { expect(result.braindumpClearDelayMs).toBe(500) }) + it('defaults the BrainDump completion-toast duration to 5000 ms when absent', () => { + // Act + const result = PreferencesStateSchema.parse({}) + + // Assert — the same 5 s window the toast used before it was configurable. + expect(result.braindumpToastDurationMs).toBe(5000) + }) + + it('clamps an out-of-range BrainDump toast duration into the bounds [2000,10000]', () => { + // Act — above the 10 s ceiling clamps down, below the 2 s floor clamps up. + const tooLong = PreferencesStateSchema.parse({ + braindumpToastDurationMs: 99000, + }) + const tooShort = PreferencesStateSchema.parse({ + braindumpToastDurationMs: 500, + }) + + // Assert + expect(tooLong.braindumpToastDurationMs).toBe(10000) + expect(tooShort.braindumpToastDurationMs).toBe(2000) + }) + + it('self-heals a non-finite BrainDump toast duration to the default (no poisoned hydrate)', () => { + // Act — a NaN that slipped into a persisted/synced blob must not survive. + const result = PreferencesStateSchema.parse({ + braindumpToastDurationMs: Number.NaN, + }) + + // Assert + expect(result.braindumpToastDurationMs).toBe(5000) + }) + it('clamps an out-of-range master volume number into [0,1]', () => { // Act const tooLoud = PreferencesStateSchema.parse({ soundVolume: 50 }) diff --git a/src/lib/schemas/preferences.ts b/src/lib/schemas/preferences.ts index 278e040..52f652e 100644 --- a/src/lib/schemas/preferences.ts +++ b/src/lib/schemas/preferences.ts @@ -18,10 +18,13 @@ import { BRAINDUMP_FONT_SIZE_MAX_PX, BRAINDUMP_FONT_SIZE_MIN_PX, BRAINDUMP_TEXT_COLOR_PATTERN, + BRAINDUMP_TOAST_DURATION_MAX_MS, + BRAINDUMP_TOAST_DURATION_MIN_MS, DEFAULT_BRAINDUMP_CLEAR_DELAY_MS, DEFAULT_BRAINDUMP_FONT_FAMILY, DEFAULT_BRAINDUMP_FONT_SIZE_PX, DEFAULT_BRAINDUMP_TEXT_COLOR, + DEFAULT_BRAINDUMP_TOAST_DURATION_MS, } from '@/lib/constants/braindump' import { DEFAULT_SOUND_VOLUME, @@ -115,6 +118,20 @@ export const PreferencesStateSchema = z.object({ ), ) .catch(DEFAULT_BRAINDUMP_CLEAR_DELAY_MS), + /** BrainDump completion-toast display duration (ms) before it auto-dismisses. + * A finite number is clamped to the slider range; a non-finite or non-number + * (corrupt blob, bad sync) self-heals to the default via `.catch` — mirroring + * `braindumpClearDelayMs`. The toast also gains a close (✕) button (#109). */ + braindumpToastDurationMs: z + .number() + .finite() + .transform((value) => + Math.min( + BRAINDUMP_TOAST_DURATION_MAX_MS, + Math.max(BRAINDUMP_TOAST_DURATION_MIN_MS, value), + ), + ) + .catch(DEFAULT_BRAINDUMP_TOAST_DURATION_MS), }) /** The validated core user-preferences shape (inferred from the schema SSoT). */ From f9baa10d3c0c44a1d8f4b65c1634f3f045753040 Mon Sep 17 00:00:00 2001 From: Ryota Murakami <dojce1048@gmail.com> Date: Thu, 25 Jun 2026 08:17:44 +0900 Subject: [PATCH 2/4] =?UTF-8?q?style(design):=20FINDING-001=20=E2=80=94=20?= =?UTF-8?q?regret-safe,=20quiet-companion=20Undo-window=20toast=20copy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-model design-review on #109: the dynamic Undo-window copy read as a system instruction ("Tap Undo within N s to revert.") and rounded the seconds with Math.round, which over-promises the Undo window at half-step slider settings (2500ms -> "3 s" while the toast closes at 2.5s) — the regret-UNSAFE direction, against the feature's own regret-safe framing. - Reword to the DESIGN.md quiet-companion voice: "Undo stays here for N s if you need it." (offer, not command). - Math.round -> Math.floor so the stated window is never longer than the real one (under-promise, never over-promise). - Update the one DAMP assertion that pins the exact copy (8s case). --- src/components/braindump/BrainDumpEditor.test.tsx | 6 ++++-- src/components/braindump/BrainDumpEditor.tsx | 9 ++++++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/components/braindump/BrainDumpEditor.test.tsx b/src/components/braindump/BrainDumpEditor.test.tsx index cdeac4e..61cb96c 100644 --- a/src/components/braindump/BrainDumpEditor.test.tsx +++ b/src/components/braindump/BrainDumpEditor.test.tsx @@ -1191,7 +1191,7 @@ describe('BrainDumpEditor completion toast — close button + display duration ( }) it('phrases the Undo-window copy for the configured display duration', async () => { - // Arrange — an 8 s duration must read "within 8 s", not a hardcoded "5 s". + // Arrange — an 8 s duration must read "8 s", not a hardcoded "5 s". installBrainDumpAPI({ getVisibleOnAllWorkspaces: vi.fn().mockResolvedValue(false), setVisibleOnAllWorkspaces: vi.fn().mockResolvedValue(true), @@ -1207,7 +1207,9 @@ describe('BrainDumpEditor completion toast — close button + display duration ( expect(toast.success).toHaveBeenCalled() }) const toastOptions = vi.mocked(toast.success).mock.calls.at(-1)?.[1] - expect(toastOptions?.description).toBe('Tap Undo within 8 s to revert.') + expect(toastOptions?.description).toBe( + 'Undo stays here for 8 s if you need it.', + ) }) it('keeps the close button and configured duration on the clear-on-complete toast', async () => { diff --git a/src/components/braindump/BrainDumpEditor.tsx b/src/components/braindump/BrainDumpEditor.tsx index 2dd6982..ae68032 100644 --- a/src/components/braindump/BrainDumpEditor.tsx +++ b/src/components/braindump/BrainDumpEditor.tsx @@ -216,9 +216,12 @@ function showCompletionToast({ onDismiss?: () => void }): string | number { const toastId = toast.success(`Completed: ${title}`, { - // Dynamic Undo-window copy — once the duration is user-configurable a fixed - // "5 s" would be wrong at every non-default setting (#109). - description: `Tap Undo within ${Math.round(durationMs / 1000)} s to revert.`, + // Dynamic Undo-window copy in the quiet-companion voice — once the duration + // is user-configurable a fixed "5 s" would be wrong at every non-default + // setting (#109). floor (not round) keeps the promise regret-safe: at a + // half-step like 2500ms it reads "2 s" (under), never "3 s" (over), so the + // Undo never expires earlier than the copy says. + description: `Undo stays here for ${Math.floor(durationMs / 1000)} s if you need it.`, duration: durationMs, closeButton: true, action: { From 23f65ee03b7267e27097afc9a53cbb0fe56b1423 Mon Sep 17 00:00:00 2001 From: Ryota Murakami <dojce1048@gmail.com> Date: Thu, 25 Jun 2026 08:18:14 +0900 Subject: [PATCH 3/4] =?UTF-8?q?style(design):=20FINDING-002=20=E2=80=94=20?= =?UTF-8?q?soften=20the=20toast-duration=20helper=20to=20quiet-companion?= =?UTF-8?q?=20voice?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-model design-review on #109: the appearance helper ended on an imperative UI command ("Press the ✕ to dismiss it sooner."), which slips out of the DESIGN.md quiet-companion voice the rest of the BrainDump copy holds (the #108 sibling helper reads as a gentle soft-exit). Reword to a calm, declarative line: "The ✕ lets it fade early." — keeps the close affordance explicit without barking an instruction. --- src/components/electron/BrainDumpAppearance.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/electron/BrainDumpAppearance.tsx b/src/components/electron/BrainDumpAppearance.tsx index 5d10a1b..55ca08a 100644 --- a/src/components/electron/BrainDumpAppearance.tsx +++ b/src/components/electron/BrainDumpAppearance.tsx @@ -237,7 +237,7 @@ export const BrainDumpAppearance = </div> <p className="text-xs text-muted-foreground"> How long the “Completed” confirmation — with its Undo — stays before - it fades. Press the ✕ to dismiss it sooner. + it fades. The ✕ lets it fade early. </p> </div> From fc556d98c934846cb0692f2f539ceb40375cb7d5 Mon Sep 17 00:00:00 2001 From: Ryota Murakami <dojce1048@gmail.com> Date: Thu, 25 Jun 2026 08:53:32 +0900 Subject: [PATCH 4/4] test: cover regret-safe Math.floor Undo-window copy at half-step duration (#109) The existing copy spec used 8000ms where floor==round, so FINDING-001's regret-safe floor (never over-promise the Undo window) was untested at the discriminating input. The slider's 500ms step makes 2500ms reachable; this asserts it reads "2 s" (floor), never "3 s" (round). --- .../braindump/BrainDumpEditor.test.tsx | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/components/braindump/BrainDumpEditor.test.tsx b/src/components/braindump/BrainDumpEditor.test.tsx index 61cb96c..84eef2a 100644 --- a/src/components/braindump/BrainDumpEditor.test.tsx +++ b/src/components/braindump/BrainDumpEditor.test.tsx @@ -1212,6 +1212,30 @@ describe('BrainDumpEditor completion toast — close button + display duration ( ) }) + it('floors the Undo-window copy at a half-step duration so it never over-promises the Undo time', async () => { + // Arrange — a half-step 2500 ms duration (reachable via the slider's 500 ms + // step) must read "2 s" (floor), never "3 s" (round): the copy must never + // claim more Undo time than actually remains (FINDING-001 regret-safe floor). + installBrainDumpAPI({ + getVisibleOnAllWorkspaces: vi.fn().mockResolvedValue(false), + setVisibleOnAllWorkspaces: vi.fn().mockResolvedValue(true), + }) + renderEditor({ braindumpToastDurationMs: 2500 }) + const noteField = await screen.findByRole<HTMLTextAreaElement>('textbox') + + // Act + fireCompleteCommandOnFirstLine(noteField, 'buy milk') + + // Assert — 2500 ms floors to "2 s", never the rounded-up "3 s". + await waitFor(() => { + expect(toast.success).toHaveBeenCalled() + }) + const toastOptions = vi.mocked(toast.success).mock.calls.at(-1)?.[1] + expect(toastOptions?.description).toBe( + 'Undo stays here for 2 s if you need it.', + ) + }) + it('keeps the close button and configured duration on the clear-on-complete toast', async () => { // Arrange — clear-on-complete ON with instant clear and a 6 s duration: the // SAME helper must wire the ✕ + duration on this second completion path too.