You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Find in document: minimal floating Find bar (⌘F), then Find and Replace #393
Find already ships, but as a docked strip under the toolbar. The maintainer wants a different shape. This issue revises and refactors Find in two steps.
Find bar. A small floating bar at the top right of the editor column, below the toolbar. It holds the query, a match count, Previous, Next and Close. ⌘F / Ctrl+F opens it, and Escape closes it.
Replace row, later. A chevron opens a second row with Replace and Replace all. Match case, Whole word and Regex toggles come with it.
The UI and UX team owns the look, the micro-animations and the micro-interactions. The research team looked at a TOC match signal. It recommends no TOC signal for now (see TOC match signal).
The maintainer will add the four screenshots to this issue, because gh cannot attach them:
The pad toolbar "Find in document (⌘+F)" button.
The Google Chrome find bar.
The VS Code find widget, collapsed.
The VS Code find widget, expanded with a Replace row.
Maintainer request (verbatim quote)
alrigth let's work on this featue, this is not the feature I expected, let's review related github issue for this feature, we have to revise and refactor this feature, the first refactor part is related to the UI and UX. so we have to create a floating input bar, simple and minimal, that will be show up in right top bellow the toolbar, like the chrome find popover component (second image) it must include previous and next button and also the close button, also we have to map the short key to this component in order when user hit the short key cmd+f then the search bar apear, also we need to expand it later and make it like vscode in order to let people to find and replace (third and fourth pictures). let's work on this github issue and create a gtihub issue and ensure the UI and UX team will be work on it and polish the idea and make the UX greate withe micro-animation and also micro-intraction. also we have to cover the ESC button in order for dismiss, this component must be fully mature. also when user search through the entire document, and for those content find and hilight, it must highlight the TOC contents to let user see which heading might include the text that user wants to find (thsi idea is not mature and you have to run team of reserch and see what is the best way to have this idea, or it is not needed at all.)
It plans to move foldedSectionsAt next to the fold plugin. Find imports it. Coordinate the move
#276 Run the webapp Cypress suite in CI, #311 Filter apply test never passed
CI coverage
Open
New find specs gate nothing in CI until #276 lands
#250 is still open, but its code shipped. The maintainer can link or close it. This issue does not change it.
Why it is "not the feature I expected".#250 built the right engine with the wrong shape. The maintainer asked for a compact floating bar, like the Google Chrome find bar. #250 built a full-width row with a bottom border inside the toolbar wrapper. Opening it pushes the document down. Replace was out of scope on purpose, and it is now wanted as a later step.
Current behaviour
All items are read from code. Nothing here was measured in a browser.
Mounts: desktop toolbar/desktop/EditorToolbar.tsx (toolbar button data-testid="toolbar-find", then <FindBar variant="desktop" /> after the toolbar row). Phone pages/document/layouts/MobileLayout.tsx (under MobilePadTitle), opened from the TocModal.tsx footer.
The engine is a ProseMirror plugin with inline decorations. A match never enters the document or its history.
The search is a literal, case-insensitive substring per textblock, Title included. Media and other leaf nodes count as one object character, so no match spans them.
Painting and stepping stop at 200 matches (CARET_FIND_HIT_CAP). The count then reads 1 of 200+. A match after the 200th cannot be reached.
Other matches use warning at 28%. The current match uses warning at 60% plus a 1px warning ring. This is separate from the Filter highlight and the Highlight mark.
Each keystroke dispatches a transaction. That transaction rescans the whole document and moves the editor selection to the first match after the caret.
The current match scrolls with scrollIntoView({ block: 'center' }). On a phone it uses scrollElementInMobilePadEditor.
A match in a folded section unfolds that section for the session only (persist: false). Close restores the folds. The Cypress spec covers this.
Shortcut
A window keydown listener in the capture phase handles Mod-f (isModShortcut). It calls preventDefault, so the browser find never opens.
It skips the key when a modal makes the editor aria-hidden or inert.
On desktop it takes Mod-f from every focus target on the pad, the docked chat composer included.
On a phone the bar yields while the chat pane is open (selectPadOwnsKeyboard).
A second Mod-f while the bar is open focuses the input and selects the query.
UI
Desktop: a row border-b px-3 py-1.5, content right-aligned (justify-end), under the toolbar. It pushes the editor well down by about 45px (read from code, not measured).
The row is role="search" with a w-64TextInput. The count is role="status" outside .ProseMirror. Previous, Next and Close are ToolbarButtons.
The bar mounts and unmounts at once. It has no motion.
Phone: the same row, full width, under the pad title. _caret-find.scss hides the bottom toolbar while the bar has focus.
Gaps and defects found in review
The current match resets on a remote edit (likely bug).@tiptap/y-tiptap applies every remote change as one replace of the whole document (_typeChanged). The plugin maps the old current match through that step, so it lands at the document end. firstHitFrom then wraps to index 0. "7 of 12" becomes "1 of 12" whenever a collaborator types. Local undo and redo take the same path. Read from code, not measured.
The caret moves on every keystroke. Each query change sets the editor selection. The caret then sends one awareness cursor update per keystroke and per step. Read from code, not measured.
Escape works only in the input. Focus on Previous, Next or Close ignores Escape.
Close leaves a collapsed caret at the match start. The match text is not selected, so the user cannot type over it.
No ⌘G / Shift+⌘G or F3. No selection prefill.
The first count can go unspoken. The status element mounts with its text already set when the bar reopens with an old query. Screen readers may skip that first value.
No debounce. A query that matches nothing walks the whole document on every keystroke and on every remote edit. The cost on a large pad is not measured.
No catalog row..cursor/docs/design-system.md has no row for the find bar or for the match colours.
Proposed behaviour — Find bar
Layout
Desktop position. The bar floats at the top right of the editor column, under the toolbar. It does not span the window. It sits inside the editor-column wrapper in DesktopEditor.tsx (the relative flex min-h-0 min-w-0 flex-1 flex-col box), with absolute placement. It does not portal.
That placement follows the TOC width, the tick rail and docked chat for free. It never covers the TOC. Docked chat sits at the bottom of the same column. Check that a tall chat panel never meets the bar.
The right inset clears the editor scrollbar, which is --scrollbar-size-thin (6px) wide. The bar never covers the thumb.
Species. L1 anchored (design-system.md §Elevation species). Frame: popoverPanelClassName, merged through @utils/twMerge with a width override only. The catalog already lists other consumers that override only size. No new radius, border or shadow.
Width. About 22rem, capped at calc(100% - 1.5rem) on a narrow column. This is a proposed value; the visual preview settles it.
Paint order. The floating tier z-50, above the docked z-[42] band.
Phone. Keep the docked L0 row under MobilePadTitle. See Mobile.
Content (left to right)
Query input: TextInput size="sm", placeholder "Find in document", aria-label="Find in document". The ghost variant lets the bar read as one box. The preview confirms that focus stays visible.
Count: text-meta text-base-content/60 tabular-nums, with a fixed minimum width. The bar does not shift when "9 of 12" becomes "10 of 12".
ToolbarDivider (the screenshot 2 divider).
Previous (Icons.chevronUp), Next (Icons.chevronDown), Close (Icons.close), all ToolbarButton. Tooltips: "Previous match (⇧Enter)", "Next match (Enter)", "Close (Esc)".
The toolbar Find button shows the is-active recipe while the bar is open. It sets aria-expanded and aria-controls to the bar.
Keyboard map
Key
Where
Action
⌘F / Ctrl+F
Anywhere on the pad page, unless a modal makes the editor aria-hidden or inert
Open the bar, focus the input, select its text. Prefill: see open decision 3
⌘F / Ctrl+F
Bar already open
Focus the input and select the query. Do not reopen. Do not replay the open motion
Enter
Input
Next match
Shift+Enter
Input
Previous match
⌘G / Ctrl+G, F3
Bar open, focus in the input or the editor
Next match
Shift+⌘G / Shift+Ctrl+G, Shift+F3
Bar open, focus in the input or the editor
Previous match
Escape
Any control in the bar
Close. Select the current match in the editor and focus the editor when it is editable. Otherwise return focus to the opener
Escape
Editor, bar open, no editor menu or popover open
Close the bar (open decision 6)
Enter with IME composition
Input
Ignore (already guarded by isComposing)
No ⌘G or F3 binding exists in the webapp today, so these chords do not collide. ⌘G / Ctrl+G and F3 follow the browser's own find keys. Bind them only while the bar is open, so the browser keeps them otherwise.
States
State
Input
Count
Previous / Next
Editor
Idle (open, empty)
Focused, placeholder
Empty
Disabled (/40)
No paint
Typing
Value updates
Updates on each keystroke
Enabled when matches exist
All matches paint. The current match scrolls into view. The caret does not move (open decision 7)
Results
Value
3 of 12, or 3 of 200+ at the paint cap
Enabled. Wraps at both ends
Current match uses the current paint
No results
Value stays
No results
Disabled
No paint
Closed
—
—
—
Paint cleared. Folds that Find opened fold again. The current match is selected
Motion and micro-interactions
All values come from apps/webapp/CLAUDE.md §Motion System and utils/motion.ts. No new duration, easing or keyframe.
Open (desktop). Overlay tier: 120ms --motion-overlay-in ease-out. Opacity plus scale 0.96, with transform-origin: top right (the anchored side). Mount through useEntryExitTransition. The transform sits on the bar only, never on an ancestor of .ProseMirror.
Close (desktop). 80ms --motion-overlay-out ease-in, opacity only.
Open and close (phone). Opacity only (doc-content-in 120ms), because the bar sits in the visual-viewport shell. The hard rule bans transforms there.
Reopen with ⌘F. No replay. One-shot reveals must not replay on re-render.
Count. No animation. tabular-nums and a fixed width keep it still. RollingNumber is for unread badges, and a reel on every keystroke is noise.
No results. No shake. A shake fires while the user is still typing a word. It needs a new keyframe, which the lock forbids. The count text and the disabled arrows carry the state.
Step. Scroll the current match with behavior: 'instant'. A held Enter repeats the key, and smooth scrolls then queue and lag. An explicit behavior also beats the wrapper's scroll-smooth class, so the two do not fight. The paint swap from "other" to "current" is the visual feedback.
Toolbar button.is-active while open. That is the existing toolbar recipe.
Reduced motion.useEntryExitTransition already skips to the final state. The step scroll is instant in every mode.
Accessibility
Container: role="search" with aria-label="Find in document". It is non-modal and has no focus trap. Tab moves input → Previous → Next → Close, then on through the page.
Count: one role="status" element. Render it empty when the bar mounts, then write the count. This fixes gap 6. While the user types, update the spoken text after a short pause and keep the visible count instant. The pause length is the team's call.
Every icon button has an aria-label. On touch, Tooltip does not name anything, so labels stay on the buttons.
The live region stays outside .ProseMirror (TipTap CLAUDE.md §Editor Performance).
Check match-paint contrast in all 7 themes, including docsplus-dark-hc. Not measured yet.
Mobile
The mobile shell is user-agent gated. A narrow desktop window never shows it.
Keep the docked L0 row under MobilePadTitle, opened from the TocModal footer Find button. Find text in the open pad #250 rules apply: no BottomSheet, because a sheet drops the iOS keyboard.
Use the same content, states and keyboard rules as desktop. Only the frame and the motion differ.
The shell is sized by --visual-viewport-height, so Previous and Next stay above the keyboard. Keep text-base on the input to stop iOS zoom.
Controls keep the settled 32px docked size.
The bar still yields to an open chat pane.
On iPhone and many iPads, ⌘F never reaches JS. The Find button stays the entry point there. A missed chord is not a bug.
Later — Find and Replace
Ship after the Find bar, as its own slices.
Scope
A chevron button at the left of the bar. It has aria-expanded and aria-controls. It shows the Replace row, as in screenshot 4.
Replace row: a replace input, then Replace (current match, then step to the next) and Replace all.
Toggles on the find input: Match case, Whole word, Regex. Each is a ToolbarButton with aria-pressed and the is-active recipe. Defaults stay off, so the current literal, case-insensitive search does not change.
Whole word must use Unicode letter classes (\p{L} with the u flag). A plain \b does not see Persian or Arabic letters.
An invalid regex shows an inline error and paints nothing. It never throws.
Read-only and access rules
Show the chevron and the Replace row only when the editor is editable. Hide them under Read-only for a non-owner, under the editing lock (selectDocumentEditingLocked), and in History.
Replace all is a large edit. Open decision 10 asks whether it needs a signed-in user (isVisitor(), then openInlineSignInDialog()).
One transaction
Replace all scans the whole document at click time, with no 200 cap. It applies every change in one ProseMirror transaction, in reverse document order, with tr.insertText.
One transaction becomes one Yjs update. Collaborators see one change. One Mod-z undoes it all.
After the transaction, the count shows "No results" or the new matches. The editor keeps focus.
Risks
Sections. Each heading is a section with its own chatroom, keyed by toc-id. A match never crosses a textblock, so a replace never joins or splits blocks. The heading node and its toc-id survive. A test must pin this.
Heading text. A replace inside a heading changes the TOC title and the ?h= slug trail. Links use id=, so they still resolve.
Marks.insertText takes the marks at the match start. A match that crosses a hyperlink edge can lose part of the link. Decide whether to refuse such a match or to keep the first mark.
Comment anchors.scrollToCommentAnchor finds a text comment by its quoted text. A replace that changes that text sends the jump to the heading fallback.
Title. Replace may empty Title. Title must stay a heading, as it does today.
Regex cost. A slow pattern blocks the main thread of the user's own tab. Cap the scan and fail closed.
Concurrent edits. A peer who types inside a match during Replace all merges through Yjs. Accept that.
Chord.Mod-Shift-h is already Highlight (@tiptap/extension-highlight), so the Google Docs replace chord is taken. See open decision 10.
TOC match signal — research and recommendation
Question. Should the TOC show which headings hold matches?
What other tools do. Sources are vendor help pages, read on 2026-10-05.
Google Docs has find and replace with Match case and regex. Its outline help describes no match mark. Sources: Find and replace, Document outline.
VS Code paints find matches in the editor, the overview ruler (by the scrollbar) and the minimap. Its Outline view shows no match mark. Source: VS Code — Basic editing, Find and replace.
Notion finds inside a page with ⌘F / Ctrl+F and shows no outline mark. Source: Notion — Search.
Confluence (7.20 docs) finds and replaces in the editor with yellow matches, inside the current page only. Source: Atlassian — The Editor.
Obsidian lists headings in its Outline plugin. That help page describes no match mark. Source: Obsidian — Outline.
Only Word for Windows marks headings. Most tools mark positions next to the scrollbar, or nothing.
Options
Option
For
Against
A. No TOC signal
Zero clutter. No new design-system recipe. Today, a match in a folded section already unfolds that section in the TOC, and the scroll spy follows the scrolled match
No overview of where matches sit
B. Match count badge per row
Word-like overview
Collides with the red unread badge in TocRowTrail. Counts are wrong past the 200 cap. A new badge recipe is locked
C. Small dot on rows with matches
Light overview
A dot is a new state recipe (locked). The red indicatorDot means attention, so it cannot be reused. A dot under a folded parent needs a roll-up rule
D. Dim rows with no matches
Strong overview
Reads like Filter, which Find must not imitate (#250). /40 means disabled in the ink ladder. Close to the banned scroll-spy wash
E. Filter the TOC to matching sections
Short list
Duplicates Filter. Breaks the Find versus Filter split
F. Mark only the current match's section
One row, no counting
Needs the spy state or a new recipe. Driving menu-focus from Find fights the scroll spy
G. Marks on the tick rail (like a scrollbar overview)
Close to VS Code
Touches settled tick-rail rules. Desktop rail only. A new colour is locked
Constraints that weigh in. The root CLAUDE.md settled list bans the data-level type ladder, the chat-open accent bar and the scroll-spy wash. Presence overhang is rejected. TocRowTrail already holds chat, unread and presence. On a phone the TOC is a modal drawer. It is closed while the find bar is open, so nobody would see a signal there. The cost of counting matches per section is small next to the scan itself. Clutter and meaning are the real cost.
Recommendation: A, no TOC signal, for now. The new bar answers the request without it. Most tools the team checked do not mark the outline. Every visible option needs a locked design-system change or bends a settled TOC rule. If readers still ask for an overview after the new bar ships, open an Ideas discussion. Bring option F or C with a design-system ruling. Do not bring D or E. Open decision 11 asks the maintainer to confirm.
Plan
0. Visual preview (UI and UX team). Before any code, build one interactive HTML demo or Cursor canvas, per AGENTS.md §Workflow And Review Expectations. Show the desktop bar in light, dark and one premium theme, in idle, results, no-results and capped states. Show it with and without docked chat, on a narrow column, open and close motion, and the phone row. The maintainer approves the look. Blocks 2 and 5.
1. Engine fixes (caret-find-plugin.ts). Keep the current index through a whole-document replace: on a y-sync change, anchor on the restored selection, not on the mapped position. Apply open decisions 7 (caret on step only) and 8 (cap). Select the match range on close. Add query debounce only if the large-pad measure in step 6 fails. No blocker.
2. Desktop floating bar. Move FindBar out of the toolbar wrapper into the editor column. Use the L1 frame, the overlay motion, the is-active toolbar button and the fixed-width count. Blocked by 0.
3. Keyboard map. ⌘G / F3, Escape from any bar control, Escape from the editor (decision 6), prefill (decision 3), the ⌘F scope (decision 5). Blocked by 2.
4. Accessibility. Status element rendered empty, then written. Spoken-count pause. Labels. aria-expanded and aria-controls on the toolbar button. Blocked by 2.
5. Phone polish. Same states and rules on the docked row, opacity-only motion. Blocked by 0.
6. Tests and verification (sections below). Measure on a large pad. Add the design-system catalog row for the find bar and its match colours, in the same change, as the routine catalog edit. Blocked by 1–5.
7. Toggles (Match case, Whole word, Regex). Blocked by 6 and decision 9.
8. Replace row (Replace, Replace all, one transaction). Blocked by 7 and decision 10.
TOC signal. Not planned. See decision 11.
Scope
The desktop Find bar refactor and its keyboard, motion, accessibility and engine fixes.
Phone row polish.
Later slices: toggles, then the Replace row.
Out of scope
A TOC match signal (recommendation A).
Searching chat, Pad title or History. Find in the chat feed is a separate feature.
New tokens, species, keyframes or shadows. Anything that needs one becomes an open decision.
The ⌘ glyph in Windows toolbar tooltips. Every toolbar tooltip has it; fix it in its own issue.
Persian and Arabic letter folding (for example ی with ي, ک with ك) and diacritic-insensitive search. Track them as a later idea if readers ask.
Open decisions
Each decision needs the maintainer. Each has a recommendation.
Position on a narrow desktop and with docked chat. Recommend: always top right of the editor column. Below the sm breakpoint the desktop toolbar moves to the window bottom (fixed bottom-0 in DesktopEditor.tsx), and the bar still stays top right. With docked chat, the bar stays in the editor area above the chat.
Find and Filter. Recommend: keep both, side by side, as Find text in the open pad #250 ruled. Find steps the caret through text. Filter hides sections.
Prefill. Recommend: when the editor selection is not empty, sits in one textblock and is short, prefill it and select it. Otherwise keep the last query of the session, selected. Do not seed the word under the caret. A good length limit is about 100 characters, a proposed value.
Case sensitivity default. Recommend: insensitive, as today. Match case arrives as a toggle in slice 7.
⌘F scope. Recommend: keep ⌘F on the pad find everywhere on the pad page, the docked chat composer included. Skip it only when a modal hides the editor. The chat feed is virtualized, so the browser find cannot see most messages either. One rule is easier to learn.
Escape in the editor. Recommend: Escape in the editor closes the bar. An open editor menu or popover takes Escape first: the slash menu, a hyperlink popover, the media toolbar.
Caret while typing. Recommend: while the user types, paint and scroll the current match but leave the caret. Move the caret on a step and on close. This cuts the awareness cursor updates sent per keystroke.
Match cap. Recommend: keep the 200-match paint cap. Count and step through every match up to a hard limit, about 9,999, a proposed value. Then the count reads 9999+. Today a match after the 200th cannot be reached.
Toggles in the first slice? Recommend: no. Find text in the open pad #250 ruled a literal search. Ship toggles with Replace (slice 7).
Replace rules. Recommend: Replace all needs a signed-in user. Single Replace follows the normal edit rule. Chord: ⌥⌘F on macOS (VS Code uses it). On Windows and Linux, choose between Ctrl+H (the browser's History key) and Ctrl+Alt+F (AltGr on some layouts). Mod-Shift-h is taken by Highlight.
TOC signal. Recommend: none for now (option A). Revisit only if readers ask, with a design-system ruling.
No-results look. Recommend: neutral "No results" text and disabled arrows. Do not use the input-error border or a shake.
Design system (§Change protocol (locked)). Confirm three things. (a) The match colours stay the warning mixes in _caret-find.scss (28% for other matches, 60% plus a 1px ring for the current one). The catalog row records them, and no new token is added. (b) The find bar joins the L1 members list as code that follows an existing species. (c) No current-match flash keyframe. A flash would be a new motion and needs its own ruling.
Acceptance criteria
On macOS, ⌘F opens the bar, focuses the input and selects its text. The browser find does not open.
On Windows and Linux, Ctrl+F does the same.
A second ⌘F / Ctrl+F while the bar is open selects the query. The open motion does not replay.
The bar floats at the top right of the editor column, under the toolbar. Opening it does not move the document.
Enter and ⌘G / Ctrl+G / F3 go to the next match. Shift+Enter and Shift+⌘G / Shift+Ctrl+G / Shift+F3 go to the previous match. Both wrap.
Escape closes the bar from the input and from Previous, Next and Close.
After Escape on an editable pad, the editor has focus and the current match is selected.
After Escape on a read-only pad, focus returns to the opener.
The count matches the real number of matches, Title included, and shows the paint cap rule from decision 8.
An empty query shows no count. A query with no match shows "No results" and disables Previous and Next.
A match inside a folded section unfolds only that section. Close folds it again. Stored folds do not change.
A collaborator edit elsewhere keeps the current index ("7 of 12" stays "7 of 12" when the count does not change).
A collaborator edit that adds a match updates the count without moving the view.
Use a large pad of about 1,500 headings, the size HeadingScale was measured at. One keystroke in the find input fits in one frame. Record the measured number in the PR.
A Read-only viewer and a visitor can find. Replace controls never show for them (slice 8).
The bar looks correct in docsplus, docsplus-dark, docsplus-dark-hc, both Graphite themes and both Paper themes. Match paint is readable in each.
With reduced motion on, the bar opens and closes with no animation, and steps scroll at once.
Keyboard only: Tab reaches every control in order, and the focus ring shows on each.
VoiceOver and NVDA read the bar name, every button name, and each count change, the first count included.
Check desktop with docked chat open and closed, with the wide TOC and with the tick rail. The bar never covers the TOC, the chat or the editor scrollbar.
iOS Safari and Android Chrome with a mobile user agent: the TOC drawer Find button opens the docked row. The keyboard stays up. Previous and Next stay tappable above the keyboard. The row yields to the chat pane.
No live region or role="status" appears inside .ProseMirror.
The document JSON never contains find paint or the Highlight mark after a find session.
Tests
Follow AGENTS.md §Test Policy. Each test names the one failure it pins. Prefer Cypress. Extend apps/webapp/cypress/e2e/editor/find/caret-find.cy.ts; do not add a new suite for the same surface.
Test
Failure it pins
Kind
Current index survives a whole-document replace transaction
Gap 1: the index resets to 1 when y-tiptap replaces the document
Jest unit on caret-find-plugin.ts. This is a dense mapping branch that an E2E cannot isolate
Escape from the Next button closes the bar and the selection equals the current match range
Gaps 3 and 4
Cypress
⌘G with focus in the editor steps to the next match while the bar is open
Chord not bound outside the input
Cypress
Reopen with an old query announces the first count
Gap 6
Cypress, assert the status text changes after mount
Replace all is one undo step, and every heading keeps its toc-id
Replace breaks sections or needs many undos (slice 8)
Cypress
Cypress notes:
cy.realPress and cy.realType are parent commands. A chained subject is discarded. Assert focus first, for example cy.get(INPUT).should('have.focus'), then call cy.realPress(...).
The mobile shell needs a mobile user agent. cy.viewport alone renders the desktop shell.
For scroll checks, set scrollBehavior = 'auto' before reading scrollTop, or the read races scroll-smooth.
Do not write snapshot tests, "renders" tests or tests of the animation.
Verification
For the implementer. Find the webapp port first; it is not always 3000 under make dev-local.
Open a long pad on desktop at 1280px or wider, light theme. Press ⌘F. Check the bar position, the motion and that the document does not move.
Type a word that appears in Title, in a folded section and many times elsewhere. Check the count, the paint, Enter, Shift+Enter, ⌘G and F3.
Tab to Next and press Escape. Check that the match is selected and the editor has focus. Type a letter, and check that it replaces the match.
Open a second browser profile on the same pad. Type there while the first tab shows "n of m". Check that n does not reset.
Open docked chat, then the tick rail, then a narrow window under 640px. Check the bar each time.
Repeat 1 to 3 in dark, high contrast, Graphite and Paper themes. Repeat with reduced motion on.
Run VoiceOver on macOS Safari: open, type, step, close.
Phone: set a mobile user agent through CDP Network.setUserAgentOverride. Open the TOC drawer, tap Find, type and step with the keyboard up. Then the maintainer checks on a real iPhone and an Android phone.
Make the pad Read-only as owner, and open it as a non-owner. Find works. Replace controls stay hidden (slice 8).
Profile one keystroke in the find input on a pad of about 1,500 headings. Record the result.
The maintainer approved the Find bar from a local visual preview as "good and mature". The preview is not linked, because it stays local. Each Find-bar item takes the recommendation in Open decisions.
393-position (1) → top right of the editor column, on L1, above docked chat. → Narrow windows and docked chat do not move it.
393-prefill (3) → a short selection in one textblock, else the last query. The limit is about 100 characters. → No word under the caret is seeded.
393-cmdf-scope (5) → ⌘F everywhere on the pad, the docked chat composer included, unless a modal hides the editor. → One rule.
393-escape-editor (6) → yes. → An open editor menu or popover takes Escape first.
393-caret (7) → the caret does not move while typing. It moves on a step and on close. → Fewer awareness cursor updates.
393-cap (8) → paint 200 matches. Count and step up to about 9,999. → A match after the 200th can be reached.
393-toc-signal (11) → no TOC signal. → Option A.
393-no-results (12) → neutral "No results" text and disabled arrows. → No input-error border, no shake.
393-design-system (13) → confirm (a), (b) and (c): the warning mixes, the L1 species, and no flash keyframe. → No new token.
Motion → as previewed. Open: 120ms, opacity plus scale from top right. Close: 80ms, opacity only. Reduced motion is honoured. → Existing motion tokens only. The phone row stays opacity only.
New scope: remove the Find button from the pad toolbar. → Desktop opens Find with ⌘F / Ctrl+F only. Google Docs has no toolbar Find button either. The tooltip Find in document (⌘+F) goes away.
The phone keeps its Find button in the TocModal footer, because a phone has no shortcut key. No new menu row is added. The maintainer can add a menu row later.
Phone: A phone has no ⌘F key. Until the maintainer rules otherwise, the phone keeps its existing Find entry in the TOC drawer. This is the only non-shortcut entry.
Acceptance changes
The pad toolbar has no Find button on desktop.
caret-find.cy.ts opens Find with ⌘F / Ctrl+F, not with the toolbar button.
Summary
Find already ships, but as a docked strip under the toolbar. The maintainer wants a different shape. This issue revises and refactors Find in two steps.
The UI and UX team owns the look, the micro-animations and the micro-interactions. The research team looked at a TOC match signal. It recommends no TOC signal for now (see TOC match signal).
The maintainer will add the four screenshots to this issue, because
ghcannot attach them:Maintainer request (verbatim quote)
Related issues
Mod-f, Enter / Shift-Enter, Escape, fold snapshot, 200-match cap, polite count, no replace, no regexf167903dd(2026-09-23). Restyled in7369172ec(2026-10-01)commandJump/buildPlaceRows.ts)foldedSectionsAtnext to the fold plugin. Find imports it. Coordinate the move#250 is still open, but its code shipped. The maintainer can link or close it. This issue does not change it.
Why it is "not the feature I expected". #250 built the right engine with the wrong shape. The maintainer asked for a compact floating bar, like the Google Chrome find bar. #250 built a full-width row with a bottom border inside the toolbar wrapper. Opening it pushes the document down. Replace was out of scope on purpose, and it is now wanted as a later step.
Current behaviour
All items are read from code. Nothing here was measured in a browser.
Files
apps/webapp/src/components/TipTap/extensions/caret-find/caret-find-plugin.ts(316 lines) andcaret-find.ts(commandsopenCaretFind,closeCaretFind,setCaretFindQuery,stepCaretFind).apps/webapp/src/components/TipTap/find/FindBar.tsx, gatecanOpenFind.ts.apps/webapp/src/styles/editor/_caret-find.scss.toolbar/desktop/EditorToolbar.tsx(toolbar buttondata-testid="toolbar-find", then<FindBar variant="desktop" />after the toolbar row). Phonepages/document/layouts/MobileLayout.tsx(underMobilePadTitle), opened from theTocModal.tsxfooter.apps/webapp/cypress/e2e/editor/find/caret-find.cy.ts(6 specs).CONTEXT.md§Pad tools (Find, Filter, Command jump).How it works
CARET_FIND_HIT_CAP). The count then reads1 of 200+. A match after the 200th cannot be reached.warningat 28%. The current match useswarningat 60% plus a 1pxwarningring. This is separate from the Filter highlight and the Highlight mark.scrollIntoView({ block: 'center' }). On a phone it usesscrollElementInMobilePadEditor.persist: false). Close restores the folds. The Cypress spec covers this.Shortcut
windowkeydown listener in the capture phase handlesMod-f(isModShortcut). It callspreventDefault, so the browser find never opens.aria-hiddenorinert.Mod-ffrom every focus target on the pad, the docked chat composer included.selectPadOwnsKeyboard).Mod-fwhile the bar is open focuses the input and selects the query.UI
border-b px-3 py-1.5, content right-aligned (justify-end), under the toolbar. It pushes the editor well down by about 45px (read from code, not measured).role="search"with aw-64TextInput. The count isrole="status"outside.ProseMirror. Previous, Next and Close areToolbarButtons._caret-find.scsshides the bottom toolbar while the bar has focus.Gaps and defects found in review
@tiptap/y-tiptapapplies every remote change as one replace of the whole document (_typeChanged). The plugin maps the old current match through that step, so it lands at the document end.firstHitFromthen wraps to index 0. "7 of 12" becomes "1 of 12" whenever a collaborator types. Local undo and redo take the same path. Read from code, not measured..cursor/docs/design-system.mdhas no row for the find bar or for the match colours.Proposed behaviour — Find bar
Layout
DesktopEditor.tsx(therelative flex min-h-0 min-w-0 flex-1 flex-colbox), withabsoluteplacement. It does not portal.--scrollbar-size-thin(6px) wide. The bar never covers the thumb.design-system.md§Elevation species). Frame:popoverPanelClassName, merged through@utils/twMergewith a width override only. The catalog already lists other consumers that override only size. No new radius, border or shadow.calc(100% - 1.5rem)on a narrow column. This is a proposed value; the visual preview settles it.z-50, above the dockedz-[42]band.MobilePadTitle. See Mobile.Content (left to right)
TextInput size="sm", placeholder "Find in document",aria-label="Find in document". The ghost variant lets the bar read as one box. The preview confirms that focus stays visible.text-meta text-base-content/60 tabular-nums, with a fixed minimum width. The bar does not shift when "9 of 12" becomes "10 of 12".ToolbarDivider(the screenshot 2 divider).Icons.chevronUp), Next (Icons.chevronDown), Close (Icons.close), allToolbarButton. Tooltips: "Previous match (⇧Enter)", "Next match (Enter)", "Close (Esc)".The toolbar Find button shows the
is-activerecipe while the bar is open. It setsaria-expandedandaria-controlsto the bar.Keyboard map
aria-hiddenorinertisComposing)No ⌘G or F3 binding exists in the webapp today, so these chords do not collide. ⌘G / Ctrl+G and F3 follow the browser's own find keys. Bind them only while the bar is open, so the browser keeps them otherwise.
States
/40)3 of 12, or3 of 200+at the paint capNo resultsMotion and micro-interactions
All values come from
apps/webapp/CLAUDE.md§Motion System andutils/motion.ts. No new duration, easing or keyframe.--motion-overlay-inease-out. Opacity plus scale 0.96, withtransform-origin: top right(the anchored side). Mount throughuseEntryExitTransition. The transform sits on the bar only, never on an ancestor of.ProseMirror.--motion-overlay-outease-in, opacity only.doc-content-in120ms), because the bar sits in the visual-viewport shell. The hard rule bans transforms there.tabular-numsand a fixed width keep it still.RollingNumberis for unread badges, and a reel on every keystroke is noise.behavior: 'instant'. A held Enter repeats the key, and smooth scrolls then queue and lag. An explicitbehavioralso beats the wrapper'sscroll-smoothclass, so the two do not fight. The paint swap from "other" to "current" is the visual feedback.is-activewhile open. That is the existing toolbar recipe.useEntryExitTransitionalready skips to the final state. The step scroll is instant in every mode.Accessibility
role="search"witharia-label="Find in document". It is non-modal and has no focus trap. Tab moves input → Previous → Next → Close, then on through the page.role="status"element. Render it empty when the bar mounts, then write the count. This fixes gap 6. While the user types, update the spoken text after a short pause and keep the visible count instant. The pause length is the team's call.aria-label. On touch,Tooltipdoes not name anything, so labels stay on the buttons..ProseMirror(TipTapCLAUDE.md§Editor Performance).docsplus-dark-hc. Not measured yet.Mobile
The mobile shell is user-agent gated. A narrow desktop window never shows it.
MobilePadTitle, opened from theTocModalfooter Find button. Find text in the open pad #250 rules apply: no BottomSheet, because a sheet drops the iOS keyboard.--visual-viewport-height, so Previous and Next stay above the keyboard. Keeptext-baseon the input to stop iOS zoom.Later — Find and Replace
Ship after the Find bar, as its own slices.
Scope
aria-expandedandaria-controls. It shows the Replace row, as in screenshot 4.ToolbarButtonwitharia-pressedand theis-activerecipe. Defaults stay off, so the current literal, case-insensitive search does not change.\p{L}with theuflag). A plain\bdoes not see Persian or Arabic letters.Read-only and access rules
selectDocumentEditingLocked), and in History.isVisitor(), thenopenInlineSignInDialog()).One transaction
tr.insertText.Risks
toc-id. A match never crosses a textblock, so a replace never joins or splits blocks. The heading node and itstoc-idsurvive. A test must pin this.?h=slug trail. Links useid=, so they still resolve.insertTexttakes the marks at the match start. A match that crosses a hyperlink edge can lose part of the link. Decide whether to refuse such a match or to keep the first mark.scrollToCommentAnchorfinds a text comment by its quoted text. A replace that changes that text sends the jump to the heading fallback.Mod-Shift-his already Highlight (@tiptap/extension-highlight), so the Google Docs replace chord is taken. See open decision 10.TOC match signal — research and recommendation
Question. Should the TOC show which headings hold matches?
What other tools do. Sources are vendor help pages, read on 2026-10-05.
Only Word for Windows marks headings. Most tools mark positions next to the scrollbar, or nothing.
Options
TocRowTrail. Counts are wrong past the 200 cap. A new badge recipe is lockedindicatorDotmeans attention, so it cannot be reused. A dot under a folded parent needs a roll-up rule/40means disabled in the ink ladder. Close to the banned scroll-spy washmenu-focusfrom Find fights the scroll spyConstraints that weigh in. The root
CLAUDE.mdsettled list bans the data-level type ladder, the chat-open accent bar and the scroll-spy wash. Presence overhang is rejected.TocRowTrailalready holds chat, unread and presence. On a phone the TOC is a modal drawer. It is closed while the find bar is open, so nobody would see a signal there. The cost of counting matches per section is small next to the scan itself. Clutter and meaning are the real cost.Recommendation: A, no TOC signal, for now. The new bar answers the request without it. Most tools the team checked do not mark the outline. Every visible option needs a locked design-system change or bends a settled TOC rule. If readers still ask for an overview after the new bar ships, open an Ideas discussion. Bring option F or C with a design-system ruling. Do not bring D or E. Open decision 11 asks the maintainer to confirm.
Plan
caret-find-plugin.ts). Keep the current index through a whole-document replace: on a y-sync change, anchor on the restored selection, not on the mapped position. Apply open decisions 7 (caret on step only) and 8 (cap). Select the match range on close. Add query debounce only if the large-pad measure in step 6 fails. No blocker.FindBarout of the toolbar wrapper into the editor column. Use the L1 frame, the overlay motion, theis-activetoolbar button and the fixed-width count. Blocked by 0.aria-expandedandaria-controlson the toolbar button. Blocked by 2.Scope
Out of scope
⌘glyph in Windows toolbar tooltips. Every toolbar tooltip has it; fix it in its own issue.Open decisions
Each decision needs the maintainer. Each has a recommendation.
smbreakpoint the desktop toolbar moves to the window bottom (fixed bottom-0inDesktopEditor.tsx), and the bar still stays top right. With docked chat, the bar stays in the editor area above the chat.9999+. Today a match after the 200th cannot be reached.Mod-Shift-his taken by Highlight.input-errorborder or a shake.warningmixes in_caret-find.scss(28% for other matches, 60% plus a 1px ring for the current one). The catalog row records them, and no new token is added. (b) The find bar joins the L1 members list as code that follows an existing species. (c) No current-match flash keyframe. A flash would be a new motion and needs its own ruling.Acceptance criteria
docsplus,docsplus-dark,docsplus-dark-hc, both Graphite themes and both Paper themes. Match paint is readable in each.role="status"appears inside.ProseMirror.Tests
Follow AGENTS.md §Test Policy. Each test names the one failure it pins. Prefer Cypress. Extend
apps/webapp/cypress/e2e/editor/find/caret-find.cy.ts; do not add a new suite for the same surface.y-tiptapreplaces the documentcaret-find-plugin.ts. This is a dense mapping branch that an E2E cannot isolatetoc-idCypress notes:
cy.realPressandcy.realTypeare parent commands. A chained subject is discarded. Assert focus first, for examplecy.get(INPUT).should('have.focus'), then callcy.realPress(...).cy.viewportalone renders the desktop shell.scrollBehavior = 'auto'before readingscrollTop, or the read racesscroll-smooth.Do not write snapshot tests, "renders" tests or tests of the animation.
Verification
For the implementer. Find the webapp port first; it is not always 3000 under
make dev-local.Network.setUserAgentOverride. Open the TOC drawer, tap Find, type and step with the keyboard up. Then the maintainer checks on a real iPhone and an Android phone.References
apps/webapp/src/components/TipTap/extensions/caret-find/,apps/webapp/src/components/TipTap/find/FindBar.tsx,canOpenFind.ts,apps/webapp/src/styles/editor/_caret-find.scss,toolbar/desktop/EditorToolbar.tsx,pages/document/components/DesktopEditor.tsx,pages/document/layouts/MobileLayout.tsx,pages/document/components/TocModal.tsx,commandJump/buildPlaceRows.ts,apps/webapp/cypress/e2e/editor/find/caret-find.cy.ts.@tiptap/y-tiptap3.0.9,_typeChanged(onetr.replace(0, doc.content.size, …)per remote change).CONTEXT.md§Pad tools;.cursor/docs/design-system.md§Elevation species, §State language, §Change protocol (locked);apps/webapp/CLAUDE.md§Motion System, §Floating Surfaces And Modal Scrims, §Mobile Document Pad, §TOC And Heading Actions;apps/webapp/src/components/TipTap/CLAUDE.md§Editor Performance; rootCLAUDE.md§Settled — do not re-propose.