diff --git a/frontend/src/i18n/locales/en/common.json b/frontend/src/i18n/locales/en/common.json
index d144b4d..64b7433 100644
--- a/frontend/src/i18n/locales/en/common.json
+++ b/frontend/src/i18n/locales/en/common.json
@@ -93,6 +93,18 @@
"resultCount_one": "{{count}} result",
"resultCount_other": "{{count}} results"
},
+ "citationChip": {
+ "openTitle": "{{label}} — open preview",
+ "popoverAria": "Citation preview for {{label}}",
+ "articleLabel": "Article {{num}}",
+ "previewError": "Could not load preview.",
+ "openLaw": "Open law"
+ },
+ "emptyState": {
+ "template": "Template",
+ "ai": "AI assistant",
+ "citation": "Citation"
+ },
"templates": {
"dialogAria": "Document templates",
"title": "Templates",
@@ -107,6 +119,9 @@
"apply": "Apply",
"deleteAria": "Delete template {{name}}",
"delete": "Delete",
+ "deleteConfirmTitle": "Delete template",
+ "deleteConfirmMessage": "Delete the template \"{{name}}\"? This cannot be undone.",
+ "applyNewDocument": "Apply in new document",
"variable": "variable",
"variables": "variables",
"noVariables": "No variables"
diff --git a/frontend/src/i18n/locales/es/common.json b/frontend/src/i18n/locales/es/common.json
index c561bf4..6613f84 100644
--- a/frontend/src/i18n/locales/es/common.json
+++ b/frontend/src/i18n/locales/es/common.json
@@ -93,6 +93,18 @@
"resultCount_one": "{{count}} resultado",
"resultCount_other": "{{count}} resultados"
},
+ "citationChip": {
+ "openTitle": "{{label}} — abrir vista previa",
+ "popoverAria": "Vista previa de la cita {{label}}",
+ "articleLabel": "Artículo {{num}}",
+ "previewError": "No se pudo cargar la vista previa.",
+ "openLaw": "Abrir ley"
+ },
+ "emptyState": {
+ "template": "Plantilla",
+ "ai": "Asistente IA",
+ "citation": "Cita"
+ },
"templates": {
"dialogAria": "Plantillas de documento",
"title": "Plantillas",
@@ -107,6 +119,9 @@
"apply": "Aplicar",
"deleteAria": "Eliminar plantilla {{name}}",
"delete": "Eliminar",
+ "deleteConfirmTitle": "Eliminar plantilla",
+ "deleteConfirmMessage": "¿Eliminar la plantilla «{{name}}»? Esta acción no se puede deshacer.",
+ "applyNewDocument": "Aplicar en documento nuevo",
"variable": "variable",
"variables": "variables",
"noVariables": "Sin variables"
diff --git a/frontend/src/pages/EditorPage.test.tsx b/frontend/src/pages/EditorPage.test.tsx
index 8f13bbd..fb99b44 100644
--- a/frontend/src/pages/EditorPage.test.tsx
+++ b/frontend/src/pages/EditorPage.test.tsx
@@ -63,6 +63,8 @@ vi.mock('@tiptap/react', () => ({
onUpdateHandler = opts.onUpdate;
return mockEditor;
},
+ useEditorState: ({ selector }: { selector: (snap: { editor: { isEmpty: boolean } }) => unknown }) =>
+ selector({ editor: { isEmpty: true } }),
EditorContent: () =>
,
}));
@@ -279,6 +281,17 @@ describe('EditorPage autosave flush', () => {
expect(screen.getByText(/^Saved /)).toBeInTheDocument();
await i18n.changeLanguage('es');
});
+
+ it('uses measure token on the editor writing surface', () => {
+ renderEditor();
+ expect(screen.getByTestId('editor-surface')).toHaveClass('max-w-measure');
+ });
+
+ it('shows empty-state CTA tiles when the editor is empty', () => {
+ renderEditor();
+ expect(screen.getByTestId('editor-empty-state')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /plantilla/i })).toBeInTheDocument();
+ });
});
describe('EditorPage save indicator', () => {
diff --git a/frontend/src/pages/EditorPage.tsx b/frontend/src/pages/EditorPage.tsx
index 10d5117..77b40b4 100644
--- a/frontend/src/pages/EditorPage.tsx
+++ b/frontend/src/pages/EditorPage.tsx
@@ -33,7 +33,8 @@ import { useCallback, useEffect, useRef, useState, type MutableRefObject } from
import { createPortal } from 'react-dom';
import { useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
-import { useEditor, EditorContent, type Editor } from '@tiptap/react';
+import { useEditor, EditorContent, useEditorState, type Editor } from '@tiptap/react';
+import { LayoutTemplate, Scale, Sparkles } from 'lucide-react';
import StarterKit from '@tiptap/starter-kit';
import Placeholder from '@tiptap/extension-placeholder';
import { useEditorStore, makeDefaultDocument, isSentinelDocumentTitle } from '@/lib/editor-store';
@@ -156,6 +157,10 @@ function EditorDocumentSurface({ docId }: { docId: string }) {
// Comments side panel (#602): open state + the comment to autofocus on open.
const [commentsOpen, setCommentsOpen] = useState(false);
const [focusCommentId, setFocusCommentId] = useState(null);
+ const citationTriggerRef = useRef(null);
+ const templatesTriggerRef = useRef(null);
+ const aiPanelTriggerRef = useRef(null);
+ const commentsTriggerRef = useRef(null);
const addComment = useCommentStore((s) => s.addComment);
const commentBadgeCount = useCommentStore((s) => openCommentCount(s.comments, docId));
@@ -338,8 +343,34 @@ function EditorDocumentSurface({ docId }: { docId: string }) {
exportMarkdown(editor.getJSON(), title);
}, [editor, title]);
+ const closeCitationPicker = useCallback(() => {
+ setCitationPickerOpen(false);
+ citationTriggerRef.current?.focus();
+ }, []);
+
+ const closeTemplatesDialog = useCallback(() => {
+ setTemplatesOpen(false);
+ templatesTriggerRef.current?.focus();
+ }, []);
+
+ const closeAiPanel = useCallback(() => {
+ setAiPanelOpen(false);
+ aiPanelTriggerRef.current?.focus();
+ }, []);
+
+ const closeCommentsPanel = useCallback(() => {
+ setCommentsOpen(false);
+ commentsTriggerRef.current?.focus();
+ }, []);
+
+ const drawerPaddingClass = cn(
+ 'transition-[padding] duration-200',
+ aiPanelOpen && 'pr-[380px]',
+ !aiPanelOpen && commentsOpen && 'pr-[340px]',
+ );
+
return (
- <>
+
{persistError && (
)}
@@ -421,7 +456,7 @@ function EditorDocumentSurface({ docId }: { docId: string }) {
{editor &&
citationPickerOpen &&
createPortal(
-
setCitationPickerOpen(false)} />,
+ ,
document.body,
)}
@@ -429,7 +464,7 @@ function EditorDocumentSurface({ docId }: { docId: string }) {
{editor &&
templatesOpen &&
createPortal(
- setTemplatesOpen(false)} />,
+ ,
document.body,
)}
@@ -441,7 +476,7 @@ function EditorDocumentSurface({ docId }: { docId: string }) {
editor={editor}
docId={docId}
docTitle={title}
- onClose={() => setAiPanelOpen(false)}
+ onClose={closeAiPanel}
/>,
document.body,
)}
@@ -454,16 +489,17 @@ function EditorDocumentSurface({ docId }: { docId: string }) {
editor={editor}
docId={docId}
focusCommentId={focusCommentId}
- onClose={() => setCommentsOpen(false)}
+ onClose={closeCommentsPanel}
/>,
document.body,
)}
{/* TipTap content area */}
+ {editor && !isReadOnly && (
+ setTemplatesOpen(true)}
+ onOpenAiPanel={() => setAiPanelOpen(true)}
+ onOpenCitationPicker={() => setCitationPickerOpen(true)}
+ />
+ )}
- >
+
+ );
+}
+
+interface EditorEmptyStateTilesProps {
+ editor: Editor;
+ onOpenTemplates: () => void;
+ onOpenAiPanel: () => void;
+ onOpenCitationPicker: () => void;
+}
+
+/** CTA tiles shown over an empty editor surface (#75 S4.6). */
+function EditorEmptyStateTiles({
+ editor,
+ onOpenTemplates,
+ onOpenAiPanel,
+ onOpenCitationPicker,
+}: EditorEmptyStateTilesProps) {
+ const { t } = useTranslation();
+ const isEmpty = useEditorState({
+ editor,
+ selector: (snap) => snap.editor.isEmpty,
+ });
+
+ if (!isEmpty) return null;
+
+ return (
+
+
+
+
+
+
+
);
}
diff --git a/frontend/src/pages/editor/AiDraftPanel.tsx b/frontend/src/pages/editor/AiDraftPanel.tsx
index 43b1138..26840a6 100644
--- a/frontend/src/pages/editor/AiDraftPanel.tsx
+++ b/frontend/src/pages/editor/AiDraftPanel.tsx
@@ -16,7 +16,7 @@
* - Markdown insertion → `markdownDraftToBlocks` in `./draft-insert-utils`.
* - Model selection → mirrors ChatPage (`useModels` + `useUi.defaultModel`).
*/
-import { useCallback, useEffect, useMemo } from 'react';
+import { useCallback, useEffect, useMemo, useRef } from 'react';
import type { Editor } from '@tiptap/react';
import { useEditorState } from '@tiptap/react';
import { useNavigate } from 'react-router-dom';
@@ -30,6 +30,7 @@ import { isSentinelDocumentTitle } from '@/lib/editor-store';
import { EMPTY_DOC_AI_DRAFT, useAiDraftStore, type AiInsertTarget } from '@/lib/ai-draft-store';
import { useModels } from '@/lib/queries';
import { useUi } from '@/lib/store';
+import { useFocusTrap } from '@/lib/useFocusTrap';
import type { ChatSource } from '@/lib/types';
import { citationFromSource, type CitationAttrs } from './citation-utils';
import { isInsertTargetValid, markdownDraftToBlocks } from './draft-insert-utils';
@@ -84,6 +85,9 @@ function captureSelectionTarget(editor: Editor): AiInsertTarget | null {
export function AiDraftPanel({ editor, docId, docTitle, onClose }: AiDraftPanelProps) {
const { t } = useTranslation();
const navigate = useNavigate();
+ const panelRef = useRef(null);
+ const closeButtonRef = useRef(null);
+ const promptRef = useRef(null);
const { data: models = [] } = useModels();
const defaultModel = useUi((s) => s.defaultModel);
const model = useMemo(() => {
@@ -231,8 +235,15 @@ export function AiDraftPanel({ editor, docId, docTitle, onClose }: AiDraftPanelP
const citableCount = sources.filter((s) => s.target?.lawId).length;
+ useFocusTrap(panelRef, true);
+
+ useEffect(() => {
+ requestAnimationFrame(() => promptRef.current?.focus());
+ }, []);
+
return (
{t('editor.aiDraft.title')}