From 36cdd4fbca354ae43c26e621eec580344973b8c2 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Thu, 16 Jul 2026 12:03:17 +0200 Subject: [PATCH] Editor: confirm snapshots with a toast; wire ui.toast for plugins (#130) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cmd/Ctrl+S used to snapshot silently. A new app-wide ToastProvider (components/Toast.tsx) owns a bottom-center stack — permanent polite live region, auto-dismiss after 2.5 s, click to dismiss early, error variant. Both snapshot paths (the keyboard chords in PageEditorPage and the save-version TopBar button) now confirm with the version name when there is one, and their failure alert becomes an error toast. The plugin host capability ui.toast (declared since #74, wired nowhere) connects to the same stack: PluginBlockScope carries the showToast handle, plugin-block passes it into the sandbox context. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Fb2VzvcoBPHkjh8bZ6PzQn --- apps/web/e2e/content.spec.ts | 10 ++- apps/web/src/components/Toast.tsx | 66 ++++++++++++++++++++ apps/web/src/editor/nodes/plugin-block.tsx | 7 ++- apps/web/src/editor/plugin-block-context.tsx | 2 + apps/web/src/main.tsx | 9 ++- apps/web/src/pages/PageActions.tsx | 8 ++- apps/web/src/pages/PageEditorPage.tsx | 13 +++- apps/web/src/styles/base.css | 45 +++++++++++++ docs/de/manual/user-guide.md | 4 +- docs/manual/user-guide.md | 8 ++- packages/shared/i18n/de/editor.json | 2 + packages/shared/i18n/en/editor.json | 2 + 12 files changed, 161 insertions(+), 15 deletions(-) create mode 100644 apps/web/src/components/Toast.tsx diff --git a/apps/web/e2e/content.spec.ts b/apps/web/e2e/content.spec.ts index f24586d..993712f 100644 --- a/apps/web/e2e/content.spec.ts +++ b/apps/web/e2e/content.spec.ts @@ -123,8 +123,12 @@ test('keyboard shortcuts: "e" enters edit mode and the platform chord snapshots await page.keyboard.press('e'); await expect(page.locator('.ProseMirror')).toHaveAttribute('contenteditable', 'true'); - // Ctrl/Cmd+S snapshots an unnamed manual version and stays in edit mode. + // Ctrl/Cmd+S snapshots an unnamed manual version, confirms it with a + // toast (#130), and stays in edit mode. await page.keyboard.press('ControlOrMeta+s'); + // Assert the toast FIRST — it auto-dismisses after ~2.5s, so it must be + // caught before the version poll spends its budget. + await expect(page.locator('.toast')).toHaveText(/Version (gespeichert|saved)/); await expect .poll(async () => { const res = await context.request.get(`/api/v1/pages/${id}/versions`); @@ -134,10 +138,12 @@ test('keyboard shortcuts: "e" enters edit mode and the platform chord snapshots .toBe(1); await expect(page.locator('.ProseMirror')).toHaveAttribute('contenteditable', 'true'); - // Ctrl/Cmd+Shift+S asks for a name and returns to reading mode. + // Ctrl/Cmd+Shift+S asks for a name and returns to reading mode; the toast + // names the saved version. page.on('dialog', (dialog) => void dialog.accept('Meilenstein')); await page.keyboard.press('ControlOrMeta+Shift+s'); await expect(page.locator('.ProseMirror')).toHaveAttribute('contenteditable', 'false'); + await expect(page.locator('.toast').last()).toContainText('Meilenstein'); const res = await context.request.get(`/api/v1/pages/${id}/versions`); const versions = (await res.json()) as { label: string | null }[]; expect(versions.some((v) => v.label === 'Meilenstein')).toBe(true); diff --git a/apps/web/src/components/Toast.tsx b/apps/web/src/components/Toast.tsx new file mode 100644 index 0000000..022447d --- /dev/null +++ b/apps/web/src/components/Toast.tsx @@ -0,0 +1,66 @@ +import { createContext, useCallback, useContext, useRef, useState } from 'react'; + +/** + * App-wide toast notifications (issue #130). One provider near the root owns + * the stack; `useToast()` hands out a fire-and-forget `showToast`. Toasts are + * announced politely (`role="status"` on the stack container), stack bottom + * center above the footer, and dismiss themselves — clicking one dismisses + * it early. The plugin host's `ui.toast` capability feeds the same stack. + */ + +export type ToastVariant = 'success' | 'error'; + +export type ShowToast = (message: string, variant?: ToastVariant) => void; + +interface ToastItem { + id: number; + message: string; + variant: ToastVariant; +} + +const TOAST_DURATION_MS = 2500; + +const ToastContext = createContext(() => {}); + +export function useToast(): ShowToast { + return useContext(ToastContext); +} + +export function ToastProvider({ children }: { children: React.ReactNode }): React.JSX.Element { + const [toasts, setToasts] = useState([]); + const nextId = useRef(0); + + const dismiss = useCallback((id: number): void => { + setToasts((prev) => prev.filter((toast) => toast.id !== id)); + }, []); + + const showToast = useCallback( + (message, variant = 'success') => { + nextId.current += 1; + const id = nextId.current; + setToasts((prev) => [...prev, { id, message, variant }]); + window.setTimeout(() => dismiss(id), TOAST_DURATION_MS); + }, + [dismiss], + ); + + return ( + + {children} + {/* The live region exists permanently, so screen readers pick up + toasts added to it; individual toasts must not carry their own + role="status" (a region appearing WITH its content isn't read). */} +
+ {toasts.map((toast) => ( +
dismiss(toast.id)} + > + {toast.message} +
+ ))} +
+
+ ); +} diff --git a/apps/web/src/editor/nodes/plugin-block.tsx b/apps/web/src/editor/nodes/plugin-block.tsx index 77222a6..124f6f9 100644 --- a/apps/web/src/editor/nodes/plugin-block.tsx +++ b/apps/web/src/editor/nodes/plugin-block.tsx @@ -82,7 +82,12 @@ function ActivePluginBlock({ extensionPointId: blockType, locale, container, - context: { pageId: scope.pageId, pondId: scope.pondId, openPage: scope.openPage }, + context: { + pageId: scope.pageId, + pondId: scope.pondId, + openPage: scope.openPage, + toast: scope.toast, + }, capabilities: { getData: () => dataRef.current, setData: (params) => { diff --git a/apps/web/src/editor/plugin-block-context.tsx b/apps/web/src/editor/plugin-block-context.tsx index 1cfc42f..6e8f2c2 100644 --- a/apps/web/src/editor/plugin-block-context.tsx +++ b/apps/web/src/editor/plugin-block-context.tsx @@ -12,6 +12,8 @@ export interface PluginBlockScope { pondId?: string; /** Navigate to a page (backs the `ui.openPage` capability). */ openPage?: (pageId: string) => void; + /** Show a message in the app's toast stack (`ui.toast`, #130). */ + toast?: (message: string) => void; /** Scroll the content to a heading by outline id (`ui.scrollToHeading`, #77). */ scrollToHeading?: (headingId: string) => void; } diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index ce2fada..7d4c714 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -5,6 +5,7 @@ import { BrowserRouter } from 'react-router-dom'; import { App } from './App'; import { AuthProvider } from './auth/auth-context'; +import { ToastProvider } from './components/Toast'; import './i18n'; import { ApiError } from './lib/api'; import './styles/tokens.css'; @@ -32,9 +33,11 @@ createRoot(container).render( - - - + + + + + , diff --git a/apps/web/src/pages/PageActions.tsx b/apps/web/src/pages/PageActions.tsx index 61450bf..5606821 100644 --- a/apps/web/src/pages/PageActions.tsx +++ b/apps/web/src/pages/PageActions.tsx @@ -20,6 +20,7 @@ import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { IconButton } from '../components/IconButton'; +import { useToast } from '../components/Toast'; import { useDocumentExport } from '../export/use-document-export'; import { apiDelete, apiGet, apiGetText, apiPost } from '../lib/api'; import { useDismissable } from '../lib/use-dismissable'; @@ -128,6 +129,7 @@ export function PageActions(props: PageActionsProps): React.JSX.Element { function SaveVersionButton({ pageId }: { pageId: string }): React.JSX.Element { const { t } = useTranslation('editor'); const queryClient = useQueryClient(); + const showToast = useToast(); const [busy, setBusy] = useState(false); async function save(): Promise { @@ -135,10 +137,12 @@ function SaveVersionButton({ pageId }: { pageId: string }): React.JSX.Element { if (!label) return; setBusy(true); try { - await apiPost(`/pages/${pageId}/versions`, { label: label.slice(0, 100) }); + const shortLabel = label.slice(0, 100); + await apiPost(`/pages/${pageId}/versions`, { label: shortLabel }); await queryClient.invalidateQueries({ queryKey: ['versions', pageId] }); + showToast(t('history.savedToastNamed', { label: shortLabel })); } catch { - window.alert(t('history.saveFailed')); + showToast(t('history.saveFailed'), 'error'); } finally { setBusy(false); } diff --git a/apps/web/src/pages/PageEditorPage.tsx b/apps/web/src/pages/PageEditorPage.tsx index 56379bb..96d9d77 100644 --- a/apps/web/src/pages/PageEditorPage.tsx +++ b/apps/web/src/pages/PageEditorPage.tsx @@ -14,6 +14,7 @@ import { useAuth } from '../auth/auth-context'; import { CommentsPanel } from '../comments/CommentsPanel'; import { unreadCount, useComments } from '../comments/use-comments'; import { FormError } from '../components/forms'; +import { useToast } from '../components/Toast'; import { AccessRevokedDialog } from '../editor/AccessRevokedDialog'; import { AttachmentsPanel } from '../files/AttachmentsPanel'; import { HistoryPanel } from '../editor/HistoryPanel'; @@ -146,6 +147,7 @@ function PageEditor({ const { user } = useAuth(); const navigate = useNavigate(); const { presenceElement, statusElement } = usePageActionsSlot(); + const showToast = useToast(); // Created and destroyed within the same effect (not `useMemo` + a separate // cleanup effect): React StrictMode's dev-only mount→cleanup→remount would @@ -234,6 +236,8 @@ function PageEditor({ const target = (pondPagesData ?? []).find((p) => p.id === pageId); if (target) navigate(`/p/${pondSlug}/${target.slug}`); }, + // Plugins share the app's toast stack (`ui.toast`, #130). + toast: showToast, // Outline ids are derived from the doc (extractOutline), never stamped // into the DOM — so resolve the id to its heading *position* and scroll // the matching rendered heading (#77). @@ -245,7 +249,7 @@ function PageEditor({ headings[index]?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, }), - [page.id, page.pondId, pondPagesData, pondSlug, navigate, editor], + [page.id, page.pondId, pondPagesData, pondSlug, navigate, editor, showToast], ); const blockInserts = useMemo(() => pluginBlockOptions(pondPlugins.data), [pondPlugins.data]); @@ -342,6 +346,7 @@ export function PageEditorPage(): React.JSX.Element { const [showPageTools, setShowPageTools] = useState(false); const actionsSlot = usePageActionsSlot(); const queryClient = useQueryClient(); + const showToast = useToast(); useForceSidebarHidden(mode === 'edit'); @@ -402,9 +407,11 @@ export function PageEditorPage(): React.JSX.Element { try { await apiPost(`/pages/${pageId}/versions`, label ? { label } : {}); await queryClient.invalidateQueries({ queryKey: ['versions', pageId] }); + // The snapshot used to happen silently (#130) — confirm it. + showToast(label ? t('history.savedToastNamed', { label }) : t('history.savedToast')); return true; } catch { - window.alert(t('history.saveFailed')); + showToast(t('history.saveFailed'), 'error'); return false; } } @@ -439,7 +446,7 @@ export function PageEditorPage(): React.JSX.Element { }; window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); - }, [pageId, user, mode, queryClient, t]); + }, [pageId, user, mode, queryClient, t, showToast]); // TopBar action data (issue #101): the comments badge and the plugin // page-tools visibility live next to the icons, not inside the editor. diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index 201597d..de97e92 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -3516,3 +3516,48 @@ button { font-size: 0.75rem; font-weight: 400; } + +/* Toast notifications (issue #130): bottom-center stack above the footer, + over everything including modal overlays (z 1100). */ +.toast-stack { + position: fixed; + bottom: var(--space-8); + left: 50%; + transform: translateX(-50%); + z-index: 1200; + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-2); + pointer-events: none; +} + +.toast { + pointer-events: auto; + cursor: pointer; + max-width: 28rem; + padding: var(--space-2) var(--space-4); + border-radius: var(--radius); + background: var(--color-text); + color: var(--color-bg); + font-size: 0.9rem; + box-shadow: 0 4px 12px rgb(0 0 0 / 20%); + animation: toast-in 0.15s ease-out; +} + +.toast--error { + background: var(--color-danger); + color: var(--color-accent-contrast); +} + +@keyframes toast-in { + from { + opacity: 0; + transform: translateY(0.5rem); + } + + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/docs/de/manual/user-guide.md b/docs/de/manual/user-guide.md index 483dda0..e687bf8 100644 --- a/docs/de/manual/user-guide.md +++ b/docs/de/manual/user-guide.md @@ -90,7 +90,9 @@ Die Werkzeugleiste bleibt beim Scrollen sichtbar. den Text ein. Andere Dateitypen (PDFs usw., soweit die Instanz sie erlaubt) hängst du über das **Büroklammer-Symbol** an die Seite. - **Benannte Versionen:** Das **Speichern-Symbol** im Bearbeitungsmodus - legt einen benannten Schnappschuss an („vor dem großen Umbau"). Das + legt einen benannten Schnappschuss an („vor dem großen Umbau"); + `Strg/Cmd+S` speichert einen unbenannten, `Strg/Cmd+Shift+S` fragt + nach einem Namen — ein kurzer Toast bestätigt jedes Speichern. Das **Verlauf-Symbol** listet alle Versionen — automatische und benannte — mit ihren Mitwirkenden; du kannst jede Version ansehen und wiederherstellen. Wiederherstellen löscht nie den Verlauf. diff --git a/docs/manual/user-guide.md b/docs/manual/user-guide.md index 398050d..ac37ebb 100644 --- a/docs/manual/user-guide.md +++ b/docs/manual/user-guide.md @@ -78,9 +78,11 @@ toolbar stays visible while you scroll. text. Other file types (PDFs etc., as allowed by the instance) attach to the page via the **paperclip icon**. - **Named versions:** the **save icon** in edit mode stores a named - snapshot ("before the big rewrite"). The **history icon** lists all - versions — automatic and named — with their contributors; you can view - any version and restore it. Restoring never deletes history. + snapshot ("before the big rewrite"); `Ctrl/Cmd+S` stores an unnamed + one, `Ctrl/Cmd+Shift+S` asks for a name — a short toast confirms every + save. The **history icon** lists all versions — automatic and named — + with their contributors; you can view any version and restore it. + Restoring never deletes history. ## The top-bar page actions diff --git a/packages/shared/i18n/de/editor.json b/packages/shared/i18n/de/editor.json index 343bfd6..955dc6b 100644 --- a/packages/shared/i18n/de/editor.json +++ b/packages/shared/i18n/de/editor.json @@ -117,6 +117,8 @@ "saveVersion": "Version speichern", "savePrompt": "Name der Version:", "saveFailed": "Version konnte nicht gespeichert werden.", + "savedToast": "Version gespeichert", + "savedToastNamed": "Version „{{label}}“ gespeichert", "showAllContributors": "Alle Mitwirkenden anzeigen" }, "page": { diff --git a/packages/shared/i18n/en/editor.json b/packages/shared/i18n/en/editor.json index 06b6c43..661487e 100644 --- a/packages/shared/i18n/en/editor.json +++ b/packages/shared/i18n/en/editor.json @@ -117,6 +117,8 @@ "saveVersion": "Save version", "savePrompt": "Version name:", "saveFailed": "The version could not be saved.", + "savedToast": "Version saved", + "savedToastNamed": "Version “{{label}}” saved", "showAllContributors": "Show all contributors" }, "page": {