diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index 62693040..992f12a6 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -14,7 +14,7 @@ These frontmatter field names have special meaning in Laputa's UI: | Field | Meaning | UI behavior | |---|---|---| -| `title:` | Human-readable title (synced with filename) | Breadcrumb, sidebar. Filename = `slugify(title).md` | +| `title:` | Legacy display-title fallback for older notes | Used only when a note has no H1; new notes do not write it automatically | | `type:` | Entity type (Project, Person, Quarter…) | Type chip in note list + sidebar grouping | | `status:` | Lifecycle stage (active, done, blocked…) | Colored chip in note list + editor header | | `icon:` | Per-note icon (emoji, Phosphor name, or HTTP/HTTPS image URL) | Rendered on note title surfaces; editable from the Properties panel | @@ -237,16 +237,17 @@ Laputa separates **display title** from the file identifier: - **Display title resolution** (`extract_title` in `vault/parsing.rs`): first `# H1` on the first non-empty body line, then legacy frontmatter `title:`, then slug-to-title from the filename stem. - **Opening a note is read-only**: selecting a note does not inject or auto-correct `title:` frontmatter. -- **On rename / explicit title edits** (`rename_note`): Laputa updates both filename and `title` frontmatter atomically, plus wikilinks across the vault. +- **Explicit filename actions** (`rename_note`): breadcrumb rename/sync actions update the filename and wikilinks across the vault. The editor body remains the title editing surface. - **Untitled drafts** start as `untitled-*.md` and are auto-renamed on save once the note gains an H1. -### Title Field (UI) +### Title Surface (UI) -The dedicated `TitleField` is a fallback editing surface, not the canonical one: +The BlockNote body is the only title editing surface: -- If the note already has an H1, the editor body is the primary title surface and the dedicated title row is hidden. -- If the note has no H1 and is not an untitled draft, `TitleField` appears above the editor and `onTitleSync` updates `title:` frontmatter plus the filename. -- `TitleField` also responds to `laputa:focus-editor` events with `selectTitle: true` for new-note flows that start without an H1. +- The first H1 is the canonical display title. +- There is no separate title row above the editor, even when a note has no H1. +- Notes without an H1 show the editor body and placeholder only. +- Filename changes are explicit breadcrumb actions, not a dedicated title-input side effect. ### Sidebar Selection diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md index 90938e53..f1ce4734 100644 --- a/docs/GETTING-STARTED.md +++ b/docs/GETTING-STARTED.md @@ -65,7 +65,6 @@ laputa-app/ │ │ ├── WelcomeScreen.tsx # Onboarding screen │ │ ├── GitHubVaultModal.tsx # GitHub vault clone/create │ │ ├── GitHubDeviceFlow.tsx # GitHub OAuth device flow -│ │ ├── TitleField.tsx # Editable note title above editor │ │ ├── ConflictResolverModal.tsx # Git conflict resolution │ │ ├── CommitDialog.tsx # Git commit modal │ │ ├── CreateNoteDialog.tsx # New note modal diff --git a/docs/adr/0055-h1-is-the-only-editor-title-surface.md b/docs/adr/0055-h1-is-the-only-editor-title-surface.md new file mode 100644 index 00000000..26be4998 --- /dev/null +++ b/docs/adr/0055-h1-is-the-only-editor-title-surface.md @@ -0,0 +1,42 @@ +--- +type: ADR +id: "0055" +title: "H1 is the only editor title surface" +status: active +date: 2026-04-11 +supersedes: "0044" +--- + +## Context + +ADR-0044 moved Laputa to H1-as-title, but the frontend still carried a legacy fallback: when a note had no H1, `TitleField` and the old title section could reappear above the editor. That left two competing title surfaces in the product and made it possible for deleting an H1 to resurrect UI that was supposed to be gone. + +The result was both behavioral drift and stale tests: some code paths still treated the dedicated title row as a valid editing surface even though the product direction is now keyboard-first writing directly in the document body. + +## Decision + +**The editor body is now the only title surface. Laputa never renders a separate title section above the editor, regardless of whether a note currently has an H1.** + +Display-title behavior stays: +1. First H1 in the body +2. Legacy frontmatter `title:` +3. Filename-derived fallback + +But the UI no longer exposes a dedicated title field for cases 2 or 3. When a note has no H1, the editor simply shows normal body content or the empty-editor placeholder. + +Filename operations remain explicit: +- untitled notes still auto-rename from H1 on save +- manual filename rename/sync remains in the breadcrumb + +## Options considered + +- **Option A** (chosen): remove the fallback title section entirely. This makes the editor honest, removes a stale code path, and keeps title editing aligned with the keyboard-first document model. +- **Option B**: keep the fallback title field for non-H1 notes. This preserves an alternate rename path, but it reintroduces the exact dual-surface ambiguity that ADR-0044 tried to escape. +- **Option C**: hide the title section with CSS only. Low churn, but it leaves dead render/state paths in place and makes regressions like “delete H1 and old title row returns” easy to reintroduce. + +## Consequences + +- Deleting an H1 no longer reveals any legacy title UI; the user stays in the editor body. +- `TitleField` and the title-section render path are removed from the frontend. +- Breadcrumb filename controls are now the only explicit file-identifier editing surface outside the editor body. +- Older tests that asserted title editing through `TitleField` are obsolete and should be replaced by H1-title or breadcrumb-filename coverage. diff --git a/docs/adr/README.md b/docs/adr/README.md index b7c4877b..2f6c0223 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -99,7 +99,7 @@ proposed → active → superseded | [0041](0041-filekind-all-files-in-vault-scanner.md) | fileKind field — scan all vault files, not just markdown | active | | [0042](0042-trash-auto-purge-safety-model.md) | Trash auto-purge safety model | superseded → [0045](0045-permanent-delete-no-trash.md) | | [0043](0043-reactive-vault-state-on-save.md) | Reactive vault state: editor changes propagate immediately to all UI | active | -| [0044](0044-h1-as-title-primary-source.md) | H1 as primary title source — filename as stable identifier | active | +| [0044](0044-h1-as-title-primary-source.md) | H1 as primary title source — filename as stable identifier | superseded → [0055](0055-h1-is-the-only-editor-title-surface.md) | | [0045](0045-permanent-delete-no-trash.md) | Permanent delete with confirm modal — no Trash system | active | | [0046](0046-starter-vault-cloned-from-github.md) | Starter vault cloned from GitHub at runtime — no bundled content | active | | [0047](0047-regex-mode-for-view-filter-conditions.md) | Regex mode for view filter conditions | active | @@ -110,3 +110,4 @@ proposed → active → superseded | [0052](0052-renderer-first-shortcut-execution-with-native-menu-dedupe.md) | Renderer-first shortcut execution with native-menu dedupe | active | | [0053](0053-webview-init-prevention-for-browser-reserved-shortcuts.md) | Webview-init prevention for browser-reserved shortcuts | active | | [0054](0054-deterministic-shortcut-qa-matrix.md) | Deterministic shortcut QA matrix | active | +| [0055](0055-h1-is-the-only-editor-title-surface.md) | H1 is the only editor title surface | active | diff --git a/src/App.tsx b/src/App.tsx index 3c7b31b3..3e9169dc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -707,7 +707,6 @@ function App() { onUnarchiveNote={activeDeletedFile ? undefined : entryActions.handleUnarchiveNote} onContentChange={appSave.handleContentChange} onSave={appSave.handleSave} - onTitleSync={activeDeletedFile ? undefined : appSave.handleTitleSync} onRenameFilename={activeDeletedFile ? undefined : handleFilenameRename} rawToggleRef={rawToggleRef} diffToggleRef={diffToggleRef} diff --git a/src/components/BreadcrumbBar.test.tsx b/src/components/BreadcrumbBar.test.tsx index bcc1d4f7..80d2a875 100644 --- a/src/components/BreadcrumbBar.test.tsx +++ b/src/components/BreadcrumbBar.test.tsx @@ -141,26 +141,16 @@ describe('BreadcrumbBar — title in breadcrumb (always rendered, CSS-toggled)', const { container } = render() const bar = container.querySelector('.breadcrumb-bar')! expect(bar).toHaveClass('border-b', 'border-transparent') - expect(bar).not.toHaveAttribute('data-title-hidden') - bar.setAttribute('data-title-hidden', '') expect(bar).toHaveAttribute('data-title-hidden') }) - it('uses the active separator state when raw mode forces the title into the breadcrumb', () => { + it('keeps the breadcrumb title visible in raw mode', () => { const { container } = render( , ) expect(container.querySelector('.breadcrumb-bar')).toHaveAttribute('data-title-hidden') }) - - it('keeps the breadcrumb title visible when the separate title section is absent', () => { - const { container } = render( - , - ) - - expect(container.querySelector('.breadcrumb-bar')).toHaveAttribute('data-title-hidden') - }) }) describe('BreadcrumbBar — filename controls', () => { diff --git a/src/components/BreadcrumbBar.tsx b/src/components/BreadcrumbBar.tsx index ba7ff9a7..57d69e2e 100644 --- a/src/components/BreadcrumbBar.tsx +++ b/src/components/BreadcrumbBar.tsx @@ -43,7 +43,6 @@ interface BreadcrumbBarProps { onArchive?: () => void onUnarchive?: () => void onRenameFilename?: (path: string, newFilenameStem: string) => void - showTitleSection?: boolean /** Ref for direct DOM manipulation — avoids re-render on scroll. */ barRef?: React.Ref } @@ -515,17 +514,13 @@ export const BreadcrumbBar = memo(function BreadcrumbBar({ entry, barRef, onRenameFilename, - showTitleSection = true, ...actionProps }: BreadcrumbBarProps) { - // In raw/diff mode the title section is not rendered — always show title in breadcrumb. - // Using a prop-driven attribute avoids the timing issues of DOM mutation in useEffect. - const titleAlwaysVisible = !showTitleSection || actionProps.rawMode || actionProps.diffMode return (
void onContentChange?: (path: string, content: string) => void onSave?: () => void - /** Called when the user edits the title in TitleField. */ - onTitleSync?: (path: string, newTitle: string) => void /** Called when the user explicitly renames the filename from the breadcrumb. */ onRenameFilename?: (path: string, newFilenameStem: string) => void canGoBack?: boolean @@ -205,7 +203,7 @@ export const Editor = memo(function Editor(props: EditorProps) { showAIChat, onToggleAIChat, vaultPath, noteList, noteListFilter, onToggleFavorite, onToggleOrganized, onDeleteNote, onArchiveNote, onUnarchiveNote, - onContentChange, onSave, onTitleSync, onRenameFilename, + onContentChange, onSave, onRenameFilename, onFileCreated, onFileModified, onVaultChanged, isConflicted, onKeepMine, onKeepTheirs, } = props @@ -256,7 +254,6 @@ export const Editor = memo(function Editor(props: EditorProps) { onUnarchiveNote={onUnarchiveNote} vaultPath={vaultPath} rawLatestContentRef={rawLatestContentRef} - onTitleChange={onTitleSync} onRenameFilename={onRenameFilename} isConflicted={isConflicted} onKeepMine={onKeepMine} diff --git a/src/components/TitleField.test.tsx b/src/components/TitleField.test.tsx deleted file mode 100644 index 8b51116e..00000000 --- a/src/components/TitleField.test.tsx +++ /dev/null @@ -1,168 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import { render, screen, fireEvent } from '@testing-library/react' -import { TitleField } from './TitleField' - -describe('TitleField', () => { - it('renders the title in the input', () => { - render( {}} />) - expect(screen.getByTestId('title-field-input')).toHaveValue('My Note') - }) - - it('calls onTitleChange on blur with new value', () => { - const onChange = vi.fn() - render() - const input = screen.getByTestId('title-field-input') - fireEvent.change(input, { target: { value: 'New Title' } }) - fireEvent.blur(input) - expect(onChange).toHaveBeenCalledWith('New Title') - }) - - it('does not call onTitleChange if title unchanged', () => { - const onChange = vi.fn() - render() - const input = screen.getByTestId('title-field-input') - fireEvent.focus(input) - fireEvent.blur(input) - expect(onChange).not.toHaveBeenCalled() - }) - - it('reverts to original title if input is emptied', () => { - const onChange = vi.fn() - render() - const input = screen.getByTestId('title-field-input') - fireEvent.change(input, { target: { value: '' } }) - fireEvent.blur(input) - expect(onChange).not.toHaveBeenCalled() - expect(input).toHaveValue('Keep This') - }) - - it('shows filename indicator when slug differs and title is focused', () => { - render( {}} />) - // Not shown when unfocused - expect(screen.queryByTestId('title-field-filename')).not.toBeInTheDocument() - // Shown when focused - fireEvent.focus(screen.getByTestId('title-field-input')) - expect(screen.getByTestId('title-field-filename')).toHaveTextContent('my-note.md') - }) - - it('does not show filename when slug matches and not editing', () => { - render( {}} />) - expect(screen.queryByTestId('title-field-filename')).not.toBeInTheDocument() - }) - - it('disables input when editable is false', () => { - render( {}} />) - expect(screen.getByTestId('title-field-input')).toBeDisabled() - }) - - it('commits title on Enter key', () => { - const onChange = vi.fn() - render() - const input = screen.getByTestId('title-field-input') - fireEvent.change(input, { target: { value: 'After' } }) - fireEvent.keyDown(input, { key: 'Enter' }) - // In jsdom, blur() after keyDown needs explicit blur event - fireEvent.blur(input) - expect(onChange).toHaveBeenCalledWith('After') - }) - - it('reverts on Escape key', () => { - const onChange = vi.fn() - render() - const input = screen.getByTestId('title-field-input') - fireEvent.change(input, { target: { value: 'Changed' } }) - fireEvent.keyDown(input, { key: 'Escape' }) - // Escape reverts value and blurs - expect(input).toHaveValue('Original') - }) - - it('shows new title optimistically after commit (before prop updates)', () => { - const onChange = vi.fn() - const { rerender } = render() - const input = screen.getByTestId('title-field-input') - fireEvent.focus(input) - fireEvent.change(input, { target: { value: 'New Title' } }) - fireEvent.blur(input) - // After commit, should show new title even though prop is still "Old Title" - expect(input).toHaveValue('New Title') - // After prop updates to match, should still show new title - rerender() - expect(input).toHaveValue('New Title') - }) - - it('resets optimistic title when prop changes from external source', () => { - const onChange = vi.fn() - const { rerender } = render() - const input = screen.getByTestId('title-field-input') - // Simulate external title change (e.g., tab switch) - rerender() - expect(input).toHaveValue('Title B') - }) - - it('responds to laputa:focus-editor event with selectTitle', () => { - render( {}} />) - const input = screen.getByTestId('title-field-input') - window.dispatchEvent(new CustomEvent('laputa:focus-editor', { detail: { selectTitle: true } })) - expect(document.activeElement).toBe(input) - }) - - it('resets stale localValue when title prop changes after focus', () => { - // Regression: creating a new note fires focus-editor before React re-renders, - // so handleFocus captures the OLD note's title into localValue. - // When React re-renders with the new title, localValue should be cleared. - const onChange = vi.fn() - const { rerender } = render() - const input = screen.getByTestId('title-field-input') - // Simulate: focus fires while title prop is still "Old Note" - fireEvent.focus(input) - expect(input).toHaveValue('Old Note') - // React re-renders with new note's title (tab switched) - rerender() - expect(input).toHaveValue('Untitled note') - }) - - it('shows vault-relative path (without .md) only when title is focused', () => { - render( {}} />) - // Path hidden by default - expect(screen.queryByTestId('title-field-path')).not.toBeInTheDocument() - // Focus title → path appears - fireEvent.focus(screen.getByTestId('title-field-input')) - expect(screen.getByTestId('title-field-path')).toHaveTextContent('docs/adr/0001-tauri-stack') - // No bare filename shown when path is visible - expect(screen.queryByTestId('title-field-filename')).not.toBeInTheDocument() - }) - - it('hides path on blur', () => { - render( {}} />) - const input = screen.getByTestId('title-field-input') - fireEvent.focus(input) - expect(screen.getByTestId('title-field-path')).toBeInTheDocument() - fireEvent.blur(input) - expect(screen.queryByTestId('title-field-path')).not.toBeInTheDocument() - }) - - it('hides path for notes at vault root even when focused', () => { - render( {}} />) - fireEvent.focus(screen.getByTestId('title-field-input')) - expect(screen.queryByTestId('title-field-path')).not.toBeInTheDocument() - }) - - it('hides path when vaultPath is not provided', () => { - render( {}} />) - fireEvent.focus(screen.getByTestId('title-field-input')) - expect(screen.queryByTestId('title-field-path')).not.toBeInTheDocument() - }) - - it('resolves vault-relative path when paths differ by symlink prefix', () => { - // vaultPath uses symlink /Users/luca/... but notePath is canonical /Volumes/Jupiter/... - render( {}} />) - fireEvent.focus(screen.getByTestId('title-field-input')) - expect(screen.getByTestId('title-field-path')).toHaveTextContent('docs/adr/0001-tauri-stack') - }) - - it('handles vaultPath with trailing slash', () => { - render( {}} />) - fireEvent.focus(screen.getByTestId('title-field-input')) - expect(screen.getByTestId('title-field-path')).toHaveTextContent('docs/adr/0001-tauri-stack') - }) -}) diff --git a/src/components/TitleField.tsx b/src/components/TitleField.tsx deleted file mode 100644 index df7feb46..00000000 --- a/src/components/TitleField.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import { useState, useCallback, useRef, useEffect } from 'react' -import { slugify } from '../hooks/useNoteCreation' - -interface TitleFieldProps { - title: string - filename: string - editable?: boolean - /** Absolute path of the note file. */ - notePath?: string - /** Absolute path of the vault root. */ - vaultPath?: string - /** Called when the user finishes editing the title (blur or Enter). */ - onTitleChange: (newTitle: string) => void -} - -/** Manages local edit + optimistic title state for TitleField. */ -function useOptimisticTitle(title: string, onTitleChange: (t: string) => void) { - const [localValue, setLocalValue] = useState(null) - // [optimisticTitle, forPropTitle]: shown after commit until title prop catches up - const [optimistic, setOptimistic] = useState<[string, string] | null>(null) - const isFocusedRef = useRef(false) - const [prevTitle, setPrevTitle] = useState(title) - - // Reset local edit when the title prop changes (e.g. note switch). - // This prevents a stale handleFocus closure from locking in the old note's title - // when focus-editor fires before React re-renders with the new tab. - if (prevTitle !== title) { - setPrevTitle(title) - if (localValue !== null) setLocalValue(null) - } - - // Clear optimistic once the prop changes (rename completed or tab switched) - const optimisticValue = optimistic && optimistic[1] === title ? optimistic[0] : null - const value = localValue ?? optimisticValue ?? title - const isEditing = localValue !== null || optimisticValue !== null - - const handleFocus = useCallback(() => { - isFocusedRef.current = true - setLocalValue(title) - }, [title]) - - const commitTitle = useCallback(() => { - isFocusedRef.current = false - const trimmed = (localValue ?? '').trim() - if (trimmed && trimmed !== title) { - setLocalValue(null) - setOptimistic([trimmed, title]) - onTitleChange(trimmed) - } else { - setLocalValue(null) - } - }, [localValue, title, onTitleChange]) - - const revert = useCallback(() => setLocalValue(null), []) - const setEdit = useCallback((v: string) => setLocalValue(v), []) - - return { value, isEditing, handleFocus, commitTitle, revert, setEdit } -} - -/** - * Dedicated title input field above the editor. - * Displays the title as an editable field and shows the resulting filename below. - */ -export function TitleField({ title, filename, editable = true, notePath, vaultPath, onTitleChange }: TitleFieldProps) { - const inputRef = useRef(null) - const [isFocused, setIsFocused] = useState(false) - const { value, isEditing, handleFocus, commitTitle, revert, setEdit } = - useOptimisticTitle(title, onTitleChange) - - // Auto-resize textarea to fit content (fallback for browsers without field-sizing: content) - const autoResize = useCallback(() => { - const el = inputRef.current - if (!el) return - el.style.height = 'auto' - el.style.height = el.scrollHeight + 'px' - }, []) - - useEffect(() => { - autoResize() - // Schedule a second measurement after the browser has painted, to catch - // cases where scrollHeight is stale on the first read (e.g. tab switch). - const id = requestAnimationFrame(autoResize) - return () => cancelAnimationFrame(id) - }, [value, autoResize]) - - // Re-measure when container width changes (text may re-wrap) - useEffect(() => { - const el = inputRef.current - if (!el) return - const ro = new ResizeObserver(autoResize) - ro.observe(el) - return () => ro.disconnect() - }, [autoResize]) - - useEffect(() => { - const handler = (e: Event) => { - const detail = (e as CustomEvent).detail - if (detail?.selectTitle && inputRef.current) { - inputRef.current.focus() - inputRef.current.select() - } - } - window.addEventListener('laputa:focus-editor', handler) - return () => window.removeEventListener('laputa:focus-editor', handler) - }, []) - - const handleKeyDown = useCallback((e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - e.preventDefault() - inputRef.current?.blur() - } - if (e.key === 'Escape') { - revert() - inputRef.current?.blur() - } - }, [revert]) - - const expectedSlug = slugify(value.trim() || title) - const currentStem = filename.replace(/\.md$/, '') - - // Compute vault-relative path (only for notes in subdirectories) - const relativePath = (() => { - if (!notePath || !vaultPath) return null - const vp = vaultPath.replace(/\/+$/, '') - const np = notePath.replace(/\.md$/, '') - if (np.startsWith(vp + '/')) return np.slice(vp.length + 1) - // Fallback: match by vault directory name for symlink-resolved paths - const vaultName = vp.split('/').pop() - if (!vaultName) return null - const segments = np.split('/') - const idx = segments.lastIndexOf(vaultName) - if (idx >= 0) return segments.slice(idx + 1).join('/') - return null - })() - const isSubdirectory = relativePath != null && relativePath.includes('/') - - // Show path only when title is focused and note is in a subdirectory - const showRelativePath = isFocused && isSubdirectory - // Show filename hint when slug differs, but only when focused (not always) - const showFilename = isFocused && !showRelativePath && (isEditing || currentStem !== expectedSlug) - - return ( -
-