Skip to content

Latest commit

 

History

History
375 lines (316 loc) · 92.4 KB

File metadata and controls

375 lines (316 loc) · 92.4 KB

CLAUDE.md — @docs.plus/webapp

Webapp UI systems (module layers, pad surfaces, mobile shells, overlays, motion) and document features (access, version history, TOC, comments, filters, panels).

Moved verbatim out of the repo-root AGENTS.md so it loads only when working here. Root-level rules (git policy, package manager, code quality, test policy) still live there and still apply.

Webapp UI Systems

Webapp Module Layers

  • This app is Next.js Pages Router, not App Router. pages/ is the routing surface; there is no app/ directory. Server data comes from getServerSideProps / getStaticProps, not server components.

Webapp code is split by responsibility, not by “whatever folder the first consumer lived in.” Pick the layer before adding a file; do not colocate a pure helper under @components/ui/ just because a UI component imports it.

  • apps/webapp/src/utils/ — app-wide pure code: formatters (formatCappedCount, formatTime), layout/spacing tokens (sheetBodyPadding.ts), parsers, metrics, URL helpers with no feature owner. No React, no JSX, no DOM. Re-export new shared utils from utils/index.ts when other packages might import them.
  • apps/webapp/src/components/ui/ — reusable presentational React components only: buttons, badges, inputs, PanelTabBar, RollingNumber, ScrollArea, etc. A file here must export a component (or a type props-only module tightly coupled to one). Never put standalone .ts formatters, string builders, or bleed/pad maps in ui/.
  • apps/webapp/src/components/ (root, not ui/) — cross-feature layout shells and composites: SheetLayout, SheetHeader, SheetFooter, SheetActionFooter, PanelSurfaceShell, TabbedPanelBody, BottomSheet. These orchestrate children and variant forks; they are not generic atoms.
  • Feature folders (e.g. TipTap/hyperlinkPopovers/utils/, chatroom/utils/) — logic owned by one feature (urlFieldInput, postgresErrors, collectHeadings). Promote to @utils/ only when two or more unrelated features need it with no feature-specific contract.
  • Shared types — cross-surface variants in apps/webapp/src/types/ (e.g. PanelSurfaceVariant in types/ui.ts). Feature-owned types stay in that feature's types.ts.

DRY for formatters and caps: one canonical implementation (today: formatCappedCount for display caps like 99+). Do not inline count > 99 ? '99+' : String(count) in new code; wire existing call sites when touching them.

Tailwind token maps: pad/bleed class strings that must stay JIT-literal live in a utils module (sheetBodyPadding.ts: sheetBodyPadClassName, horizontalPadBleedClass, sheetBodyBleedClassName), not on a React shell (SheetLayout). Shell components import tokens from @utils/sheetBodyPadding.

Panel stack (canonical): PanelSurfaceShell (popover vs sheet header fork) → TabbedPanelBody (tab bar + infinite scroll list) → feature panel (BookmarkPanel, NotificationPanel). PanelTabBar stays in ui/; TabbedPanelBody stays next to PanelSurfaceShell, not in ui/.

Cross-feature component imports: chatroom → hyperlinkPopovers is an established boundary for hyperlink commands/types/widgets; do not invent a second parallel URL-field stack in composer. Prefer feature-owned widgets (HyperlinkUrlTextarea) + feature or shared utils over copy-paste.

TipTap Styling

  • TipTap pad-only SCSS lives under apps/webapp/src/styles/editor/.
  • Load path: styles.scss -> components/_index.scss -> @use '../editor'.
  • Do not add parallel .scss files next to TipTap extensions.
  • Pad shell:
    • PadTitle has border-b for header-to-toolbar.
    • .tiptap__toolbar uses border-b only; no border-t against PadTitle.
    • Pad sheet top border comes from _blocks.scss for toolbar-to-editor.
    • Mobile .m_mobile .tiptap__toolbar lives in _blocks.scss.
  • Scrollbars:
    • Shared :root tokens live in globals.scss.
    • Use scrollbar-custom scrollbar-thin on .editorWrapper and TOC ScrollArea.
    • Avoid ad-hoc scrollbar styling on the pad column.
  • Document sheet border/radius/shadow: §Pad Workspace Surfaces (desktop) — not ad-hoc box-shadow in _blocks.scss.

Pad Workspace Surfaces (desktop)

Distinguish docked pad regions from floating overlays. Docked = TOC column, editor scroll well, document sheet (.tiptap__editor), docked chat (ChatroomPanelLayout). Floating = popovers, context menus, TOC drag card, extension toolbars — those keep shadow-xl / drag shadows per §Floating Surfaces And Modal Scrims.

  • Canonical CSS tokens in apps/webapp/src/styles/globals.scss (:root; dark overrides on [data-theme='docsplus-dark'] / docsplus-dark-hc):
    • --pad-divider / --pad-sheet-border → var(--color-base-300).
    • --pad-sheet-radius: var(--radius-box) — chains to the daisyUI shape tokens, which are the app-wide radius control panel. Those tokens are --radius-selector 8px / --radius-field 8px / --radius-box 10px in every theme block, and maintainer-tuned taste is 8–10px, no more, no less. Surfaces consume rounded-selector|field|box (or var(--radius-*) in SCSS) — never Tailwind literal radius classes.
    • --pad-sheet-shadow: none in light; dark only → 0 1px 2px color-mix(in oklch, var(--color-base-content) 6%, transparent).
    • --pad-well — the workspace floor (editor scroll well, TOC rail, TocHeader, and the SlugPageLoader mirrors). Light: aliases base-200. Dark: #070d18 (HC #0b1322) — deliberately below base-100 so the sheet reads raised in dark exactly as in light. Well surfaces use bg-[var(--pad-well)] / var(--pad-well), never bg-base-200 directly.
    • --shadow-overlay — the one box-shadow for every SCSS-styled floating panel (dropdown-menu, media-toolbar, hyperlink popovers, emoji picker, Dialog SCSS, TOC drag levels, Edit FAB). Black-based with a stronger dark override — never base-content mixes, which glow in dark. Tailwind-styled floating surfaces use shadow-xl. No other floating shadow literals outside the deliberate exceptions listed in design-system.md §Elevation species (drag card/deck, crinkle folds, bottom-anchored mobile bars, tooltip tier).
    • --color-media-image / --color-media-video / --color-media-audio — theme-tuned media-category accents declared directly in @theme (dark values overridden in the dark block — one name family, no alias layer). They give media-image|video|audio color utilities (chat attachment icons + comment references). Attachment cards themselves stay neutral (border-base-300 + bg-base-200/60); the category color lives in the icon/left-border accent only.
    • --resize-sash-hit (8px), --resize-sash-size (4px), --resize-sash-idle (= --pad-divider), --resize-sash-hover (= primary).
  • Light theme workspace contrast: docsplus light is true-white paper on a cool-gray well — base-100: #ffffff (sheet/panels/header/chat), base-200: #eef1f6 (well/hovers), base-300: #dce3ed (borders). Do not collapse them back to near-identical neutrals (the pre-recalibration #fafbfc/#edf0f5/#e5eaf1 trio read washed-out) — light docked UI reads via border + background step, not shadow.
  • Media-anchored overlay ink is fixed black/white by design (upload-progress scrims on thumbnails, avatar-hover camera overlay, video/gallery letterbox). Those surfaces sit on imagery, where black scrim + white ink holds in both themes. Do not "tokenize" them to neutral/base-content, which turns into a gray wash in dark mode.
  • Document sheet (_blocks.scss .pad .editor .tiptap__editor): border + border-radius + box-shadow must use --pad-sheet-* tokens. Do not reintroduce per-theme box-shadow literals on the sheet in light mode.
  • Docked chat (ChatroomPanelLayout.tsx): border-t border-base-300 only — no drop shadow. role="region" and aria-label (Chat: {heading} or Heading chat). Opacity-only doc-content-in 200ms. Never a transform on this panel (it hosts the TipTap composer). The sash lives in useResizeContainer only (open | drag | settle-to-min | settle-to-close). Same snap recipe as TOC (useTocResize): linear paint below min, inner-column fade via isContentHidden (not the panel; one isOvershoot flip per 320 crossing), snap at half min (CHAT_SNAP_HEIGHT 160), abort 160–320 settles height to 320, snap-close settles height to 0 then closeHeadingChatroom(). Both settles use --motion-overlay-in 120ms ease-out (transitionend on height, fallback MOTION_OVERLAY_IN_MS + 50). No flick. Close button stays. Never persist a below-min height. Fire chat-panel-resize-end after settle; on hide, close first so the rail does not jump. CHAT_CLOSE and the CHAT_OPEN toggle-close restore focus through focusHeadingChatTrigger (TOC chat trigger, else the rail reopen button). It tries each candidate and stops at the first one that takes focus, so a trigger in a folded subtree is skipped. It finds the reopen button by data-toc-rail-reopen, never by its label.
  • Heading-action chips (_heading-actions.scss $ha-btn-surface + expanded .ha-group tray): same --pad-sheet-border / --pad-sheet-shadow — flat in light, subtle lift in dark only.
  • TOC column layout (DesktopEditor.tsx): editor column flex-1 min-w-0; TOC shrink-0 + explicit width — no border-r on the column (double-divider with the sash). No isolate z-0 on the TOC column when the sash lives on the row (that trap buried the sash under chat). No justify-around, no width: calc(100% - tocWidth) on the editor column — that regresses a pixel gap at the TOC↔chat junction. Tick-rail behaviour (session-only, two mounts, persist, preview) lives in §TOC And Heading Actions.
  • TOC resize (useTocResize.tsx, ResizeHandle.tsx, SlugPageLoader.tsx): max width TOC_MAX_WIDTH_RATIO = 0.46 of .editor row; clamp on drag + a ResizeObserver on .editor. Vertical sash mounts on the .editor row (not inside the TOC column) at left: paintedWidth, z-[41], straddling the split (right-[calc(var(--resize-sash-hit)/-2)] on a zero-width anchor). It unmounts while isRail. While chat is open it ends at --chat-panel-height so it meets the chat gripper and does not run through the panel. Wide TOC is h-full in wide and settle-to-wide (L-shape). settle-to-rail and the rail use h-[calc(100%-var(--chat-panel-height,0px))] so the height step starts with the width tween, not at the mount swap. Do not inset the rail for the sash. Docked chat is z-[42] (same band as the rail) so the gripper hairline paints over the join. It stays the sole continuous hairline above the docked chat and below floating overlays (z-50). Presence overhang paints over the hairline; Dialog/Popover (z-50) stay above both. Do not nest the sash under TOC isolate or chat paints over it and the panel edge reads as a thicker, offset second line. Do not bump TOC to z-[51]+ (that stacks a docked rail above modals). Always draws a 1px idle hairline (--resize-sash-idle); widens/recolors to primary on hover/drag. Vertical sash hairline anchors at the split and grows into the editor column (after:translate-x-0, not centered translate-x-1/2). A centered line loses half its width under the TOC rail (z-42 over z-41). It then reads thinner than the chatroom sash, despite sharing the same tokens. Do not reintroduce column border-r or idle after:opacity-0. Horizontal chat sash: bottom-full on the panel top edge — never top-0 -translate-y-1/2 (overlaps chat toolbar and steals clicks).
  • Do not add ad-hoc box-shadow / shadow-* on docked pad surfaces in light theme to “add depth”. Depth belongs on floating species and dark sheet lift only.

Mobile Bottom Sheets And Overlays

  • The canonical mobile sheet system is apps/webapp/src/components/BottomSheet.tsx, wrapping react-modal-sheet.
  • Sheets register through useSheetStore with SheetType + SheetDataMap.
  • New mobile UI surfaces add a SheetType variant, a typed SheetDataMap entry, and an entry in SHEETS in components/BottomSheet.tsx. Each entry holds render and that sheet's props. A sheet traps focus by default (role="dialog", aria-modal, Escape closes) and needs an ariaLabel. trapFocus: false opts out and keeps the keyboard up, as linkEditor and the slash sheet do. Do not build a parallel sheet system beside it, imperative-DOM or otherwise.
  • Tiptap extension imperative-DOM popovers connect to React sheets through extension popovers config, gated by settings.deviceDetect.isMobile in TipTap.tsx.
  • A Sheet portals to document.body unless it receives a mountPoint — the library calls createPortal(sheet, mountPoint ?? document.body). Where the React element sits says nothing about where its DOM lands. A Sheet rendered inside the mobile flex shell never joins that flex layout. Moving the component that renders it moves the React element only. The library also gates its keyboard hook on isOpen && avoidKeyboard, so a closed sheet attaches no visualViewport listeners.
  • A trapped sheet moves focus into itself on open, which lowers the phone keyboard. Keyboard dismissal beyond that stays a per-sheet entry-point decision. Do not add more of it in useSheetStore or BottomSheet.
  • Keep the keyboard up for the chat composer and linkEditor. The composer emoji panel mounts inline (not as a sheet variant); emojiPicker is no longer a SheetType / SheetDataMap entry / useBottomSheet.openEmojiPicker flow. CHATROOM_OVERLAY_SHEETS is gone with it. Do not reintroduce an emojiPicker sheet variant for the composer surface.
  • Dismiss the keyboard for linkPreview and chatroom open paths: CHAT_OPEN (services/eventsHub.ts) / CHAT_COMMENT (services/chatEvents.ts).
  • A single synchronous editor.view.dom.blur() is not reliable; it can lose the race against queued ProseMirror focus.
  • Proven dismiss patterns:
    • useClipboard.ts style: collapse selection, then setTimeout(50) and editor.view.dom.blur().
    • exitDocEditModeForSheet in services/openHeadingChatroom.ts: editor.setEditable(false) plus editor.view.dom.blur().
  • editor.setEditable(false) synchronously flips contenteditable through view.updateState (verified in Tiptap 3.20; re-verify on Tiptap major bumps); a separate DOM attribute write is not load-bearing for that timing.
  • Always early-return when isKeyboardOpen is false.
  • Unified sheet shell. SheetLayout (@components/SheetLayout) is the canonical mobile sheet body. It holds a SheetHeader title that states the sheet's purpose, a scrollable flex-1 min-h-0 overflow-y-auto body, and an optional sticky footer. fillHeight toggles h-full min-h-0 vs max-h-[min(85dvh,100%)]. Form sheets pin actions with SheetActionFooter (built on SheetFooter). It holds an optional square ghost Back (btn-square min-h-12 w-12) on the left plus a primary flex-1 Apply that is deliberately heavier (btn-primary min-h-12 text-base font-semibold). Add flows show Apply full-width, and edit flows show Back + Apply. The hyperlink add/edit and link-preview sheets, plus the bookmark, document-settings, filter, and notification sheets, all adopt this shell; desktop popovers keep their inline layout. Layer placement for these shells follows §Webapp Module Layers.
  • Mobile pad has two overlay systems: ModalDrawer (checkbox + label; left TOC via TocModal) and BottomSheet (notifications, filters, bookmarks, documentSettings, pad link sheets). BottomSheet mounts outside mobileLayoutRoot in MobileLayout. Chat is not one of them — it is a flex child of the shell; see §Mobile Chat Pane.
  • Opening a sheet from inside TocModal must closeModal() before openSheet() — the drawer is z-30 and stacks above the sheet (same pattern as filters).
  • Desktop BookmarkPanel / DocumentSettingsPanel popovers (w-[28rem], bottom-end) mirror the mobile TocModal footer sheets. SettingsPanel (user account) stays separate from DocumentSettingsPanel (per-doc metadata/markdown I/O).
  • User-account SettingsPanel renders as a modal mobile takeover, not a SheetType. It is a multi-section hub with drill-in navigation — HIG/M3 place those on full-screen surfaces, and the home page mounts no BottomSheet host. One module owns that shell: components/settings/SettingsTakeover.tsx. All four mounts (HomePage, MobilePadTitle, PadTitle, EditorToolbar) render only <SettingsTakeover open onOpenChange defaultTab /> and no longer pass ModalContent mobileTakeover themselves (ui/Dialog.tsx, design-system.md §Elevation species (L2 arm)). They own open state via useSettingsModal (components/settings/hooks/), which below md pushes one history entry so hardware back closes the surface (ComposerEmojiPanel precedent). useSignOut consumes that entry before location.assign. The Documents ⋮ renders an in-tree bottom action sheet below md (gallery-overflow recipe). Never body-portal it: the modal's focus trap and outside-press dismiss must treat it as inside. Settings host pages must mount <GlobalDialog /> (HomePage included) or the rename/trash/private/sign-out confirms render nothing.
  • messageReaction is a SheetType for the mobile reaction picker (not composer emoji; do not name it emojiPicker).
  • BottomSheet paints at z-50 (floating L2), not z-10.

Mobile Chat Pane

Glossary: CONTEXT.md §Mobile pad split. Decision record: docs/adr/0002-mobile-chat-pane.md (untracked — these bullets are the durable copy).

  • Mobile chat is a flex child of .mobileLayoutRoot, not a sheet. 'chatroom' is not a SheetType. Do not put it back. react-modal-sheet positions its container with transform: translateY, so at any snap below the top the composer falls below the viewport. Measured 422px off-screen at a 0.5 snap on a 390×844 viewport. That is why the old sheet forced detent: 'full' on composer focus; keyboard avoidance was the side effect, not the reason.
  • The pane's height comes only from resolveChatPaneHeight (chatroom/utils/chatPaneGeometry.ts): clamp(ratio × shell, paneFloor, shell − title − documentFloor). One expression, every mode. The upper bound is what keeps the document's last section reachable, so no mode collapses the document to zero. expanded is not a special case — it is the ratio at 1 caught by that bound.
  • The keyboard needs no handling. The shell is sized by --visual-viewport-height, so it shrinks and both panes keep their fractions; a ResizeObserver on the shell re-runs the clamp. Never add a keyboard detector, avoidKeyboard, or a snap-to-full to this path — that was the bug, not the fix. Verified on real iOS Safari.
  • CHAT_PANE_FLOOR_PX is measured, never estimated. It is the sum of non-shrinkable furniture: grabber 20 (h-5 pill, same as the sheet header) + header 53 (border-b + pb-2 around the btn-sm Share/Notification/Close row) + feed padding 20 + composer 61 = 154, plus margin, rounded to 160. Do not restore 184 from the old 44px grabber math. The feed is flex-1 min-h-0, but its own padding does not shrink, so a floor that omits it pushes the composer off-screen. The device's bottom safe-area inset is not part of this constant — see next bullet.
  • The height the pad header reserves is measured at runtime, never a constant. ChatPane reads .mobilePadTitleShell's offsetHeight and passes it to the clamp as reservedHeight. A hardcoded value drifts. MobilePadTitle renders at 61px, not the 56px its Tailwind classes suggest, and the earlier constant silently handed the document less than its floor. Do not reintroduce a CHAT_PANE_TITLE_PX.
  • The bottom safe-area inset is measured at runtime and added to the floor, never baked into CHAT_PANE_FLOOR_PX. It is 0 on non-notched hardware and ~34px on notched, so folding a fixed value into the constant either starves notched devices or over-reserves everyone else. env(safe-area-inset-bottom) lives on data-chat-pane-body in ChatContainerMobile, the pane's true last-in-flow wrapper. The reserved space therefore stays below whichever child renders last: the composer, or the composer emoji panel. That panel is a sibling of the composer and can outgrow it. ChatPane reads that element's computed paddingBottom (resolves env() to real px; a CSS custom property would not) and passes it to resolveChatPaneHeight as safeAreaInsetBottom. Do not put the inset back on .composer-bar--mobile — the composer is only the pane's last child while the emoji panel is closed.
  • The pane's height is written to the DOM, never held in React state. ChatPane's ResizeObserver assigns style.height directly. iOS rewrites --visual-viewport-height in a burst on every keyboard step, and ChatContainerMobile is not memoized. A state write per step re-renders the feed, the composer and Virtuoso mid-animation. Same rule and same reason as the desktop drag path in useResizeContainer; only paneMode belongs in state.
  • Opening chat seeds expanded only from closed. Switching headings while the reader holds half must not yank them to expanded.
  • The rendered mode is derived, not written. useChatPaneMode promotes the pane to expanded while the composer emoji panel is open. That is because the panel opens at roughly a keyboard's height and cannot fit inside half. Deriving leaves the reader's stored mode untouched, so dismissal restores it with no snapshot. Do not implement promotion by writing setPaneMode, and do not build a registry for it. The emoji panel is the only surface that outgrows the pane.
  • Pane-internal surfaces size against the pane, never the window. ComposerEmojiPanel measures its host column with a ResizeObserver; window.innerHeight * 0.7 overshoots the pane and leaves the header and composer no room on a small phone.
  • Mode values are closed | half | expanded (ChatPaneMode in types/ui.ts). Never full — the pane cannot cover the document. Never snap or detent; those belong to react-modal-sheet and UIKit.
  • Closed unmounts the chat subtree, like the sheet did. Do not mount it at zero height. A zero-height Virtuoso satisfies its own scrollHeight > visibleListHeight + 1 settle check and fires onInitialVisible with a meaningless index. That call reaches advance_read_cursor, which recomputes unread_message_count server-side.
  • Readiness is chatRoom.editorInstance != null, because closed unmounts. openHeadingChatroom has no four-state lifecycle and no setSheetState force-write. A focus request is one pending chatRoom.composerFocusRequest for one heading. openHeadingChatroom writes it on every open: a comment or focusEditor sets it, and any other open clears it. The composer of that heading takes the request when it mounts, or at once if it already shows. It focuses only if focus has not moved since the request. destroyChatRoom clears it. There is no timed retry.
  • The pad owns the keyboard only while the pane is closed. ToolbarMobile and EditFAB both gate on paneMode === 'closed'. Keep that predicate out of selectDocumentEditingLocked — CONTEXT.md reserves the editing lock for the durable access concept.
  • Feed rows need shrink-0 (MessageCardContext). A scrolling flex column shrinks children instead of overflowing them, which flattens media tiles.
  • Pad margins that were tuned for a full-height viewport are clamped to container height: useCaretPosition's SCROLL_MARGIN and useHeadingScrollSpy's anchor/hysteresis/past-max bands. At the 96px document floor a fixed 100px margin inverts the visible band.
  • useSyncChatPanelHeight is desktop-only. Mobile must not call it. Call it from DesktopPadEditor (first sibling of TocTickRail) so its useLayoutEffect writes --chat-panel-height on .editor before the rail paints. Tick budget reads measured nav only — do not subtract chat height in JS. Drag still uses chat-panel-resize-*. useChatRailReserve returns { dragging } only so a tick does not remount viewport listeners. .editorWrapper is h-full, so a marginBottom does not clear the sheet. Document clearance is padding-bottom: calc(300px + var(--chat-panel-height, 0px)) in _blocks.scss.
  • Opening a modal sheet must not close the pane. The pane's lifetime follows paneMode alone, independent of activeSheet. The filter sheet and the rest of the registry therefore open over a pane that stays mounted underneath. BottomSheet's old teardown effect destroyed the chat the moment activeSheet changed; do not reintroduce a teardown keyed on sheet state.
  • Mode changes need no scroll-anchor. Shrinking the document scroller raises scrollHeight − clientHeight so scrollTop stays valid. Growing it clamps to the new maximum, which is where the reader already was.
  • Hardware back: the pane pushes its own { chatPane: true } history entry (composer-emoji precedent). Do not put the pane on useHistoryDismiss — that marker is shared with registry sheets and would destroy the room when a sheet closes.
  • Lifetime is paneMode + headingId. There is no chatRoom.open / openChatRoom.

Mobile Document Pad

  • iOS Safari rules live under apps/webapp, mainly html.m_mobile in styles/_mobile.scss.
  • html and body are position: fixed.
  • .mobileLayoutRoot tracks window.visualViewport through syncVisualViewportToCssVars. AppProviders only calls useVisualViewportCssSync, and that hook owns the visualViewport resize + scroll listeners, coalesced with rAF.
  • Do not skip CSS sync when height deltas are small. WebKit can emit sub-threshold steps after a large keyboard resize.
  • useVisualViewportCssSyncOnFocus listens for captured focusin on .mobileLayoutRoot .tiptap__editor.docy_editor and reruns viewport CSS sync when a final resize is missing.
  • Do not use transform: translateZ(0) on .editor.editorWrapper.
  • Do not use contain or will-change: height on .mobileLayoutRoot; WebKit can mis-paint the contenteditable caret.
  • Use scrollElementInMobilePadEditor for headings, TOC, and deep links. Avoid raw Element.scrollIntoView on doc nodes.
  • innerHeight - visualViewport.height can stay 0 while the keyboard is up. Use applyVirtualKeyboardToStore in utils/virtualKeyboardMetrics.ts.
  • useVirtualKeyboard and nudgeVirtualKeyboardOpenFromVisualViewport both call that metrics path. Listen to visualViewport scroll and resize.
  • In useEditableDocControl, never set isEditable = isKeyboardOpen on every effect. Keyboard opens before resize; only clear isEditable on keyboard close.
  • The 500ms DOM sync must not set contenteditable=false while settings.editor.isEditable is still true.
  • Read-mode contenteditable leak fix:
    • Keyboard-close store updates alone are not enough.
    • Add/keep a reconcile effect that mirrors isEditable -> false to both editor.setEditable(false) and view.dom.contenteditable.
    • Guard false-direction only and only when editor.isEditable is currently true.
    • Do not remove legacy entry-edit-mode behavior or the 500ms grace.
  • Removing the JS .focus() call from extensions/extension-hyperlink/src/interactions/clickHandler.ts is not enough; the user tap itself can focus a lingering contenteditable=true host.
  • useVisualViewportCssSync resets the page scroll. On a visualViewport scroll, if visualViewport.offsetTop > 0 and window.scrollY > 0, it calls window.scrollTo(0, 0). The reset runs for the document mode, and for landing while the mobile shell is pinned.
  • Edit entry:
    • EditFAB and double-tap share enableAndFocus() from hooks/useCaretPosition.ts.
    • FAB uses onTouchEnd and suppresses synthetic click.
    • enableAndFocus() uses editor.commands.focus() only. Do not chain Tiptap scrollIntoView() with ensureCaretVisible / scrollCaretIntoView.
    • Mobile caret scroll uses behavior: 'auto'.
    • ensureCaretVisible uses 2x rAF plus one ~300ms retry.

Floating Surfaces And Modal Scrims

Follow industry overlay UX (Material, Apple HIG, Radix/shadcn) and dim-not-lift depth principles: scrims dim the page; they never lighten it. A modal/sheet backdrop pushes content back in depth; a white fog on dark UI breaks hierarchy and reads immature.

  • base-content is text ink, not scrim ink. Low-opacity color-mix(… base-content …) is fine for muted copy, borders, and placeholders (~8–16%). Never use base-content at high opacity for full-screen modal/sheet/lightbox scrims — on dark themes it becomes a milky #fff wash. Scrim color is always black + opacity (color-mix(in oklch, black …, transparent)), theme-tuned via CSS vars not base-content.
  • Canonical scrim tokens live in apps/webapp/src/styles/globals.scss (:root + [data-theme='docsplus-dark'] / docsplus-dark-hc overrides):
    • --modal-scrim — dialogs + bottom sheets (light: black 45%; dark/HC: black 55%).
    • --modal-scrim-heavy — full-bleed media lightbox (light: black 72%; dark/HC: black 80%).
  • React exports in @components/ui/Dialog.tsx — import these; do not duplicate Tailwind/hex scrims at call sites:
    • modalBackdropClassName → bg-[var(--modal-scrim)] motion-safe:backdrop-blur-sm (centered modals: Share, Profile, GlobalDialog, chatroom confirms, etc.).
    • modalBackdropHeavyClassName → bg-[var(--modal-scrim-heavy)] motion-safe:backdrop-blur-sm (e.g. ChatMediaGallery).
    • modalPanelFrameClassName → border + shadow + bg-base-100 only (small portaled cards, e.g. composer link dialog).
    • modalPanelClassName → the frame + flex max-h-[90vh] flex-col overflow-hidden (full ModalContent shells).
  • Unified floating panel frame — popovers, context menus, and modal cards share one elevation language:
    • popoverPanelClassName (Popover.tsx), contextMenuPanelClassName (ContextMenu.tsx), modalPanelFrameClassName / modalPanelClassName (Dialog.tsx): rounded-box (the daisyUI shape token — 10px today, retunable from the theme blocks alone), 1px border-base-300, shadow-xl, bg-base-100. Never a Tailwind literal radius class (rounded-lg/xl/2xl) and no per-feature PANEL_CLASS string modules. The full popover export is rounded-box border-base-300 bg-base-100 z-50 w-[28rem] overflow-hidden border p-0 shadow-xl. Every desktop toolbar/pad PopoverContent (bookmarks, document-settings, filter, PadTitle) wraps in it. That export is deliberately the sibling of contextMenuPanelClassName, so popovers and context menus speak one floating-surface language in light and dark. SCSS floating panels use border-radius: var(--radius-box) + var(--shadow-overlay).
  • Modal height ownership — default max height lives on modalPanelClassName (90vh). Compact dialogs (e.g. profile peek) override once via openDialog / ModalContent className (userProfileDialogOpenConfig: max-h-[min(80vh,28rem)]); do not stack a second cap on the inner content shell.
  • Blur policy: Dialogs — frosted scrim (motion-safe:backdrop-blur-sm on modalBackdropClassName). Bottom sheets — same --modal-scrim, no blur (.react-modal-sheet-backdrop in document-styles.scss). Popovers / context menus — no page scrim (anchored dismiss only). Blur does not fix wrong scrim color; fix color first.
  • New overlay surfaces must pick the existing species (popover panel, context menu, modal, sheet, extension imperative popover). They must reuse that species' tokens + motion tier from §Motion System (motion v1). Do not add a third border radius or shadow stack.
  • Z-index tiers. Docked chat and the TOC column share z-[42]. The TOC↔editor sash is z-[41]. Other docked surfaces stay at z-40 or lower. All of them sit below z-50. Pad toolbars, popovers, dialogs, and extension toolbars use z-50. Global app prompt cards (NotificationPromptCard, PWAInstallPrompt) and composer link shells use z-[60]. The catalog row is in design-system.md §Elevation.

Motion System (motion v1)

  • Tokens live in two lockstep homes. CSS: :root in apps/webapp/src/styles/_entry.scss (--motion-overlay-in: 120ms, --motion-overlay-out: 80ms, --motion-panel: 200ms, --motion-region: 220ms, --motion-ease-enter: ease-out, --motion-ease-exit: ease-in). JS mirror: apps/webapp/src/utils/motion.ts (MOTION_*_MS, MOTION_DIALOG_IN_MS 180 / OUT 150, PANEL_TWEEN, prefersReducedMotion()) — the --motion-region token is CSS-only (no JS mirror). Update the mirrored tokens together; do not invent new duration/easing values per surface. User theme writes go through setPreference (View Transition); boot and rehydrate stay on applyThemeToDom.
  • Tiers. Overlays (popovers, menus, selects): 120ms ease-out enter, opacity + scale 0.96 from the anchored side; exit 80ms ease-in opacity-only (context menus and tooltips dismiss instantly). Tooltips: 100ms opacity-only, never scale. Dialogs: backdrop 150ms fade, card 180ms scale-0.96 from center, exits 150ms. In-page panels / status surfaces: 200ms. Content/region reveals: 180–240ms via the shared keyframes doc-region-in (opacity + 4px rise) and doc-content-in (opacity only), applied as motion-safe:animate-[…] Tailwind utilities.
  • Shared primitives, not bespoke wiring. Every React Floating UI surface animates through apps/webapp/src/components/ui/useOverlayTransition.ts (useOverlayTransition). Hosts that animate scale MUST pass transform: false to useFloating (left/top positioning), or the scale clobbers translate positioning. Conditional mounts that need an exit fade use apps/webapp/src/hooks/useEntryExitTransition.ts (double-rAF enter so the from-frame paints; transitionend + fallback-timer exit). Under display:none or reduced motion, transitionend never fires.
  • Hard rules. Opacity-only on/above ProseMirror hosts and sticky/visualViewport shells — no transforms (containing-block + caret hazards). Never transition-all; always an explicit property list. One-shot reveals must not replay on re-render (gate with an onAnimationEnd flag or a deliberate keyed remount; display-toggles restart CSS animations). .animate-badge-entry keeps animation-fill-mode: backwards — forwards sticks the end transform and clobbers co-existing translate utilities.
  • Reduced motion is layered, not one switch. CSS entries: motion-safe:. JS-driven motion (Floating UI transition styles, framer-motion): prefersReducedMotion() / <MotionConfig reducedMotion="user"> in _app.tsx, because inline styles beat CSS PRM rules. That config covers motion/react consumers including react-modal-sheet — motion is its peer dep. Functional delays keep their timing under PRM and drop only the motion (the skeleton pill's 1.5s anti-flash hold has a PRM keyframe override in _entry.scss). daisyUI vendor motion overrides (drawer) live in one auditable block in _daisyui.scss. Decorative infinite loops are PRM-gated; .loading spinners stay (status, not decoration).
  • floating-popover engine contract (published). hide() plays the skin's exit: removes .visible, defers root.remove() until transitionend with a 150ms fallback; destroy() removes immediately. A popover is a one-shot once opened. hide() releases the controller's ownership and nothing re-adopts, so show() no-ops afterwards and reopening means building a new popover. (hide() before the first show() is a plain no-op that keeps ownership.) updatePosition() sets transform-origin from the resolved placement. The skins in extension-hyperlink/src/styles.css and extension-hypermultimedia/src/styles/media-toolbar.css restate 120/80/0.96 + a PRM block as literals (publish boundary — webapp tokens never cross into extension CSS). The webapp re-skin in styles.scss stays lockstep. Any engine/skin change rebuilds both extensions and gets a CHANGELOG entry each. The hypermultimedia toolbar appends before flagging hm-has-toolbar (rAF) and defers removal 100ms, or its fades never play.

Slug Page Entry And Skeletons

  • The page gate is provider presence only. DocumentPage renders the layout when settings.hocuspocusProvider is set (synchronously at provider creation; nulled on destroy). Channel fetches, join_workspace, profile arrival, and sync state must never re-gate the tree — once mounted, the editor is never unmounted for the same document. joinedWorkspace means the join RPC succeeded, not started.
  • Pre-sync mounts. The layout exists before first onSynced. Any hook under useEditorAndProvider that reads the Ydoc/ymetadata once (not event-driven) must early-return while settings.editor.providerSyncing is true — wired in useInitializeNewDocument, useHandleDraftOnFocus, useCheckUrlAndOpenHeadingChat.
  • Provider lifecycle on doc switch. Recreate the provider AND the Y.Doc (useYdocAndProvider nulls providerRef in cleanup and tags the ydoc by documentId). A reused Y.Doc merges the old document's content and its needsInitialization=false into the new room. A 15s first-sync watchdog (per documentId, skipped while status is offline) sets providerStatus: 'error'; SyncErrorCard in EditorContent renders on providerSyncing && status ∈ {error, offline} and self-heals on a later onSynced.
  • Offline mirror. useYdocAndProvider pairs each Y.Doc with an IndexeddbPersistence keyed by the provider room name (documentId). The persistence is created and destroyed inside the ydoc-owning effect, with persistence teardown first, before the provider. It is never reused across doc switches. It replays local bytes before first onSynced; the providerSyncing gates above keep ymetadata consumers post-merge. The merge alone is NOT safe for never-persisted drafts. isDraft docs skip store(), so every reopen meets a fresh server default state whose needsInitialization=true can win the Y.Map merge coin-flip against the mirror's false. useInitializeNewDocument therefore also content-gates the seed on the fragment's text (never setContent over user text). Node count is the wrong predicate. The editor binding writes its empty mandatory-heading scaffold into the default fragment before the seed effect runs. Even a genuinely fresh doc is therefore non-empty-but-textless at seed time. A node-count gate blocks every first seed (browser-verified 2026-07-15). Residual accepted: a media-only never-persisted draft has no text and could still be re-seeded. Cached doc bytes stay readable by later users of the browser profile — accepted, not solved.
  • Skeleton doctrine. SlugPageLoader is server-rendered as a page sibling keyed on !hasProvider, prop-pure (isMobile, isAuthed from GSSP — never store reads, which land post-paint). Its geometry mirrors the real layout pixel-exact. Headers are h-14. ToolbarSkeleton bones match the real control sizes (select 160×32 rounded-field, buttons size-8). The document bones carry the _blocks.scss sheet styling through the real pad → editor → editorWrapper class cascade (--pad-sheet-* tokens + --pad-well floor — never hand-mirrored utility copies). The wrapper carries overflow-y-auto scrollbar-custom scrollbar-thin, and TableOfContentsLoader mirrors the TocHeader row. EditorContentSkeleton is the single bones component shared by the page skeleton and EditorContent, so the S0→S1 swap doesn't move a pixel. Verify skeleton↔real geometry in the browser at ≥1280px and as both anon and authed — narrow viewports and the anon header mask real drift.
  • Skeleton visual language. Text-line bones: bare .skeleton (base 0.25rem radius). Control-shaped bones: rounded-field. Circles: rounded-full. Media/card bones: rounded-box. Square bones use size-*, not h-* w-*. The _daisyui.scss base-radius override MUST stay scoped :not([class*='rounded-']). It is unlayered CSS and otherwise silently squares every rounded-* bone (layers lose to unlayered regardless of order). EditorContentSkeleton's root is a layout shell, NOT a .skeleton. A root slab swallows its own bones on surfaces without a bg override (mobile has no .pad .editor sheet SCSS). The status pill is CSS-delayed (doc-region-in … 1500ms both) — never JS timers, which cannot run in the pre-hydration window the pill exists to cover.
  • emoji-mart never loads at page module scope. utils/ensureEmojiData.ts owns init (idle-scheduled; ensureEmojiData(true) from CHAT_OPEN / CHAT_COMMENT). It must be the first init emoji-mart sees, or its components self-fetch data from a CDN.

Collab Provider Status

  • ProviderStatus lives in types/collab.ts: saved/synced/saving/error/offline/unauthenticated — no fake online state.
  • Of the three shared helpers below, useYdocAndProvider imports only collabSession. Other modules import providerCollabStatus and openInlineSignInDialog directly, for example SyncErrorCard, openComposerSignIn, PrivateDocumentGate and PadTitle. @utils/providerCollabStatus.ts holds predicates + getNeedsAuthCopy() — "Session expired" copy only when the auth store had a profile. @utils/openInlineSignInDialog.tsx is the one pad/document sign-in shell; do not fork through the profile modal. hooks/collabSession.ts holds auth/disconnect constants + pure helpers; the hook is the React adapter. Do not re-export those helpers from @utils.
  • Pre-sync terminal states render SyncErrorCard (render condition and self-heal in §Slug Page Entry And Skeletons); post-sync auth-stops render the shared SessionExpiredBanner (document + history parity). Keep the pre-/post-sync split.
  • ProviderSyncStatus stays outside the editor (role="status" is OK there — never inside .ProseMirror, per §Editor Performance).
  • hooks/guardProviderAwareness.ts wraps the provider's awareness right after new HocuspocusProvider(). It skips a cursor write that equals the stored value, and it skips re-sending awareness whose origin is the provider. @tiptap/y-tiptap 3.0.6+ re-sends an unchanged cursor after every remote edit (ueberdosis/y-tiptap#55). @hocuspocus/provider 3.x echoes received awareness (fixed in 4.5.0). Together they cost 1800 cursor frames where 5 were needed. Do not remove a guard until its upstream fix ships. The server's awareness metric never saw the echoes, so a quiet dashboard proves nothing.
  • A signed-in-only action checks isVisitor() (components/auth/isVisitor.ts) before it sends anything, then opens openInlineSignInDialog(). Upload, paste, import and export do this. Check before the request, not after a 401: the picker needs the click's user activation, and a failed upload leaves a placeholder and a toast. While auth loads, isVisitor() is false, so no prompt flashes for a signed-in user.

Landing Page Shell And PWA

  • Landing / is SSG with deferred anon auth via routePolicy.ts; document-styles.scss is dynamic-import only when documentShell.
  • Public legal pages /privacy and /terms share LegalPage, which owns their <Head> block and derives the canonical URL from HOME_SITE_URL. They are utility routes in routePolicy.ts: no document styles and no GA. HomeFooter links both, which Google branding verification requires. INDEXABLE_PATHS includes them. Do not let [...slugs] or isDocumentAsPath treat those paths as documents.
  • Mobile landing compact layout tracks keyboard visibility via useVirtualKeyboard({ activeMq: HOME_MOBILE_MQ, clearStoreOnDisable: true }) + global isKeyboardOpen from applyVirtualKeyboardToStore() — not slug input focus; slug-focus scroll-into-view stays in useVisualViewportCssSync only. Do not reintroduce useHomeKeyboardCompact or a second matchMedia gate when activeMq already owns the mobile breakpoint.
  • The mobile landing shell is pinned to the visual viewport (max-sm:fixed + --visual-viewport-offset-top/left / --visual-viewport-width, the same .mobileLayoutRoot pattern). The pin stops the iOS keyboard from scrolling it off-screen. useVisualViewportCssSync extends its iOS scrollTo(0,0) reset to the landing route and dropped the focus scrollIntoView centering.
  • Reject interactive-widget=resizes-content — it's a global viewport-meta change that fights the editor's overlay machinery and is unreliable on iOS.
  • HomeCollapseRegion collapses via grid-template-rows 0fr↔1fr (not max-height) with min-h-0 so the footer flex child reaches 0px, using direction-aware easing homeRegionEase(compact) (collapse → --motion-ease-exit, expand → --motion-ease-enter) + HOME_REGION_DURATION.
  • PWA Workbox + CSP: service-worker fetch() for cross-origin images/audio/video is gated by connect-src, not img-src/media-src. Default next-pwa rules can break any remote avatar/embed URL despite img-src https:. Fix in config/pwa/workbox-runtime-caching.js only. Rule 13 (cross-origin catch-all) skipping image/audio/video/script is load-bearing. The script skip stops the SW NetworkFirst from throwing an uncaught no-response when an ad blocker/CSP rejects a cross-origin gtag.js/widgets.js. GA <script onError> swallows the plain load failure. Same-origin-only rewrites on rules 3/5/6 are defense-in-depth. Do not enumerate OAuth/media CDNs in connect-src. next/image (/_next/image) stays SW-cached same-origin; raw cross-origin <img> bypasses the SW and uses browser HTTP cache only.
  • Workbox urlPattern matchers must be self-contained (next-pwa serializes via Function.toString() — no module-scope refs). Verify a matcher change under Node (the actual Next build runtime), never bun -e. Bun's Function.prototype.toString() re-serializes with const bindings inlined. A matcher that reads const isSameOrigin = …; return !isSameOrigin therefore reads back mutated, and throws a false serialization/assertion failure that Node (source-accurate) passes.
  • Deploy-boundary chunk/SW GlitchTip noise: chunkLoadRecovery.ts one-shot reload (10s sessionStorage cooldown) + instrumentation-client.ts beforeSend drops SW-lifecycle and auto-recovered chunk errors. Keep the chunk pattern lists in sync across both. Rebuild the webapp for sw.js changes.
  • PWA path guards (epic #249). Keep #257, #259, #260 and #261 open as standing "do not build" cards. Do not add capture or a camera UI to the profile photo. Do not add folders; Settings → Documents is the one library, and Home and Command jump are doors into it. Do not add visit-based Recents; Last opened stays an owner-only stamp, and the REST list sorts by it only on the owner live list. Open pads with router.push, never _blank, so launch_handler: navigate-existing holds.
  • Workbox rule 0 keeps authenticated requests out of Cache Storage. Any request with a token or Authorization header is NetworkOnly. supabase-js sends Authorization even when signed out, so every Supabase REST GET skips the cache. Keep the rule first in workbox-runtime-caching.js, ahead of the adapted defaults. The MCP status probe (/.well-known/oauth-protected-resource) is NetworkOnly right after it, so a cached answer never shows Online while the server is down.
  • Inbound share is /receive. The manifest share_target posts there. public/service-worker.js handles only POST /receive: it stashes the file and redirects. /receive is a utility route and a reserved slug, so it never becomes a pad. Do not add file_handlers or change manifest id or launch_handler.

Next Product APIs

  • Glossary: root CONTEXT.md §Product HTTP (rest-api, Health, Validate, Status, Confirm).
  • Next pages/api keeps only Health. apps/webapp/src/pages/api/health.ts is the probe. Do not retarget Traefik, Docker HEALTHCHECK, compose, CI, src/proxy.ts, or Next health rewrites.
  • Validate is on rest-api. SignInForm fetches ${NEXT_PUBLIC_RESTAPI_URL}/email/validate. Read top-level isValid. No cookies, rewrite, or Next proxy. The Next Validate route is deleted.
  • Status is not a Hono route. Heartbeat, visibility, online, and offline stay updateUser plus RLS. Tab close holds the JWT in accessTokenRef (filled by onAuthStateChange) and PATCHes Supabase REST with keepalive. Do not call updateUser on unload. Do not read authStore.session for a JWT. The service worker no longer writes Status. The Next Status route is deleted.
  • Confirm is deleted. Do not add a Hono stand-in or a Pages auth/callback page. A magic link to /{slug} still drops the hash; that is a different fact.
  • Leave apps/webapp/src/utils/supabase/api.ts even when it has no caller.

Document Features

Document Access

  • Glossary: root CONTEXT.md §Document access (Private, Read-only, PrivateAccess, PrivateGateVariant, access mutation, live seal, editing lock).
  • Private is owner-only end-to-end. Shared resolvePrivateAccess (REST slug read + WS via resolveWsAccess, fail-closed on metadata lookup failure). Anonymous → sign-in-required, signed-in non-owner → denied. Webapp PrivateDocumentGate (sign-in-required | access-denied | check-unavailable via toPrivateGateVariant) mounts before the collab provider so blocked viewers never open a WS room. check-unavailable is a degraded backend, not a decision — it retries and must never offer sign-in.
  • Private and Read-only must not both be ON. Under owner-only Private, Read-only is meaningless to non-owners (they cannot open the doc). Turning Private ON clears Read-only in the same PATCH and disables the Read-only control until the doc is public again. The same rule holds on DocumentSettingsPanel and Settings Documents DocumentRowMenu (⋮).
  • Private ON is confirm-gated. Turning Private on opens openMakePrivateConfirm → MakePrivateDialog via openDialog (Settings soft well and Documents ⋮); Private off and Read-only flips stay instant.
  • Metadata follows ownership. Title, description and keywords are owner-only on an owned document and open to everyone — signed in or not — on an ownerless one. PUT /api/documents/:docId 403s every other caller (isOpenDocument / isDocumentOwner, lib/ownerAccess.ts). The webapp mirrors the rule through canEditDocumentMetadata (hooks/) on DocTitle, MobilePadTitle and DocumentSettingsPanel, so no surface offers a write the server refuses. Those three surfaces also send the pad slug. The server reads it only when the save creates the row, so a never-persisted draft keeps the slug that reload resolves by. After documentId is known, a missing or empty ownerId is open. First-edit persist is the hocuspocus rule.
  • An ownerless document cannot be closed. resolvePrivateAccess answers sign-in-required to everyone while ownerId is null, so flipping Private there seals the row against the world with nothing able to undo it. The service ignores both lock changes on an ownerless row and logs them. Do not reintroduce the claim arm that made the flipper the owner. It let any visitor take a document nobody had claimed, and locked its real authors out permanently. Ownership handoff is its own feature.
  • Access mutation path. The owner changes flags via isDocumentOwner + useDocumentAccessMutation (Settings panel + Documents ⋮ share the hook). Live seal: REST publish → Redis doc:{id}:access (accessRealtime) → WS broadcast/close → client applyAccessStateless.
  • Editing lock. Client cannot edit when content-fork/providerStatus error, mirrored WS authorizedScope === 'readonly', or metadata Read-only for a non-owner — selectDocumentEditingLocked / useEditorEditableState keep authorizedScope in the workspace store.

Documents list Favorites

  • Glossary: root CONTEXT.md §Documents list. Owner-only. Not Bookmark (chat + hyperlink picker) and not Pin (channel messages).
  • ⋮ Favorite / Unfavorite after Duplicate, before the Private divider. The menu stays open. No toolbar star. No Favorites heading.
  • Owner live list pins Favorites first, then sort. Accent LuStar (text-accent fill-accent) after the list title and top-left on the grid paper. List and Trash rows paint LuFileText, not first-page paper. List hairline between the Favorite block and the rest. Date sorts add Today / Yesterday / Previous 7 days / Previous 30 days / Earlier headers on the rest (and on the whole list when there are no Favorites). Last opened is a sort. Owner Trash list includes DocumentGridPreview and lastOpenedAt, fills a missing preview, and omits isFavorite. The fleet omits DocumentGridPreview and Last opened. { heading: null, lines: [] } is empty. The paper omits heading when it equals Title.
  • Soft-delete keeps DocumentFavorite. Purge cascade-drops it.

Document Version History

  • Hocuspocus history uses stateless history.list / history.watch.
  • Server unicasts { msg: 'history.response', type, response } to the requesting connection. Do not use broadcastStateless. Every list and watch reply, success or failure, also echoes the request's since, version and beforeVersion. The client reads them to tell which request a failure or an Anchor answers.
  • Prisma always uses the collab room document id (document.name).
  • If the client sends a different documentId, respond history_failed.
  • history.list returns one page: { versions, hasMore, nextBefore?, beforeVersion?, anchor?, profiles, clientAuthors }. A page is 50 rows, newest first. For Show older versions, the client passes nextBefore back as beforeVersion. A first-page request may carry since. The reply then carries anchor: the Anchor for since, else the oldest row. anchor is never added to versions, so versions stays newest first. previousEntry and countVersionsAfter rely on that. anchor can repeat a row that versions holds, so never push it into historyList. An older page still carries response.beforeVersion, and the client tells an older page apart by it. Only the failure arm reads the top-level beforeVersion echo. The client drops an older-page reply whose beforeVersion is not the current historyNextBefore, so a stale reply never appends rows. An unknown history type is refused with history_failed. The list carries no document bytes; the head loads through history.watch. history.watch and history.list each have a per-connection limit in hocuspocus.server.ts (WATCH_MAX per WATCH_WINDOW_MS, and LIST_COOLDOWN_MS). Both refuse with rate-limited. clientAuthors is the per-document Yjs clientID → person table; like profiles it is fail-soft and can be empty. Client still accepts legacy plain HistoryItem[]. profiles is a uid → author side table rather than a profile per row. It can be empty and can miss a uid, so never fabricate a face from a bare id. Avatar would generate one and name a person you cannot.
  • Version rows carry trigger / triggeredBy / contributors; history.watch rows do not.
  • Block authorship answers "whose text sits here now", and two of its limits are permanent. collectBlockClientIds walks live Yjs items per top-level block; DocumentClientAuthor binds a clientID to a person only when a live socket announced it (lib/client-authors.ts). (1) Nobody can be named as a deleter. Yjs keys its delete set by the clientID that created each deleted item, and stores only a clock and a length. No setting therefore records who removed text, garbage collection included. (2) Server-side writes produce content with no bindable author. Restore, import, PATCH /content and the schema-migration rebuild all apply through a direct connection with no socket. applyContentToDoc clones every node under the server document's own clientID. Do not "fix" either by writing a DocumentClientAuthor row for a server clientID or for the acting user. The server is not the writer, and a fabricated binding turns an honest unknown into a false accusation. Surface the gap instead — the version's trigger names the operation that caused it.
  • applyHistoryItemToEditor is the single TipTap hydration path.
  • loadingHistory clears only after successful apply, not merely after a network response — that rule governs the read ops. A history.revert ack or failure clears it on the network response, because a revert never hydrates the history editor.
  • useHistoryEditorApplyWhenReady applies when the editor mounts after data arrives.
  • While pendingWatchVersion is set, do not re-apply stale activeHistory.
  • Late history.list must not reset pending watch state.
  • Only a first-page list can be silent. A frame that carries beforeVersion never clears or consumes silentListRefresh. Apart from rate-limited, a watch failure belongs to compare only when its echoed version equals pendingCompareVersion. A watch failure or null watch whose echo names neither slot is dropped. A reply with no echo keeps the old path.
  • On history_failed, clear pendingWatchVersion so the next watch is not dropped — for the read ops only. A history.revert failure clears loadingHistory alone; it never owns the watch slot, and clearing it would strand a watch still in flight.
  • Restore is a server write through the history.revert stateless op, never a client setContent. The restored bytes reach every client over ordinary y-sync, so the ack handler confirms and exits the history view rather than applying content. Compare rides a second watch slot keyed on pendingCompareVersion; the A side is held whole as compareBaseItem because list rows carry no data. Desktop picks A from the sidebar. Mobile picks A from the Compare with sheet; the version drawer still picks B.
  • document:saved while the history view is mounted triggers a debounced silent re-list. It must not blank the sidebar on failure. The list-failure arm also rewrites the URL, so a background refresh that fails has to return before it clears anything. A first-page re-list keeps the older pages the reader already loaded, but only when the new page reaches the old head. Otherwise the page and its cursor replace the list. A list refusal or failure while rows are loaded keeps the sidebar.
  • History paints the latest live Pad title change notice above the editor (getLatestPadTitleChange). It is a workspace-chat title_changed row, not a History version. History uses the snapshot username. Chat paints the live username. A rename does not mint a Documents row.
  • Shareable revision URLs use same pathname/query plus #history?version=<n>, where <n> is HistoryItem.version.
  • A #history?version= link below the loaded pages fetches older pages first, HISTORY_LIST_GAP_MS (300 ms) apart and at most DEEP_LINK_PAGE_CAP (40) pages. Only then is it judged unavailable. The gap clears the history.list cooldown (LIST_COOLDOWN_MS), so do not remove it. loadingHistory stays true during the walk. A rate-limited page is retried once. A second refusal or any other failure ends the walk, and the target resolves from the loaded rows.
  • While older pages exist, the sidebar header reads N+ versions, and the desktop sidebar always virtualizes. Otherwise one Show older press swaps the list tree, and the sidebar jumps.
  • URL helpers live in components/pages/history/historyShareUrl.ts: parseHistoryHash, buildHistoryShareUrl, replaceHistoryHashVersion.
  • Without #history?version=, the sidebar treats the latest version as active.
  • Every entry to history page, including editor <-> history navigation, must resync sidebar selection from current hash + store.

TOC And Heading Actions

  • TOC code lives under components/toc/.
  • Toc vocabulary is canonical. Keep the Toc* / toc__* / TOC_* names and the components/toc/ home — never rebrand to Document Outline / Outline* (paths or exports). Deepen the feature in place under toc/ (structure + interfaces); do not rename/move the folder. Prefer a shell-facing public barrel (TocDesktop / TocMobile / TocHeader, optionally TOC_CLASSES) over re-exporting presence, scroll-spy, unread, or move helpers from toc/index.ts.
  • Desktop tick rail is session-only. Glossary: root CONTEXT.md §Pad outline (Wide TOC, Tick rail, Painted width, tocWidth). Look: .cursor/docs/design-system.md §TocTickRail. TOC_RAIL_WIDTH is 32. DesktopEditor deep-imports TocTickRail and mounts it in the .editor pad row so docked chat can go full width. Wide TOC stays a sibling of the editor+chat column. Two mounts are the product. Do not unify them into one parent. Do not export TocTickRail from toc/index.ts. Mobile TocModal is unchanged. Do not add SideContinuum.
    • Persist docsy:toc-width is the last committed wide width only (> TOC_MIN_WIDTH 240). Never persist 32 or 240. A stored value at or below 240 is missing and becomes 320. Refresh, document change, and workspace change restore wide TOC. SlugPageLoader stays on the persisted wide width.
    • Mode union in useTocResize: wide | rail | drag | settle-to-rail | settle-to-wide. stepTocRelease in that hook is the only release step (cancel, snap, abort, commit). Snap line TOC_SNAP_WIDTH 120. During drag, tocWidth does not follow the pointer. Paint follows the pointer. lastWideWidthRef only ratchets up. Do not copy tocWidth onto lastWide on every wide render. On snap, persist that remembered wide width (default 320 if the memory is only the 240 floor) and paint 32. Abort 120–240 paints 240 this session only; it does not overwrite lastWide or storage. Wide release commits intended. Persist effect writes tocWidth when that value commits. It skips rail, settle-to-rail, and drag. Abort does not change tocWidth, so it does not write. Clamp must not restore paint while the column is still on the 240 abort floor. Hydrate must not overwrite rail paint with the stored wide width. settle-to-wide must not arm transitionend while paint === 32.
    • Sash unmounts while isRail. Reopen is the top sidebar button (Icons.tableOfContents → LuPanelLeft). openWide keeps last-wide and paints the clamp (320 when last-wide is missing). Geometry and z-index live in §Pad Workspace Surfaces. Do not inset rail height for the sash.
    • A short tick stack sits in the middle of the live rail (railStackOffset(viewport.height, stackH)). Chat open and close tween that offset with --motion-panel. Sash drag has no tween. First paint skips the tween (slideStack). Do not lock the stack to the window mid-line.
    • Rail spy and chat-open ticks are bg-primary. aria-current on spy only. Wide TOC spy stays menu-focus / base-300. That split is a maintainer correction — do not paint the rail spy as base-300.
    • Preview is an L1 card (popoverPanelClassName), not the house Tooltip. Clone the live section from editor.view.dom (not document.querySelector — rail ticks also have data-toc-id). Do not mount a second TipTap. Do not serialize the schema (hypermultimedia NodeViews become placeholders). Title text-sm; body text-xs line-clamp-3; every heading in the card shares !text-sm. First media hoists into a flush h-24 cover well. If the clone is the media root, do not also append it to the body. Strip id and data-toc-id. Fit media; never drop a block because scrollHeight is large. DaisyUI skeleton only while the host is empty — never hidden the clone behind ready. Open after PREVIEW_OPEN_MS (80) on the same tick while inRail. A focused tick holds the preview only when it matches :focus-visible, so a mouse click does not hold it open. Hide on scrub, leave tick, or cross the card. pointer-events-none. closeMs 0. Slop still grows neighbour ticks; slop does not keep the preview open.
    • Killed deepenings: width-contract file, column adapter, delete chat-panel-resize-* events, persist the rail, barrel-export TocTickRail, delete lastWideWidthRef, migrate settle onto useEntryExitTransition, inset rail height for the sash, house Tooltip for the section preview.
  • Keep tocClasses.ts in sync with styles/components/_tableOfContents.scss.
  • --color-docsy equals var(--color-primary) in both @theme and :root; it tracks DaisyUI light/dark/high-contrast themes.
  • Heading widgets live in TipTap/extensions/HeadingActions/plugins/.
  • Heading-action styling lives in styles/components/_heading-actions.scss.
  • Sheet-edge dock for heading chat + body-selection comment (half in / half out on the pad outline). Heading hover uses .ha-wrap (PM decoration on each h1–h6[data-toc-id]). Non-heading text selection uses desktop-only selectionChatPlugin (HeadingActions/plugins/selectionChatPlugin.ts), which appends a direct child of .tiptap__editor with classes ha-comment-btn + ha-selection-comment-dock (HEADING_ACTIONS_CLASSES.selectionCommentDock). Both species must straddle the same vertical sheet border. The chip center sits on the 1px outline, not floating in the prose gutter or fully outside the sheet.
    • Shared horizontal math lives in _heading-actions.scss as $ha-sheet-border-straddle-x: translateX(calc(var(--tiptap-inline-pad-end) + 0.5px + 50%)) where 50% is half the chip width ($ha-hit-size / size-11 = 2.75rem).
    • Anchor reference differs by mount point: .ha-wrap uses right: 0 on the full-width heading (prose column right edge). .ha-selection-comment-dock must use right: var(--tiptap-inline-pad-end) on .tiptap__editor. That is the same prose edge, because right: 0 on the sheet is already one pad-end inset. Reusing the straddle transform from the wrong anchor double-counts pad-end and misplaces the chip. Vertical: the selection dock sets top in JS from selection from/to viewport coords vs .tiptap__editor.getBoundingClientRect().
    • When changing pad sheet outline, editor horizontal padding, or chip size, update together: --tiptap-inline-pad-end on .tiptap__editor in _blocks.scss (must stay in lockstep with EditorContent px-6 / sm:p-8), $ha-hit-size + $ha-sheet-border-straddle-x, .editorWrapper padding-inline-end straddle gutter in _blocks.scss, heading padding-right: $heading-chat-gutter, and verify both heading hover + body selection in the browser.
    • Do not mount the selection chip on ProseMirror's parent or use inline right: 9px — that regresses to selection-adjacent placement. overflow-x: visible on .editorWrapper is load-bearing so the outside half paints. History read-only hides both .ha-wrap and .ha-selection-comment-dock (_blocks.scss .history_editor).
  • $ha-hit-size is shared with plugins.
  • $ha-group-has-unread owns the DRY :has() selector for unread tray visibility.
  • _unread-badge.scss only styles [data-unread-count] on .ha-chat-btn (the notification bell uses the React <UnreadBadge>). Do not add .toc__chat-trigger or .ha-group rules there.
  • TOC uses React UnreadBadge only. Shared count math is resolveUnreadCount (utils/unreadDisplay.ts); ProseMirror widgets sync via syncHeadingWidgetUnread (.ha-chat-btn only — never TOC .toc__chat-trigger). UnreadBadge keeps RollingNumber (Safari-safe em-reel translate3d — never daisyUI .countdown, never strip the roll for plain capped text).
  • Active chat icon uses toc__chat-icon--active with fill: none; Lucide icons are stroke-based. Active accent is var(--color-docsy) on both desktop and mobile — never text-accent. TOC visual recipes live in .cursor/docs/design-system.md §Table of contents.
  • When nested ul.toc__children lives under the parent li, folded subtrees hide with &.closed > .toc__children { display: none }.
  • Fold state still comes from editor state, not CSS alone.
  • TOC data path:
    • components/toc/hooks/useToc.ts builds in useLayoutEffect plus a 200ms retry so the first rail paint is not empty; setItems([]) when !editor || editor.isDestroyed; skip setItems when id / level / text / open match; throttle later heading-driven rebuilds with lodash/throttle;
    • flat heading list converts to recursive NestedTocNode through buildNestedToc;
    • TocDesktop / TocMobile own roots;
    • useHeadingScrollSpy.ts debounces scroll/active-heading work with lodash/debounce.
  • TOC row = daisyUI menu item. ul.toc__list.menu owns padding / hover / nest rails (stock — no TOC ::before strengthen); desktop doc-title is the sticky first <li class="toc__header"> wrapping the same TocRow as headings. Chat-open → soft-primary menu-active (--menu-active-bg/fg = opaque color-mix(primary 14%, var(--pad-well)) / primary). That pair is scoped to .toc__list.menu in _daisyui.scss so the pad-well mix base can’t tint app-wide menus — never transparent mix, never daisyUI’s charcoal neutral. Scroll-spy → menu-focus (base-300 only — not brand). TocRow is three grid children (leading | title <a> | TocRowTrail) — do not reintroduce a flex override or absolute trail + spacer math. Fold is a plain <button> chevron (not btn-ghost) and always visible. Desktop .toc-drag-handle idle-hidden until li hover only (:focus-visible for keyboard — not menu-active / menu-focus). .toc__chat-trigger idle-hidden until li hover with the usual active/focus/unread exceptions (menu-active / menu-focus / :focus-visible stay visible; unread badge keeps chat on). Mobile always shows both (grip is desktop-only in the tree). Grip is an absolute overhang chip (position: absolute; left: -22px in _tocDrag.scss, centered with translateY(-50%)); the frame is one family (base-300 border + micro shadow; fill mirrors row). .tableOfContents / .tiptap__toc / .toc__row stay overflow: visible. The .toc__scroll viewport is column-width with scrollbar-gutter: stable (preserveWidth), so the native scrollbar stays INSIDE the TOC wrapper. Do NOT widen it: calc(100% + 2rem) pushed the scrollbar out over the editor. Do NOT set the scroll viewport itself to overflow: visible to force avatar overhang. That silently disables the column's own vertical scroll (browser-verified: a tall outline stopped scrolling entirely). The outer .tableOfContents / .tiptap__toc / .toc__row overflow-visible is fine; the scroller is not. A vertical scroll container clips horizontal overflow at its own box, and the scrollbar always renders at that box's edge. "scrollbar inside the column" and "presence beyond the column edge" are mutually exclusive inside one scroller. Presence therefore lives in-flow in TocRowTrail, right of the chat trigger, right-anchored (<AvatarStack anchor="right">, fixed right edge / grows left). It is nudged to the column inner edge with translate-x-3 (visual only — chat + mobile flow untouched). Beyond-column overhang was evaluated and rejected (settled — do not re-attempt). The overflow: visible hack breaks scroll. A JS overlay outside the scroll clip does per-row getBoundingClientRect on every scroll frame (forced reflow — too heavy). CSS anchor positioning is the only zero-JS overhang, but Firefox doesn't ship it (2026). In-column right-anchored presence is the shipped answer (matches Google Docs / Figma / Slack / Notion — presence stays in the panel). .tableOfContents is z-[42] / sash z-[41] so the row/grip paints above the hairline without climbing the floating overlay tier (z-50). Sash hit still works on the editor half of the straddle. menu-active fold/chat inherit primary ink (soft tint — not primary-content; drag grip stays hidden unless hovered). menu-focus = solid base-300; row hover = same family but darker (color-mix(base-content 14%, base-300)) — do not collapse hover into focus. Doc-title and heading chat glyphs share the same size (Icons.chatroom / TocRowTrail iconSize={20}). Drag overlay SCSS: _tocDrag.scss. TOC ScrollArea uses preserveWidth={true} (stable gutter, scrollbar inside) + hideScrollbar.
  • TOC chat trigger is <button type="button"> for keyboard + a11y; drag handle, title link, and chat are siblings (never nest <button> inside <a>). usePresentUsers MUST filter profile?.id and keep per-channel useShallow equality. TOC focus/chat-active MUST use boolean Zustand selectors (focusedHeadingId === item.id, chatRoom.headingId === item.id). useTocAutoScroll must subscribe outside the outline React tree (store.subscribe), never via a parent useStore that would remount the list.
  • TOC menus + drag. Right-click/long-press menus share @components/ui/ContextMenu primitives (contextMenuPanelClassName, ContextMenuRow, ContextMenuDivider, MenuItem); mobile uses the same row/divider shell via ContextActionsMenu. The panel is Tailwind flex flex-col list-none, not daisyUI menu — .menu only styles direct button/a children, so <span> rows need group/cursor-pointer/group-hover:bg-base-300. Dividers are empty <li role="separator"> with bg-base-300 h-px my-[4px] on the li — never inner divs, daisyUI divider, or border-t under .menu. The drag grip is the absolute overhang chip with hover-only desktop reveal (chat keeps active/focus/unread exceptions). The level picker is shared TocLevelPicker (H1–H6). The drag card's DragOverlay portals to document.body, because the TOC scroller's fade mask clips and fades it. Flat-schema moveSection moves the whole section — drag E2E order assertions belong on h2[data-toc-id]. The TOC rebuild gate is transactionRequiresTocRebuild in components/toc/utils/headingTransaction.ts.

Heading Fold Crinkle

  • Crinkle uses widget decorations with data-fold-phase for CSS animation.
  • Unique Decoration.widget keys per phase force ProseMirror remount so animation fires:
    • fold-${id}-folding;
    • fold-${id}-unfolding;
    • fold-${id}.
  • Width spans the full sheet with margin-left/right: calc(-1 * var(--tiptap-inline-pad-end)).
  • Timing uses SCSS variables $crinkle-fold-duration and $crinkle-easing, not CSS custom properties.
  • Decoration.node on heading-section was removed; animations live on the widget.
  • Strip count uses MIN_FOLD_STRIPS, MAX_FOLD_STRIPS, and CONTENT_HEIGHT_PER_STRIP in heading-fold-plugin.ts.
  • If MIN_FOLD_STRIPS === MAX_FOLD_STRIPS, strip count is fixed regardless of content height.

Document Comments

  • Document comments are first-class messages rows with metadata.comment holding CommentAnchorV1 (v:1, text|media kinds). A comment with no attachment has type = 'comment'. With an attachment, type is text when it has a caption, else the media type from resolveOutgoingMessageType. CommentReference therefore checks metadata.comment, not only the type. File map: types in types/comment.ts, anchor helpers in services/commentAnchor.ts, preview parsing in utils/commentPreview.ts with the TipTap adapter in mediaPopovers/buildCommentPreview.ts, reference framing in utils/commentReferenceTheme.ts.
  • Send via sendCommentMessage; the composer draft is CommentMessageMemory (Profile | null user). publishDocumentComment → CHAT_COMMENT (services/chatEvents.ts) → openCommentComposer in services/openHeadingChatroom.ts — distinct from CHAT_OPEN browse via openHeadingChatBrowse.
  • Pad comment entry: heading hover/selection widgets + the media toolbar (mediaComment.ts) → publishDocumentComment. Desktop body-selection chip positioning is coupled to the sheet-edge dock geometry — see §TOC And Heading Actions.
  • Jump-to-doc targets the anchored content, not the heading. ReferenceJumpButton.onJump → scrollToCommentAnchor(anchor) (utils/scrollToCommentAnchor.ts) finds the media node by node_type+src, or the text run by content, and centers it. It ring-selects a media node with a PM NodeSelection, which is PM-managed, so there is no foreign DOM mutation and no node-view reload. It calls no editor.focus(), so no iOS keyboard opens. It only falls back to scrollToHeading(heading_id) when the editor is unmounted or the target is unresolved. Do not revert to heading-only scroll.
  • Feed/composer framing: shared ReferenceJumpButton — reply = border-l-info + Icons.reply; comments = Icons.comment with border/surface/emphasis from commentReferenceTheme(anchor) (text selection → primary; media → per-MediaNodeType brand tokens in MEDIA_COMMENT_META, e.g. YouTube #FF0000, X base-content). Media preview goes through the shared CommentPreviewVisual at components/CommentPreviewVisual.tsx; CommentAnchorPreview stacks thumbnail above label/excerpt (the parent passes theme; text anchors ignore it). Feed cards stay compact and non-interactive (no inline players in Virtuoso).

Document Filters

  • Active filter terms live in URL path segments after the doc slug (/docSlug/term1/term2?mode=and). useApplyFilters bridges router.query.slugs + mode → the HeadingFilter PM plugin (applyFilter / clearFilter) and mirrors chips in settings.editor.filterResult.
  • Apply when router.isReady, the editor instance exists, !loading, and !providerSyncing. Never poll the DOM for .pad.tiptap … [data-toc-id] readiness. The mobile pad is mobileLayoutRoot tiptap without .pad, so that selector silently blocks mobile sheet apply.
  • All shallow filter URL math belongs in @utils/filterRoute (append/remove/reset/mode, deduped segments); shallow router.push uses pathname+search+hash via shallowPathFromAsPath, not a full origin URL.
  • Typeahead suggestions use PM matchSections in filterTypeahead.ts (same section rule as the filter engine), not a heading-only DOM scan.
  • Surfaces: desktop FilterPanel popover. Mobile TocModal footer → filters sheet (FilterSheet reuses FilterPanel with variant="sheet", dismiss via useDismissPanel after apply/clear). Active chips + mobile-visible Reset live on FilterBar (MobilePadTitle). Active-state indicators sit on the desktop toolbar and TocModal footer (filter-active-indicator*). No filter-mode body-class toggle — filter state is URL-only.

Bookmark And Notification Panels

  • Shared stack: PanelSurfaceShell → TabbedPanelBody + PanelPopoverHeader, PanelFeedItem, useDismissPanel, useFeedItemExit (80ms MOTION_OVERLAY_OUT_MS exit before row removal).
  • Mark-as-read/remove/archive: optimistic tab badge + header count first, API in parallel. The exit micro-animation must show and tab/header badges must drop immediately, not after the API round-trip or a global loading lock. Notification readDedupe skips the double realtime decrement.
  • Notification store: setNotifications replaces (never prepends); the pagination effect must not depend on the notifications Map.
  • Bookmark tab badges come from get_bookmark_stats: unread = non-archived + unmarked (In Progress only); get_user_bookmarks is tab-scoped (p_marked_as_read / archived flags). Badge mutations use ±1 (adjustBookmarkTabCount / decrementNotificationCounts), never loaded list length.
  • Documents list Favorite is a different mark — do not reuse this panel or message_bookmarks.
  • The sheet variant dismisses on View via closeSheet(); BookmarkItem "View in chat" dispatches CHAT_OPEN only — no closeSheet() / activeSheet checks (NotificationItem parity). Feed modules: useBookmarkPanelFeed, useNotificationPanelFeed.
  • The mobile sheet variant adds swipe-between-tabs via usePanelTabSwipe wired into TabbedPanelBody (sheet-only; the desktop popover path stays if (!isSheet) return body). The gesture is finger-follow translateX, with commit past ~22%/48px, rubber-band at edges (no wrap), and scroll lock during the horizontal drag. It uses transitionend + fallback at MOTION_PANEL_MS, and reduced-motion skips transforms. Keep key={activeTab} stable (gate the fade by class, not key) so a mid-swipe horizontal lock never remounts the list.
  • View on a content_change notification opens History with compare already painted, and the window is Last left → now. The From end is that reader's Last left for that document (CONTEXT.md §Document changes), never notification.created_at. That instant is when the worker minted the carrier, after the persist debounce, so it starts after the first save the reader missed. Read the documentId from notification.channel_id, which holds it verbatim (packages/supabase/scripts/10-func-notifications.sql:654 and :666). action_url supplies the route only: its later segments carry the reader's filter terms. Do not block the click on that read. useArmPendingHistoryCompare waits for the History list, so a late value still arms. It arms from the anchor of a list reply that echoed this since. It sends a silent list with since when no Anchor arrived, no loaded row sits at or before Last left, and older pages exist. It waits while another silent list is in flight, then sends after the 300 ms HISTORY_LIST_GAP_MS. It retries a refusal once. After a second failure it clears pendingCompareSince and opens no compare.
  • pendingCompareSince must survive resetHistorySessionForMount. Reset clears every other history field and leaves this one alone (resetHistorySessionForMount in components/pages/history/clearHistorySession.ts, pendingCompareSince in stores/history.ts). A View from a pad that is already open remounts the history session after the value is set. Clearing it there makes the button do nothing. useArmPendingHistoryCompare (components/pages/history/hooks/useArmPendingHistoryCompare.ts) consumes the value once and then nulls it.

Overlay Hash Routes

  • An overlay hash is a one-shot instruction, never a view. #notifications and #settings?tab=<TabType> open a surface and are then cleared, so closing a panel never writes to the URL. One module parses them, apps/webapp/src/hooks/useHashOverlay.ts, and it knows those two routes only. #history is the opposite and stays a view: parseOverlayHash returns overlay: null for it, so clearOverlayHash can never drop it. There are three callers today: email links, the own empty profile card in UserProfileDialog (#settings?tab=profile), and Command jump (components/commandJump/). The app serves every path from one catch-all and has no /notifications or /settings route. Do not add a route table or a generic hash router for a third value.
  • updateAppUrl's replace arm carries window.history.state forward, and clearOverlayHash is now only a guard. Next reads e.state on popstate, so a null there makes Back rewrite the address bar instead of routing. The replace arm rewrites the CURRENT entry, so its state must survive. The push arm still writes null on purpose: it mints a NEW entry, and copying the state would give two entries one index. Three tests in historyShareUrl.test.ts pin the replace arm.
    • Because that write is now safe to share, clearOverlayHash is a route guard plus clearHistoryHash(). The guard is its whole reason to exist: clearHistoryHash drops ANY hash, and #history is a view that must survive.
    • A bundle barrier was claimed here once and it was WRONG. Do not restore it. The original trace started at src/pages/index.tsx and never followed src/pages/_app.tsx, which wraps every route. Re-traced 2026-09-07 from _app.tsx: 44 distinct static chains already reach the editor stack, because <BottomSheet /> mounts on every route. Removing any one edge changes nothing. There is no bundle barrier at this seam.
  • Five surfaces consume the hash, and each opens its own local state. Settings: components/pages/home/HomePage.tsx, TipTap/pad-title-section/PadTitle.tsx, TipTap/pad-title-section/MobilePadTitle.tsx. Notifications: the PadTitle Popover and MobilePadTitle's openSheet('notifications'). Three near-identical effects beat one shared hook here, because the shared version needs a setter, an action and a keyboard-blur hook for three callers.
  • Controlling the PadTitle notification Popover disables its click-to-open. components/ui/Popover.tsx builds useClick(context, { enabled: controlledOpen == null }), so an open prop silently kills the bell. The bell therefore carries its own onClick toggle, which survives because PopoverTrigger merges the child's props. Verified in a browser, both directions. Never "simplify" that handler away, and do not edit Popover.tsx to fix it — other call sites rely on that guard.
  • An overlay hash and #history cannot co-occur, at two levels. One hash string names one route, so the URL cannot carry both. And neither pad title mounts in history view: DesktopLayout.tsx returns <DesktopHistory /> before <PadTitle />, and MobileLayout.tsx swaps in <MobileHistory />. So clearOverlayHash and normalizeToPlainHistoryHash can never write the hash in the same commit.
  • All three settings call sites gate on the signed-in profile and keep the hash when it is absent. A signed-out visitor lands on Home, nothing opens, and the hash survives so signing in still lands them on the right tab. There is no signed-out settings path, and building one is not planned.
  • SettingsPanel seeds showContent on whether a tab was NAMED, never on which tab. Below the md breakpoint the content pane is hidden until that flag turns true, so a deep link that named a tab would otherwise open the tab list on a phone and cost one more tap. defaultTab is optional for that reason: it shipped once as defaultTab !== 'profile' with 'profile' as the destructuring default, and that sentinel could not tell #settings?tab=profile from a bare #settings. The parser accepts every TabType on purpose. The pane keeps its back button, so the list stays reachable.

Settings Takeover And The Owner List Cache

  • components/settings/SettingsTakeover.tsx is the only owner of the settings modal shell, and it gates on the signed-in profile itself. Before it, four mounts rebuilt the shell and disagreed on three fields. EditorToolbar gated only its trigger and left the <Modal> ungated, so a session expiring while Settings was open left a signed-out shell that would not close itself. The other three wrapped the whole modal in {user && (. The shell now carries if (!user) return null, so no mount can reproduce that.
    • The aria-label is the dialog's ONLY accessible name. SettingsPanel uses a plain <h2> rather than ModalHeading, so ModalContent's labelledBy fallback resolves to undefined. Removing the label in favour of the heading leaves the dialog unnamed.
    • size on a takeover modal is a desktop-only knob. modalPanelTakeoverClassName sets max-md:max-w-none, so the value never paints below md. That is exactly why a 4xl versus 5xl split survived in four copies without anyone noticing.
  • components/settings/hooks/documentsCache.ts owns every write to the Owner live list and Owner Trash list caches. Never put a raw setQueryData back into a settings component. Before it, about 27 raw cache calls across four modules each hand-wrote the same page reshape, and their rules had already drifted apart. utils/documentsPageCache.ts holds the pure reshape it calls, and both are tested.
  • The Owner live list pages by ROW OFFSET, never a page index times a page size. nextDocumentsOffset is the rule. A page index cannot see an optimistic remove, so after a delete it re-asks for an offset the server list has already moved past, and one document disappears until reload.
  • The patch-versus-invalidate rule. A remove or an in-place patch keeps the loaded rows equal to a server prefix, so it patches. An insert whose server position the client cannot know must refetch instead. Duplicate is that case and it must NOT patch. duplicateDocument never writes lastOpenedAt, and documents.service.ts sorts that column nulls: 'last', so under lastOpenedAt_desc a copy belongs at the end, past the loaded window. A patch put it near the top and the refetch then removed it, so the row flashed and vanished. addDuplicate() takes no argument for that reason.
  • A debounce guarding a NETWORK write must flush() on unmount. A debounce guarding LOCAL state must cancel(). NotificationsSection.tsx cancelled a preference write, and ModalContent unmounts its children on close, so closing the panel inside 500 ms discarded the patch silently. Its line is the only caller of updateNotificationPreferences in the webapp, so nothing rescued it. DocumentsSection.tsx and ProfileSection.tsx both cancel correctly, because both guard local state. A sweep that unifies the three would reintroduce the data loss.
    • The cleanup body must stay braced. The flushed function is async, so an expression-bodied arrow hands React a Promise as a cleanup value.
    • Sign-out still drops a pending patch, and keepalive is NOT the fix. That was recorded here once and it is wrong. useSignOut awaits signOut() first, so useOnAuthStateChange clears the profile, SettingsTakeover hits if (!user) return null, and only THEN does the unmount flush fire. By that point the session is gone, and update_notification_preferences opens with auth.uid() and raises 42501 unauthenticated (packages/supabase/scripts/07-0-notifications.sql:41-53). No transport option can save a write the database refuses. The real fix is ordering — flush while the session is still live — and it needs a seam that does not exist today. Window is under 500 ms. Not fixed.
  • The Connected apps tab shows only what docs.plus can vouch for (apps/webapp/src/components/settings/components/ConnectedAppsSection.tsx). Any app registers itself, so its name, logo_uri and client.uri are self-declared. Never load logo_uri, since the fetch sends the viewer's address to that host. Never link client.uri. Clean the name with apps/webapp/src/utils/displayClientName.ts, and render it in <bdi>. Trust comes from the registered redirect URIs (GET /api/connected-apps/redirects) through apps/webapp/src/utils/appTrust.ts, which the consent page shares. An exact known callback (Claude, ChatGPT) shows no badge, and its name and brand mark come from that table. A loopback redirect shows "On this computer". Everything else shows "Unverified app". Never match on the name, and never on an origin suffix. DCR gives one client per fresh connection, so rows group by trust state plus name, and Disconnect revokes every client in the group. groupApps in apps/webapp/src/utils/appTrust.ts does this grouping. appTrust.test.ts pins the lookalike and borrowed-name rules. A borrowed "Claude" name therefore gets its own unverified row. Two rows can share a name. So for a row that is not known, the Disconnect aria-label and the confirm dialog add "(unverified)" or "(on this computer)". fetchRedirects in apps/webapp/src/components/settings/hooks/useConnectedApps.ts stops after 8 s, and any failure answers no redirects. Every row then shows "Unverified app" and keeps Disconnect. Do not turn that failure into a query error, which would hide every Disconnect button. Show no "last used" column. It would be per-person data, and it needs its own short expiry and a maintainer ruling first.
  • utils/splitHashRoute.ts owns the #<route>?<query> split, and both parsers call it. It knows the separator character and no route name, so it is a splitter and not the route table this section forbids. parseHistoryHash and parseOverlayHash each held their own copy of the same four lines, and the second one's comment admitted it mirrored the first.
  • useHashRouter was DELETED, and useHistoryHash next to the parser replaced it. It ended up holding no state and no listener — a rename layer that renamed two fields and folded a third, creating a second vocabulary for one fact while three other modules read the parser's own names. Its name also promised the generic hash router this section forbids. All four call sites read the parsed value directly now.
  • hooks/useLocationHash.ts owns the four hash listeners for the whole app. It returns the raw window.location.hash string and parses nothing, so it is not the route table the section above forbids. useHashRouter and useHashOverlay both read it. Before it, the two hooks held byte-identical eighteen-line subscriptions whose only difference was which parser they called.

Report Route

  • apps/webapp/src/utils/reportContent.ts is the canonical home. It opens a prefilled mailto: to LEGAL_CONTACT_EMAIL, the same constant /terms and /privacy read, so the pages and the product cannot disagree on the address. Do not add a second report path.
  • One live entry point. reportCurrentDocument is the Settings row in components/settings/SettingsPanel.tsx. It shows for signed-in users on a pad route and is hidden on Home. The chat row in useMessageActionMenuItems.tsx stays at display: false, so neither message menu shows it. Do not restore it.
  • The payload is a link and a kind label. Never interpolate document text, message text, a title, or the reporter's profile. A mailto: opens in the reporter's own mail client, so anything prefilled is content handed to their mail provider. reportCurrentDocument drops the query for the same reason: a pad URL can carry ?chatroom= and ?msg_id=.
  • Build the string with encodeURIComponent, never URLSearchParams. The latter writes a space as +, which mail clients show literally in a subject line, and an unencoded & inside a chat deep link would split the mailto query.
  • The route is a signed-in Settings row by maintainer decision (root CLAUDE.md §Settled, Report route).
  • No handler hook. A useReportMessageHandler wrapper was built and deleted: the row holds no state, and the Delete row in the same file already inlines openDialog without a hook.