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.
- This app is Next.js Pages Router, not App Router.
pages/is the routing surface; there is noapp/directory. Server data comes fromgetServerSideProps/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 fromutils/index.tswhen 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.tsformatters, string builders, or bleed/pad maps inui/.apps/webapp/src/components/(root, notui/) — 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.PanelSurfaceVariantintypes/ui.ts). Feature-owned types stay in that feature'stypes.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 pad-only SCSS lives under
apps/webapp/src/styles/editor/. - Load path:
styles.scss->components/_index.scss->@use '../editor'. - Do not add parallel
.scssfiles next to TipTap extensions. - Pad shell:
PadTitlehasborder-bfor header-to-toolbar..tiptap__toolbarusesborder-bonly; noborder-tagainstPadTitle.- Pad sheet top border comes from
_blocks.scssfor toolbar-to-editor. - Mobile
.m_mobile .tiptap__toolbarlives in_blocks.scss.
- Scrollbars:
- Shared
:roottokens live inglobals.scss. - Use
scrollbar-custom scrollbar-thinon.editorWrapperand TOCScrollArea. - Avoid ad-hoc scrollbar styling on the pad column.
- Shared
- Document sheet border/radius/shadow: §Pad Workspace Surfaces (desktop) — not ad-hoc
box-shadowin_blocks.scss.
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-selector8px /--radius-field8px /--radius-box10px in every theme block, and maintainer-tuned taste is 8–10px, no more, no less. Surfaces consumerounded-selector|field|box(orvar(--radius-*)in SCSS) — never Tailwind literal radius classes.--pad-sheet-shadow: nonein 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 theSlugPageLoadermirrors). Light: aliasesbase-200. Dark:#070d18(HC#0b1322) — deliberately belowbase-100so the sheet reads raised in dark exactly as in light. Well surfaces usebg-[var(--pad-well)]/var(--pad-well), neverbg-base-200directly.--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 — neverbase-contentmixes, which glow in dark. Tailwind-styled floating surfaces useshadow-xl. No other floating shadow literals outside the deliberate exceptions listed indesign-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 givemedia-image|video|audiocolor 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/#e5eaf1trio 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-shadowmust use--pad-sheet-*tokens. Do not reintroduce per-themebox-shadowliterals on the sheet in light mode. - Docked chat (
ChatroomPanelLayout.tsx):border-t border-base-300only — no drop shadow.role="region"andaria-label(Chat: {heading}orHeading chat). Opacity-onlydoc-content-in200ms. Never a transform on this panel (it hosts the TipTap composer). The sash lives inuseResizeContaineronly (open | drag | settle-to-min | settle-to-close). Same snap recipe as TOC (useTocResize): linear paint below min, inner-column fade viaisContentHidden(not the panel; oneisOvershootflip per 320 crossing), snap at half min (CHAT_SNAP_HEIGHT160), abort 160–320 settles height to 320, snap-close settles height to 0 thencloseHeadingChatroom(). Both settles use--motion-overlay-in120msease-out(transitionendonheight, fallbackMOTION_OVERLAY_IN_MS + 50). No flick. Close button stays. Never persist a below-min height. Firechat-panel-resize-endafter settle; on hide, close first so the rail does not jump.CHAT_CLOSEand theCHAT_OPENtoggle-close restore focus throughfocusHeadingChatTrigger(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 bydata-toc-rail-reopen, never by its label. - Heading-action chips (
_heading-actions.scss$ha-btn-surface+ expanded.ha-grouptray): same--pad-sheet-border/--pad-sheet-shadow— flat in light, subtle lift in dark only. - TOC column layout (
DesktopEditor.tsx): editor columnflex-1 min-w-0; TOCshrink-0+ explicit width — noborder-ron the column (double-divider with the sash). Noisolate z-0on the TOC column when the sash lives on the row (that trap buried the sash under chat). Nojustify-around, nowidth: 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 widthTOC_MAX_WIDTH_RATIO = 0.46of.editorrow; clamp on drag + aResizeObserveron.editor. Vertical sash mounts on the.editorrow (not inside the TOC column) atleft: paintedWidth,z-[41], straddling the split (right-[calc(var(--resize-sash-hit)/-2)]on a zero-width anchor). It unmounts whileisRail. While chat is open it ends at--chat-panel-heightso it meets the chat gripper and does not run through the panel. Wide TOC ish-fullinwideandsettle-to-wide(L-shape).settle-to-railand the rail useh-[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 isz-[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 TOCisolateor chat paints over it and the panel edge reads as a thicker, offset second line. Do not bump TOC toz-[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 centeredtranslate-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 columnborder-ror idleafter:opacity-0. Horizontal chat sash:bottom-fullon the panel top edge — nevertop-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.
- The canonical mobile sheet system is
apps/webapp/src/components/BottomSheet.tsx, wrappingreact-modal-sheet. - Sheets register through
useSheetStorewithSheetType+SheetDataMap. - New mobile UI surfaces add a
SheetTypevariant, a typedSheetDataMapentry, and an entry inSHEETSincomponents/BottomSheet.tsx. Each entry holdsrenderand that sheet's props. A sheet traps focus by default (role="dialog",aria-modal, Escape closes) and needs anariaLabel.trapFocus: falseopts out and keeps the keyboard up, aslinkEditorand 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
popoversconfig, gated bysettings.deviceDetect.isMobileinTipTap.tsx. - A
Sheetportals todocument.bodyunless it receives amountPoint— the library callscreatePortal(sheet, mountPoint ?? document.body). Where the React element sits says nothing about where its DOM lands. ASheetrendered 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 onisOpen && avoidKeyboard, so a closed sheet attaches novisualViewportlisteners. - 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
useSheetStoreorBottomSheet. - Keep the keyboard up for the chat composer and
linkEditor. The composer emoji panel mounts inline (not as a sheet variant);emojiPickeris no longer aSheetType/SheetDataMapentry /useBottomSheet.openEmojiPickerflow.CHATROOM_OVERLAY_SHEETSis gone with it. Do not reintroduce anemojiPickersheet variant for the composer surface. - Dismiss the keyboard for
linkPreviewand 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.tsstyle: collapse selection, thensetTimeout(50)andeditor.view.dom.blur().exitDocEditModeForSheetinservices/openHeadingChatroom.ts:editor.setEditable(false)pluseditor.view.dom.blur().
editor.setEditable(false)synchronously flipscontenteditablethroughview.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
isKeyboardOpenis false. - Unified sheet shell.
SheetLayout(@components/SheetLayout) is the canonical mobile sheet body. It holds aSheetHeadertitle that states the sheet's purpose, a scrollableflex-1 min-h-0 overflow-y-autobody, and an optional sticky footer.fillHeighttogglesh-full min-h-0vsmax-h-[min(85dvh,100%)]. Form sheets pin actions withSheetActionFooter(built onSheetFooter). It holds an optional square ghost Back (btn-square min-h-12 w-12) on the left plus a primaryflex-1Apply 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 viaTocModal) andBottomSheet(notifications, filters, bookmarks, documentSettings, pad link sheets).BottomSheetmounts outsidemobileLayoutRootinMobileLayout. Chat is not one of them — it is a flex child of the shell; see §Mobile Chat Pane. - Opening a sheet from inside
TocModalmustcloseModal()beforeopenSheet()— the drawer isz-30and stacks above the sheet (same pattern as filters). - Desktop
BookmarkPanel/DocumentSettingsPanelpopovers (w-[28rem],bottom-end) mirror the mobile TocModal footer sheets.SettingsPanel(user account) stays separate fromDocumentSettingsPanel(per-doc metadata/markdown I/O). - User-account
SettingsPanelrenders as a modal mobile takeover, not aSheetType. It is a multi-section hub with drill-in navigation — HIG/M3 place those on full-screen surfaces, and the home page mounts noBottomSheethost. 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 passModalContent mobileTakeoverthemselves (ui/Dialog.tsx, design-system.md §Elevation species (L2 arm)). They own open state viauseSettingsModal(components/settings/hooks/), which belowmdpushes one history entry so hardware back closes the surface (ComposerEmojiPanel precedent).useSignOutconsumes that entry beforelocation.assign. The Documents ⋮ renders an in-tree bottom action sheet belowmd(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. messageReactionis a SheetType for the mobile reaction picker (not composer emoji; do not name itemojiPicker).- BottomSheet paints at
z-50(floating L2), notz-10.
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 aSheetType. Do not put it back.react-modal-sheetpositions its container withtransform: 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 forceddetent: '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.expandedis 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; aResizeObserveron 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_PXis measured, never estimated. It is the sum of non-shrinkable furniture: grabber 20 (h-5pill, same as the sheet header) + header 53 (border-b + pb-2 around thebtn-smShare/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 isflex-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.
ChatPanereads.mobilePadTitleShell'soffsetHeightand passes it to the clamp asreservedHeight. A hardcoded value drifts.MobilePadTitlerenders at 61px, not the 56px its Tailwind classes suggest, and the earlier constant silently handed the document less than its floor. Do not reintroduce aCHAT_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 ondata-chat-pane-bodyinChatContainerMobile, 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.ChatPanereads that element's computedpaddingBottom(resolvesenv()to real px; a CSS custom property would not) and passes it toresolveChatPaneHeightassafeAreaInsetBottom. 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'sResizeObserverassignsstyle.heightdirectly. iOS rewrites--visual-viewport-heightin a burst on every keyboard step, andChatContainerMobileis 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 inuseResizeContainer; onlypaneModebelongs in state. - Opening chat seeds
expandedonly fromclosed. Switching headings while the reader holdshalfmust not yank them toexpanded. - The rendered mode is derived, not written.
useChatPaneModepromotes the pane toexpandedwhile the composer emoji panel is open. That is because the panel opens at roughly a keyboard's height and cannot fit insidehalf. Deriving leaves the reader's stored mode untouched, so dismissal restores it with no snapshot. Do not implement promotion by writingsetPaneMode, 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.
ComposerEmojiPanelmeasures its host column with aResizeObserver;window.innerHeight * 0.7overshoots the pane and leaves the header and composer no room on a small phone. - Mode values are
closed | half | expanded(ChatPaneModeintypes/ui.ts). Neverfull— the pane cannot cover the document. Neversnapordetent; those belong toreact-modal-sheetand 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 + 1settle check and firesonInitialVisiblewith a meaningless index. That call reachesadvance_read_cursor, which recomputesunread_message_countserver-side. - Readiness is
chatRoom.editorInstance != null, because closed unmounts.openHeadingChatroomhas no four-state lifecycle and nosetSheetStateforce-write. A focus request is one pendingchatRoom.composerFocusRequestfor one heading.openHeadingChatroomwrites it on every open: a comment orfocusEditorsets 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.destroyChatRoomclears it. There is no timed retry. - The pad owns the keyboard only while the pane is closed.
ToolbarMobileandEditFABboth gate onpaneMode === 'closed'. Keep that predicate out ofselectDocumentEditingLocked—CONTEXT.mdreserves 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'sSCROLL_MARGINanduseHeadingScrollSpy's anchor/hysteresis/past-max bands. At the 96px document floor a fixed 100px margin inverts the visible band. useSyncChatPanelHeightis desktop-only. Mobile must not call it. Call it fromDesktopPadEditor(first sibling ofTocTickRail) so itsuseLayoutEffectwrites--chat-panel-heighton.editorbefore the rail paints. Tick budget reads measured nav only — do not subtract chat height in JS. Drag still useschat-panel-resize-*.useChatRailReservereturns{ dragging }only so a tick does not remount viewport listeners..editorWrapperish-full, so amarginBottomdoes not clear the sheet. Document clearance ispadding-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
paneModealone, independent ofactiveSheet. 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 momentactiveSheetchanged; do not reintroduce a teardown keyed on sheet state. - Mode changes need no scroll-anchor. Shrinking the document scroller raises
scrollHeight − clientHeightsoscrollTopstays 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 onuseHistoryDismiss— that marker is shared with registry sheets and would destroy the room when a sheet closes. - Lifetime is
paneMode+headingId. There is nochatRoom.open/openChatRoom.
- iOS Safari rules live under
apps/webapp, mainlyhtml.m_mobileinstyles/_mobile.scss. htmlandbodyareposition: fixed..mobileLayoutRoottrackswindow.visualViewportthroughsyncVisualViewportToCssVars.AppProvidersonly callsuseVisualViewportCssSync, and that hook owns the visualViewportresize+scrolllisteners, coalesced with rAF.- Do not skip CSS sync when height deltas are small. WebKit can emit sub-threshold steps after a large keyboard resize.
useVisualViewportCssSyncOnFocuslistens for capturedfocusinon.mobileLayoutRoot .tiptap__editor.docy_editorand reruns viewport CSS sync when a final resize is missing.- Do not use
transform: translateZ(0)on.editor.editorWrapper. - Do not use
containorwill-change: heighton.mobileLayoutRoot; WebKit can mis-paint the contenteditable caret. - Use
scrollElementInMobilePadEditorfor headings, TOC, and deep links. Avoid rawElement.scrollIntoViewon doc nodes. innerHeight - visualViewport.heightcan stay 0 while the keyboard is up. UseapplyVirtualKeyboardToStoreinutils/virtualKeyboardMetrics.ts.useVirtualKeyboardandnudgeVirtualKeyboardOpenFromVisualViewportboth call that metrics path. Listen to visualViewportscrollandresize.- In
useEditableDocControl, never setisEditable = isKeyboardOpenon every effect. Keyboard opens before resize; only clearisEditableon keyboard close. - The 500ms DOM sync must not set
contenteditable=falsewhilesettings.editor.isEditableis still true. - Read-mode
contenteditableleak fix:- Keyboard-close store updates alone are not enough.
- Add/keep a reconcile effect that mirrors
isEditable -> falseto botheditor.setEditable(false)andview.dom.contenteditable. - Guard false-direction only and only when
editor.isEditableis currently true. - Do not remove legacy entry-edit-mode behavior or the 500ms grace.
- Removing the JS
.focus()call fromextensions/extension-hyperlink/src/interactions/clickHandler.tsis not enough; the user tap itself can focus a lingeringcontenteditable=truehost. useVisualViewportCssSyncresets the page scroll. On a visualViewportscroll, ifvisualViewport.offsetTop > 0andwindow.scrollY > 0, it callswindow.scrollTo(0, 0). The reset runs for thedocumentmode, and forlandingwhile the mobile shell is pinned.- Edit entry:
EditFABand double-tap shareenableAndFocus()fromhooks/useCaretPosition.ts.- FAB uses
onTouchEndand suppresses syntheticclick. enableAndFocus()useseditor.commands.focus()only. Do not chain TiptapscrollIntoView()withensureCaretVisible/scrollCaretIntoView.- Mobile caret scroll uses
behavior: 'auto'. ensureCaretVisibleuses 2x rAF plus one ~300ms retry.
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-contentis text ink, not scrim ink. Low-opacitycolor-mix(… base-content …)is fine for muted copy, borders, and placeholders (~8–16%). Never usebase-contentat high opacity for full-screen modal/sheet/lightbox scrims — on dark themes it becomes a milky#fffwash. Scrim color is always black + opacity (color-mix(in oklch, black …, transparent)), theme-tuned via CSS vars notbase-content.- Canonical scrim tokens live in
apps/webapp/src/styles/globals.scss(:root+[data-theme='docsplus-dark']/docsplus-dark-hcoverrides):--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-100only (small portaled cards, e.g. composer link dialog).modalPanelClassName→ the frame +flex max-h-[90vh] flex-col overflow-hidden(fullModalContentshells).
- 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), 1pxborder-base-300,shadow-xl,bg-base-100. Never a Tailwind literal radius class (rounded-lg/xl/2xl) and no per-featurePANEL_CLASSstring modules. The full popover export isrounded-box border-base-300 bg-base-100 z-50 w-[28rem] overflow-hidden border p-0 shadow-xl. Every desktop toolbar/padPopoverContent(bookmarks, document-settings, filter,PadTitle) wraps in it. That export is deliberately the sibling ofcontextMenuPanelClassName, so popovers and context menus speak one floating-surface language in light and dark. SCSS floating panels useborder-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 viaopenDialog/ModalContentclassName(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-smonmodalBackdropClassName). Bottom sheets — same--modal-scrim, no blur (.react-modal-sheet-backdropindocument-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 isz-[41]. Other docked surfaces stay atz-40or lower. All of them sit belowz-50. Pad toolbars, popovers, dialogs, and extension toolbars usez-50. Global app prompt cards (NotificationPromptCard,PWAInstallPrompt) and composer link shells usez-[60]. The catalog row is indesign-system.md§Elevation.
- Tokens live in two lockstep homes. CSS:
:rootinapps/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_MS180 /OUT150,PANEL_TWEEN,prefersReducedMotion()) — the--motion-regiontoken is CSS-only (no JS mirror). Update the mirrored tokens together; do not invent new duration/easing values per surface. User theme writes go throughsetPreference(View Transition); boot and rehydrate stay onapplyThemeToDom. - 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) anddoc-content-in(opacity only), applied asmotion-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 passtransform: falsetouseFloating(left/top positioning), or the scale clobbers translate positioning. Conditional mounts that need an exit fade useapps/webapp/src/hooks/useEntryExitTransition.ts(double-rAF enter so the from-frame paints; transitionend + fallback-timer exit). Underdisplay:noneor 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 anonAnimationEndflag or a deliberate keyed remount; display-toggles restart CSS animations)..animate-badge-entrykeepsanimation-fill-mode: backwards—forwardssticks 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 coversmotion/reactconsumers including react-modal-sheet —motionis 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;.loadingspinners stay (status, not decoration). - floating-popover engine contract (published).
hide()plays the skin's exit: removes.visible, defersroot.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, soshow()no-ops afterwards and reopening means building a new popover. (hide()before the firstshow()is a plain no-op that keeps ownership.)updatePosition()setstransform-originfrom the resolved placement. The skins inextension-hyperlink/src/styles.cssandextension-hypermultimedia/src/styles/media-toolbar.cssrestate 120/80/0.96 + a PRM block as literals (publish boundary — webapp tokens never cross into extension CSS). The webapp re-skin instyles.scssstays lockstep. Any engine/skin change rebuilds both extensions and gets a CHANGELOG entry each. The hypermultimedia toolbar appends before flagginghm-has-toolbar(rAF) and defers removal 100ms, or its fades never play.
- The page gate is provider presence only.
DocumentPagerenders the layout whensettings.hocuspocusProvideris 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.joinedWorkspacemeans the join RPC succeeded, not started. - Pre-sync mounts. The layout exists before first
onSynced. Any hook underuseEditorAndProviderthat reads the Ydoc/ymetadata once (not event-driven) must early-return whilesettings.editor.providerSyncingis true — wired inuseInitializeNewDocument,useHandleDraftOnFocus,useCheckUrlAndOpenHeadingChat. - Provider lifecycle on doc switch. Recreate the provider AND the Y.Doc (
useYdocAndProvidernullsproviderRefin cleanup and tags the ydoc by documentId). A reused Y.Doc merges the old document's content and itsneedsInitialization=falseinto the new room. A 15s first-sync watchdog (per documentId, skipped while status isoffline) setsproviderStatus: 'error';SyncErrorCardinEditorContentrenders onproviderSyncing && status ∈ {error, offline}and self-heals on a lateronSynced. - Offline mirror.
useYdocAndProviderpairs each Y.Doc with anIndexeddbPersistencekeyed 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 firstonSynced; theproviderSyncinggates 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 whoseneedsInitialization=truecan win the Y.Map merge coin-flip against the mirror'sfalse.useInitializeNewDocumenttherefore also content-gates the seed on the fragment's text (neversetContentover user text). Node count is the wrong predicate. The editor binding writes its empty mandatory-heading scaffold into thedefaultfragment 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.
SlugPageLoaderis server-rendered as a page sibling keyed on!hasProvider, prop-pure (isMobile,isAuthedfrom GSSP — never store reads, which land post-paint). Its geometry mirrors the real layout pixel-exact. Headers areh-14.ToolbarSkeletonbones match the real control sizes (select 160×32rounded-field, buttonssize-8). The document bones carry the_blocks.scsssheet styling through the realpad → editor → editorWrapperclass cascade (--pad-sheet-*tokens +--pad-wellfloor — never hand-mirrored utility copies). The wrapper carriesoverflow-y-auto scrollbar-custom scrollbar-thin, andTableOfContentsLoadermirrors the TocHeader row.EditorContentSkeletonis the single bones component shared by the page skeleton andEditorContent, 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 usesize-*, noth-* w-*. The_daisyui.scssbase-radius override MUST stay scoped:not([class*='rounded-']). It is unlayered CSS and otherwise silently squares everyrounded-*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 .editorsheet 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.tsowns 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.
ProviderStatuslives intypes/collab.ts:saved/synced/saving/error/offline/unauthenticated— no fakeonlinestate.- Of the three shared helpers below,
useYdocAndProviderimports onlycollabSession. Other modules importproviderCollabStatusandopenInlineSignInDialogdirectly, for exampleSyncErrorCard,openComposerSignIn,PrivateDocumentGateandPadTitle.@utils/providerCollabStatus.tsholds predicates +getNeedsAuthCopy()— "Session expired" copy only when the auth store had a profile.@utils/openInlineSignInDialog.tsxis the one pad/document sign-in shell; do not fork through the profile modal.hooks/collabSession.tsholds 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 sharedSessionExpiredBanner(document + history parity). Keep the pre-/post-sync split. ProviderSyncStatusstays outside the editor (role="status"is OK there — never inside.ProseMirror, per §Editor Performance).hooks/guardProviderAwareness.tswraps the provider's awareness right afternew HocuspocusProvider(). It skips acursorwrite that equals the stored value, and it skips re-sending awareness whose origin is the provider.@tiptap/y-tiptap3.0.6+ re-sends an unchanged cursor after every remote edit (ueberdosis/y-tiptap#55).@hocuspocus/provider3.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 opensopenInlineSignInDialog(). Upload, paste, import and export do this. Check before the request, not after a401: 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
/is SSG with deferred anon auth viaroutePolicy.ts;document-styles.scssis dynamic-import only whendocumentShell. - Public legal pages
/privacyand/termsshareLegalPage, which owns their<Head>block and derives the canonical URL fromHOME_SITE_URL. They are utility routes inroutePolicy.ts: no document styles and no GA.HomeFooterlinks both, which Google branding verification requires.INDEXABLE_PATHSincludes them. Do not let[...slugs]orisDocumentAsPathtreat those paths as documents. - Mobile landing compact layout tracks keyboard visibility via
useVirtualKeyboard({ activeMq: HOME_MOBILE_MQ, clearStoreOnDisable: true })+ globalisKeyboardOpenfromapplyVirtualKeyboardToStore()— not slug input focus; slug-focus scroll-into-view stays inuseVisualViewportCssSynconly. Do not reintroduceuseHomeKeyboardCompactor a secondmatchMediagate whenactiveMqalready 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.mobileLayoutRootpattern). The pin stops the iOS keyboard from scrolling it off-screen.useVisualViewportCssSyncextends its iOSscrollTo(0,0)reset to the landing route and dropped the focusscrollIntoViewcentering. - Reject
interactive-widget=resizes-content— it's a global viewport-meta change that fights the editor's overlay machinery and is unreliable on iOS. HomeCollapseRegioncollapses viagrid-template-rows0fr↔1fr(notmax-height) withmin-h-0so the footer flex child reaches0px, using direction-aware easinghomeRegionEase(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 byconnect-src, notimg-src/media-src. Default next-pwa rules can break any remote avatar/embed URL despiteimg-src https:. Fix inconfig/pwa/workbox-runtime-caching.jsonly. Rule 13 (cross-origin catch-all) skippingimage/audio/video/scriptis load-bearing. Thescriptskip stops the SWNetworkFirstfrom throwing an uncaughtno-responsewhen an ad blocker/CSP rejects a cross-origingtag.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 inconnect-src.next/image(/_next/image) stays SW-cached same-origin; raw cross-origin<img>bypasses the SW and uses browser HTTP cache only. - Workbox
urlPatternmatchers must be self-contained (next-pwa serializes viaFunction.toString()— no module-scope refs). Verify a matcher change under Node (the actual Next build runtime), neverbun -e. Bun'sFunction.prototype.toString()re-serializes withconstbindings inlined. A matcher that readsconst isSameOrigin = …; return !isSameOrigintherefore reads back mutated, and throws a false serialization/assertion failure that Node (source-accurate) passes. - Deploy-boundary chunk/SW GlitchTip noise:
chunkLoadRecovery.tsone-shot reload (10ssessionStoragecooldown) +instrumentation-client.tsbeforeSenddrops SW-lifecycle and auto-recovered chunk errors. Keep the chunk pattern lists in sync across both. Rebuild the webapp forsw.jschanges. - PWA path guards (epic #249). Keep #257, #259, #260 and #261 open as standing "do not build" cards. Do not add
captureor 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 withrouter.push, never_blank, solaunch_handler: navigate-existingholds. - Workbox rule 0 keeps authenticated requests out of Cache Storage. Any request with a
tokenorAuthorizationheader isNetworkOnly. supabase-js sendsAuthorizationeven when signed out, so every Supabase REST GET skips the cache. Keep the rule first inworkbox-runtime-caching.js, ahead of the adapted defaults. The MCP status probe (/.well-known/oauth-protected-resource) isNetworkOnlyright after it, so a cached answer never shows Online while the server is down. - Inbound share is
/receive. The manifestshare_targetposts there.public/service-worker.jshandles onlyPOST /receive: it stashes the file and redirects./receiveis a utility route and a reserved slug, so it never becomes a pad. Do not addfile_handlersor change manifestidorlaunch_handler.
- Glossary: root
CONTEXT.md§Product HTTP (rest-api, Health, Validate, Status, Confirm). - Next
pages/apikeeps only Health.apps/webapp/src/pages/api/health.tsis the probe. Do not retarget Traefik, Docker HEALTHCHECK, compose, CI,src/proxy.ts, or Next health rewrites. - Validate is on rest-api.
SignInFormfetches${NEXT_PUBLIC_RESTAPI_URL}/email/validate. Read top-levelisValid. No cookies, rewrite, or Next proxy. The Next Validate route is deleted. - Status is not a Hono route. Heartbeat, visibility, online, and offline stay
updateUserplus RLS. Tab close holds the JWT inaccessTokenRef(filled byonAuthStateChange) and PATCHes Supabase REST withkeepalive. Do not callupdateUseron unload. Do not readauthStore.sessionfor 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/callbackpage. A magic link to/{slug}still drops the hash; that is a different fact. - Leave
apps/webapp/src/utils/supabase/api.tseven when it has no caller.
- 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 viaresolveWsAccess, fail-closed on metadata lookup failure). Anonymous →sign-in-required, signed-in non-owner →denied. WebappPrivateDocumentGate(sign-in-required|access-denied|check-unavailableviatoPrivateGateVariant) mounts before the collab provider so blocked viewers never open a WS room.check-unavailableis 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
DocumentSettingsPaneland Settings DocumentsDocumentRowMenu(⋮). - Private ON is confirm-gated. Turning Private on opens
openMakePrivateConfirm→MakePrivateDialogviaopenDialog(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/:docId403s every other caller (isOpenDocument/isDocumentOwner,lib/ownerAccess.ts). The webapp mirrors the rule throughcanEditDocumentMetadata(hooks/) onDocTitle,MobilePadTitleandDocumentSettingsPanel, so no surface offers a write the server refuses. Those three surfaces also send the padslug. The server reads it only when the save creates the row, so a never-persisted draft keeps the slug that reload resolves by. AfterdocumentIdis known, a missing or emptyownerIdis open. First-edit persist is the hocuspocus rule. - An ownerless document cannot be closed.
resolvePrivateAccessanswerssign-in-requiredto everyone whileownerIdis 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 → Redisdoc:{id}:access(accessRealtime) → WS broadcast/close → clientapplyAccessStateless. - Editing lock. Client cannot edit when content-fork/
providerStatuserror, mirrored WSauthorizedScope === 'readonly', or metadata Read-only for a non-owner —selectDocumentEditingLocked/useEditorEditableStatekeepauthorizedScopein the workspace store.
- 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. AccentLuStar(text-accent fill-accent) after the list title and top-left on the grid paper. List and Trash rows paintLuFileText, 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 andlastOpenedAt, fills a missingpreview, and omitsisFavorite. 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.
- Hocuspocus history uses stateless
history.list/history.watch. - Server unicasts
{ msg: 'history.response', type, response }to the requesting connection. Do not usebroadcastStateless. Every list and watch reply, success or failure, also echoes the request'ssince,versionandbeforeVersion. 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, respondhistory_failed. history.listreturns one page:{ versions, hasMore, nextBefore?, beforeVersion?, anchor?, profiles, clientAuthors }. A page is 50 rows, newest first. For Show older versions, the client passesnextBeforeback asbeforeVersion. A first-page request may carrysince. The reply then carriesanchor: the Anchor forsince, else the oldest row.anchoris never added toversions, soversionsstays newest first.previousEntryandcountVersionsAfterrely on that.anchorcan repeat a row thatversionsholds, so never push it intohistoryList. An older page still carriesresponse.beforeVersion, and the client tells an older page apart by it. Only the failure arm reads the top-levelbeforeVersionecho. The client drops an older-page reply whosebeforeVersionis not the currenthistoryNextBefore, so a stale reply never appends rows. An unknown historytypeis refused withhistory_failed. The list carries no document bytes; the head loads throughhistory.watch.history.watchandhistory.listeach have a per-connection limit inhocuspocus.server.ts(WATCH_MAXperWATCH_WINDOW_MS, andLIST_COOLDOWN_MS). Both refuse withrate-limited.clientAuthorsis the per-document Yjs clientID → person table; likeprofilesit is fail-soft and can be empty. Client still accepts legacy plainHistoryItem[].profilesis 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.Avatarwould generate one and name a person you cannot.- Version rows carry
trigger/triggeredBy/contributors;history.watchrows do not. - Block authorship answers "whose text sits here now", and two of its limits are permanent.
collectBlockClientIdswalks live Yjs items per top-level block;DocumentClientAuthorbinds 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 /contentand the schema-migration rebuild all apply through a direct connection with no socket.applyContentToDocclones every node under the server document's own clientID. Do not "fix" either by writing aDocumentClientAuthorrow 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'striggernames the operation that caused it. applyHistoryItemToEditoris the single TipTap hydration path.loadingHistoryclears only after successful apply, not merely after a network response — that rule governs the read ops. Ahistory.revertack or failure clears it on the network response, because a revert never hydrates the history editor.useHistoryEditorApplyWhenReadyapplies when the editor mounts after data arrives.- While
pendingWatchVersionis set, do not re-apply staleactiveHistory. - Late
history.listmust not reset pending watch state. - Only a first-page list can be silent. A frame that carries
beforeVersionnever clears or consumessilentListRefresh. Apart fromrate-limited, a watch failure belongs to compare only when its echoedversionequalspendingCompareVersion. 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, clearpendingWatchVersionso the next watch is not dropped — for the read ops only. Ahistory.revertfailure clearsloadingHistoryalone; it never owns the watch slot, and clearing it would strand a watch still in flight. - Restore is a server write through the
history.revertstateless op, never a clientsetContent. 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 onpendingCompareVersion; the A side is held whole ascompareBaseItembecause list rows carry nodata. Desktop picks A from the sidebar. Mobile picks A from the Compare with sheet; the version drawer still picks B. document:savedwhile 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-chattitle_changedrow, not a History version. History uses the snapshot username. Chat paints the live username. A rename does not mint aDocumentsrow. - Shareable revision URLs use same pathname/query plus
#history?version=<n>, where<n>isHistoryItem.version. - A
#history?version=link below the loaded pages fetches older pages first,HISTORY_LIST_GAP_MS(300 ms) apart and at mostDEEP_LINK_PAGE_CAP(40) pages. Only then is it judged unavailable. The gap clears thehistory.listcooldown (LIST_COOLDOWN_MS), so do not remove it.loadingHistorystays true during the walk. Arate-limitedpage 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 code lives under
components/toc/. - Toc vocabulary is canonical. Keep the
Toc*/toc__*/TOC_*names and thecomponents/toc/home — never rebrand to Document Outline /Outline*(paths or exports). Deepen the feature in place undertoc/(structure + interfaces); do not rename/move the folder. Prefer a shell-facing public barrel (TocDesktop/TocMobile/TocHeader, optionallyTOC_CLASSES) over re-exporting presence, scroll-spy, unread, or move helpers fromtoc/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_WIDTHis 32.DesktopEditordeep-importsTocTickRailand mounts it in the.editorpad 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 exportTocTickRailfromtoc/index.ts. MobileTocModalis unchanged. Do not addSideContinuum.- Persist
docsy:toc-widthis the last committed wide width only (>TOC_MIN_WIDTH240). 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.SlugPageLoaderstays on the persisted wide width. - Mode union in
useTocResize:wide | rail | drag | settle-to-rail | settle-to-wide.stepTocReleasein that hook is the only release step (cancel, snap, abort, commit). Snap lineTOC_SNAP_WIDTH120. During drag,tocWidthdoes not follow the pointer. Paint follows the pointer.lastWideWidthRefonly ratchets up. Do not copytocWidthontolastWideon 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 overwritelastWideor storage. Wide release commits intended. Persist effect writestocWidthwhen that value commits. It skipsrail,settle-to-rail, anddrag. Abort does not changetocWidth, 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-widemust not armtransitionendwhilepaint === 32. - Sash unmounts while
isRail. Reopen is the top sidebar button (Icons.tableOfContents→LuPanelLeft).openWidekeeps 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-currenton spy only. Wide TOC spy staysmenu-focus/base-300. That split is a maintainer correction — do not paint the rail spy asbase-300. - Preview is an L1 card (
popoverPanelClassName), not the house Tooltip. Clone the live section fromeditor.view.dom(notdocument.querySelector— rail ticks also havedata-toc-id). Do not mount a second TipTap. Do not serialize the schema (hypermultimedia NodeViews become placeholders). Titletext-sm; bodytext-xsline-clamp-3; every heading in the card shares!text-sm. First media hoists into a flushh-24cover well. If the clone is the media root, do not also append it to the body. Stripidanddata-toc-id. Fit media; never drop a block becausescrollHeightis large. DaisyUI skeleton only while the host is empty — neverhiddenthe clone behindready. Open afterPREVIEW_OPEN_MS(80) on the same tick whileinRail. 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.closeMs0. 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-exportTocTickRail, deletelastWideWidthRef, migrate settle ontouseEntryExitTransition, inset rail height for the sash, house Tooltip for the section preview.
- Persist
- Keep
tocClasses.tsin sync withstyles/components/_tableOfContents.scss. --color-docsyequalsvar(--color-primary)in both@themeand: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 eachh1–h6[data-toc-id]). Non-heading text selection uses desktop-onlyselectionChatPlugin(HeadingActions/plugins/selectionChatPlugin.ts), which appends a direct child of.tiptap__editorwith classesha-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.scssas$ha-sheet-border-straddle-x:translateX(calc(var(--tiptap-inline-pad-end) + 0.5px + 50%))where50%is half the chip width ($ha-hit-size/size-11= 2.75rem). - Anchor reference differs by mount point:
.ha-wrapusesright: 0on the full-width heading (prose column right edge)..ha-selection-comment-dockmust useright: var(--tiptap-inline-pad-end)on.tiptap__editor. That is the same prose edge, becauseright: 0on 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 setstopin JS from selectionfrom/toviewport coords vs.tiptap__editor.getBoundingClientRect(). - When changing pad sheet outline, editor horizontal padding, or chip size, update together:
--tiptap-inline-pad-endon.tiptap__editorin_blocks.scss(must stay in lockstep withEditorContentpx-6/sm:p-8),$ha-hit-size+$ha-sheet-border-straddle-x,.editorWrapperpadding-inline-endstraddle gutter in_blocks.scss, headingpadding-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 inlineright: 9px— that regresses to selection-adjacent placement.overflow-x: visibleon.editorWrapperis load-bearing so the outside half paints. History read-only hides both.ha-wrapand.ha-selection-comment-dock(_blocks.scss.history_editor).
- Shared horizontal math lives in
$ha-hit-sizeis shared with plugins.$ha-group-has-unreadowns the DRY:has()selector for unread tray visibility._unread-badge.scssonly styles[data-unread-count]on.ha-chat-btn(the notification bell uses the React<UnreadBadge>). Do not add.toc__chat-triggeror.ha-grouprules there.- TOC uses React
UnreadBadgeonly. Shared count math isresolveUnreadCount(utils/unreadDisplay.ts); ProseMirror widgets sync viasyncHeadingWidgetUnread(.ha-chat-btnonly — never TOC.toc__chat-trigger).UnreadBadgekeepsRollingNumber(Safari-safe em-reeltranslate3d— never daisyUI.countdown, never strip the roll for plain capped text). - Active chat icon uses
toc__chat-icon--activewithfill: none; Lucide icons are stroke-based. Active accent isvar(--color-docsy)on both desktop and mobile — nevertext-accent. TOC visual recipes live in.cursor/docs/design-system.md§Table of contents. - When nested
ul.toc__childrenlives under the parentli, 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.tsbuilds inuseLayoutEffectplus a 200ms retry so the first rail paint is not empty;setItems([])when!editor || editor.isDestroyed; skipsetItemswhen id / level / text / open match; throttle later heading-driven rebuilds withlodash/throttle;- flat heading list converts to recursive
NestedTocNodethroughbuildNestedToc; TocDesktop/TocMobileown roots;useHeadingScrollSpy.tsdebounces scroll/active-heading work withlodash/debounce.
- TOC row = daisyUI menu item.
ul.toc__list.menuowns padding / hover / nest rails (stock — no TOC::beforestrengthen); desktop doc-title is the sticky first<li class="toc__header">wrapping the sameTocRowas headings. Chat-open → soft-primarymenu-active(--menu-active-bg/fg= opaquecolor-mix(primary 14%, var(--pad-well))/primary). That pair is scoped to.toc__list.menuin_daisyui.scssso the pad-well mix base can’t tint app-wide menus — never transparent mix, never daisyUI’s charcoalneutral. Scroll-spy →menu-focus(base-300only — not brand).TocRowis three grid children (leading | title<a>|TocRowTrail) — do not reintroduce a flex override or absolute trail + spacer math. Fold is a plain<button>chevron (notbtn-ghost) and always visible. Desktop.toc-drag-handleidle-hidden untillihover only (:focus-visiblefor keyboard — notmenu-active/menu-focus)..toc__chat-triggeridle-hidden untillihover with the usual active/focus/unread exceptions (menu-active/menu-focus/:focus-visiblestay 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: -22pxin_tocDrag.scss, centered withtranslateY(-50%)); the frame is one family (base-300border + micro shadow; fill mirrors row)..tableOfContents/.tiptap__toc/.toc__rowstayoverflow: visible. The.toc__scrollviewport is column-width withscrollbar-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 tooverflow: visibleto 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__rowoverflow-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 inTocRowTrail, right of the chat trigger, right-anchored (<AvatarStack anchor="right">, fixed right edge / grows left). It is nudged to the column inner edge withtranslate-x-3(visual only — chat + mobile flow untouched). Beyond-column overhang was evaluated and rejected (settled — do not re-attempt). Theoverflow: visiblehack breaks scroll. A JS overlay outside the scroll clip does per-rowgetBoundingClientRecton 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)..tableOfContentsisz-[42]/ sashz-[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-activefold/chat inheritprimaryink (soft tint — notprimary-content; drag grip stays hidden unless hovered).menu-focus= solidbase-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/TocRowTrailiconSize={20}). Drag overlay SCSS:_tocDrag.scss. TOCScrollAreausespreserveWidth={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>).usePresentUsersMUST filterprofile?.idand keep per-channeluseShallowequality. TOC focus/chat-active MUST use boolean Zustand selectors (focusedHeadingId === item.id,chatRoom.headingId === item.id).useTocAutoScrollmust subscribe outside the outline React tree (store.subscribe), never via a parentuseStorethat would remount the list. - TOC menus + drag. Right-click/long-press menus share
@components/ui/ContextMenuprimitives (contextMenuPanelClassName,ContextMenuRow,ContextMenuDivider,MenuItem); mobile uses the same row/divider shell viaContextActionsMenu. The panel is Tailwindflex flex-col list-none, not daisyUImenu—.menuonly styles directbutton/achildren, so<span>rows needgroup/cursor-pointer/group-hover:bg-base-300. Dividers are empty<li role="separator">withbg-base-300 h-px my-[4px]on the li — never inner divs, daisyUIdivider, orborder-tunder.menu. The drag grip is the absolute overhang chip with hover-only desktop reveal (chat keeps active/focus/unread exceptions). The level picker is sharedTocLevelPicker(H1–H6). The drag card'sDragOverlayportals todocument.body, because the TOC scroller's fade mask clips and fades it. Flat-schemamoveSectionmoves the whole section — drag E2E order assertions belong onh2[data-toc-id]. The TOC rebuild gate istransactionRequiresTocRebuildincomponents/toc/utils/headingTransaction.ts.
- Crinkle uses widget decorations with
data-fold-phasefor CSS animation. - Unique
Decoration.widgetkeys 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-durationand$crinkle-easing, not CSS custom properties. Decoration.nodeon heading-section was removed; animations live on the widget.- Strip count uses
MIN_FOLD_STRIPS,MAX_FOLD_STRIPS, andCONTENT_HEIGHT_PER_STRIPinheading-fold-plugin.ts. - If
MIN_FOLD_STRIPS === MAX_FOLD_STRIPS, strip count is fixed regardless of content height.
- Document comments are first-class
messagesrows withmetadata.commentholdingCommentAnchorV1(v:1,text|mediakinds). A comment with no attachment hastype = 'comment'. With an attachment,typeistextwhen it has a caption, else the media type fromresolveOutgoingMessageType.CommentReferencetherefore checksmetadata.comment, not only the type. File map: types intypes/comment.ts, anchor helpers inservices/commentAnchor.ts, preview parsing inutils/commentPreview.tswith the TipTap adapter inmediaPopovers/buildCommentPreview.ts, reference framing inutils/commentReferenceTheme.ts. - Send via
sendCommentMessage; the composer draft isCommentMessageMemory(Profile | nulluser).publishDocumentComment→CHAT_COMMENT(services/chatEvents.ts) →openCommentComposerinservices/openHeadingChatroom.ts— distinct fromCHAT_OPENbrowse viaopenHeadingChatBrowse. - 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 bynode_type+src, or the text run by content, and centers it. It ring-selects a media node with a PMNodeSelection, which is PM-managed, so there is no foreign DOM mutation and no node-view reload. It calls noeditor.focus(), so no iOS keyboard opens. It only falls back toscrollToHeading(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.commentwith border/surface/emphasis fromcommentReferenceTheme(anchor)(text selection → primary; media → per-MediaNodeTypebrand tokens inMEDIA_COMMENT_META, e.g. YouTube#FF0000, Xbase-content). Media preview goes through the sharedCommentPreviewVisualatcomponents/CommentPreviewVisual.tsx;CommentAnchorPreviewstacks thumbnail above label/excerpt (the parent passestheme; text anchors ignore it). Feed cards stay compact and non-interactive (no inline players in Virtuoso).
- Active filter terms live in URL path segments after the doc slug (
/docSlug/term1/term2?mode=and).useApplyFiltersbridgesrouter.query.slugs+mode→ the HeadingFilter PM plugin (applyFilter/clearFilter) and mirrors chips insettings.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 ismobileLayoutRoot tiptapwithout.pad, so that selector silently blocks mobile sheet apply. - All shallow filter URL math belongs in
@utils/filterRoute(append/remove/reset/mode, deduped segments); shallowrouter.pushuses pathname+search+hash viashallowPathFromAsPath, not a full origin URL. - Typeahead suggestions use PM
matchSectionsinfilterTypeahead.ts(same section rule as the filter engine), not a heading-only DOM scan. - Surfaces: desktop
FilterPanelpopover. Mobile TocModal footer →filterssheet (FilterSheetreusesFilterPanelwithvariant="sheet", dismiss viauseDismissPanelafter apply/clear). Active chips + mobile-visible Reset live onFilterBar(MobilePadTitle). Active-state indicators sit on the desktop toolbar and TocModal footer (filter-active-indicator*). Nofilter-modebody-class toggle — filter state is URL-only.
- Shared stack:
PanelSurfaceShell→TabbedPanelBody+PanelPopoverHeader,PanelFeedItem,useDismissPanel,useFeedItemExit(80msMOTION_OVERLAY_OUT_MSexit 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
readDedupeskips the double realtime decrement. - Notification store:
setNotificationsreplaces (never prepends); the pagination effect must not depend on thenotificationsMap. - Bookmark tab badges come from
get_bookmark_stats: unread = non-archived + unmarked (In Progress only);get_user_bookmarksis 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
variantdismisses on View viacloseSheet();BookmarkItem"View in chat" dispatchesCHAT_OPENonly — nocloseSheet()/activeSheetchecks (NotificationItemparity). Feed modules:useBookmarkPanelFeed,useNotificationPanelFeed. - The mobile sheet variant adds swipe-between-tabs via
usePanelTabSwipewired intoTabbedPanelBody(sheet-only; the desktop popover path staysif (!isSheet) return body). The gesture is finger-followtranslateX, with commit past ~22%/48px, rubber-band at edges (no wrap), and scroll lock during the horizontal drag. It usestransitionend+ fallback atMOTION_PANEL_MS, and reduced-motion skips transforms. Keepkey={activeTab}stable (gate the fade by class, not key) so a mid-swipe horizontal lock never remounts the list. - View on a
content_changenotification 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), nevernotification.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 fromnotification.channel_id, which holds it verbatim (packages/supabase/scripts/10-func-notifications.sql:654and:666).action_urlsupplies the route only: its later segments carry the reader's filter terms. Do not block the click on that read.useArmPendingHistoryComparewaits for the History list, so a late value still arms. It arms from theanchorof a list reply that echoed thissince. It sends a silent list withsincewhen 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 msHISTORY_LIST_GAP_MS. It retries a refusal once. After a second failure it clearspendingCompareSinceand opens no compare. pendingCompareSincemust surviveresetHistorySessionForMount. Reset clears every other history field and leaves this one alone (resetHistorySessionForMountincomponents/pages/history/clearHistorySession.ts,pendingCompareSinceinstores/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.
- An overlay hash is a one-shot instruction, never a view.
#notificationsand#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.#historyis the opposite and stays a view:parseOverlayHashreturnsoverlay: nullfor it, soclearOverlayHashcan never drop it. There are three callers today: email links, the own empty profile card inUserProfileDialog(#settings?tab=profile), and Command jump (components/commandJump/). The app serves every path from one catch-all and has no/notificationsor/settingsroute. Do not add a route table or a generic hash router for a third value. updateAppUrl's replace arm carrieswindow.history.stateforward, andclearOverlayHashis now only a guard. Next readse.stateon 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 inhistoryShareUrl.test.tspin the replace arm.- Because that write is now safe to share,
clearOverlayHashis a route guard plusclearHistoryHash(). The guard is its whole reason to exist:clearHistoryHashdrops ANY hash, and#historyis 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.tsxand never followedsrc/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.
- Because that write is now safe to share,
- 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: thePadTitlePopover andMobilePadTitle'sopenSheet('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
PadTitlenotification Popover disables its click-to-open.components/ui/Popover.tsxbuildsuseClick(context, { enabled: controlledOpen == null }), so anopenprop silently kills the bell. The bell therefore carries its ownonClicktoggle, which survives becausePopoverTriggermerges the child's props. Verified in a browser, both directions. Never "simplify" that handler away, and do not editPopover.tsxto fix it — other call sites rely on that guard. - An overlay hash and
#historycannot 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.tsxreturns<DesktopHistory />before<PadTitle />, andMobileLayout.tsxswaps in<MobileHistory />. SoclearOverlayHashandnormalizeToPlainHistoryHashcan 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.
SettingsPanelseedsshowContenton whether a tab was NAMED, never on which tab. Below themdbreakpoint 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.defaultTabis optional for that reason: it shipped once asdefaultTab !== 'profile'with'profile'as the destructuring default, and that sentinel could not tell#settings?tab=profilefrom a bare#settings. The parser accepts everyTabTypeon purpose. The pane keeps its back button, so the list stays reachable.
components/settings/SettingsTakeover.tsxis 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.EditorToolbargated 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 carriesif (!user) return null, so no mount can reproduce that.- The
aria-labelis the dialog's ONLY accessible name.SettingsPaneluses a plain<h2>rather thanModalHeading, soModalContent'slabelledByfallback resolves toundefined. Removing the label in favour of the heading leaves the dialog unnamed. sizeon a takeover modal is a desktop-only knob.modalPanelTakeoverClassNamesetsmax-md:max-w-none, so the value never paints belowmd. That is exactly why a 4xl versus 5xl split survived in four copies without anyone noticing.
- The
components/settings/hooks/documentsCache.tsowns every write to the Owner live list and Owner Trash list caches. Never put a rawsetQueryDataback 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.tsholds 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.
nextDocumentsOffsetis 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.
duplicateDocumentnever writeslastOpenedAt, anddocuments.service.tssorts that columnnulls: 'last', so underlastOpenedAt_desca 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 mustcancel().NotificationsSection.tsxcancelled a preference write, andModalContentunmounts its children on close, so closing the panel inside 500 ms discarded the patch silently. Its line is the only caller ofupdateNotificationPreferencesin the webapp, so nothing rescued it.DocumentsSection.tsxandProfileSection.tsxboth 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
keepaliveis NOT the fix. That was recorded here once and it is wrong.useSignOutawaitssignOut()first, souseOnAuthStateChangeclears the profile,SettingsTakeoverhitsif (!user) return null, and only THEN does the unmount flush fire. By that point the session is gone, andupdate_notification_preferencesopens withauth.uid()and raises42501 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 cleanup body must stay braced. The flushed function is
- 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_uriandclient.uriare self-declared. Never loadlogo_uri, since the fetch sends the viewer's address to that host. Never linkclient.uri. Clean the name withapps/webapp/src/utils/displayClientName.ts, and render it in<bdi>. Trust comes from the registered redirect URIs (GET /api/connected-apps/redirects) throughapps/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.groupAppsinapps/webapp/src/utils/appTrust.tsdoes this grouping.appTrust.test.tspins 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 Disconnectaria-labeland the confirm dialog add "(unverified)" or "(on this computer)".fetchRedirectsinapps/webapp/src/components/settings/hooks/useConnectedApps.tsstops 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.tsowns 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.parseHistoryHashandparseOverlayHasheach held their own copy of the same four lines, and the second one's comment admitted it mirrored the first.useHashRouterwas DELETED, anduseHistoryHashnext 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.tsowns the four hash listeners for the whole app. It returns the rawwindow.location.hashstring and parses nothing, so it is not the route table the section above forbids.useHashRouteranduseHashOverlayboth read it. Before it, the two hooks held byte-identical eighteen-line subscriptions whose only difference was which parser they called.
apps/webapp/src/utils/reportContent.tsis the canonical home. It opens a prefilledmailto:toLEGAL_CONTACT_EMAIL, the same constant/termsand/privacyread, so the pages and the product cannot disagree on the address. Do not add a second report path.- One live entry point.
reportCurrentDocumentis the Settings row incomponents/settings/SettingsPanel.tsx. It shows for signed-in users on a pad route and is hidden on Home. The chat row inuseMessageActionMenuItems.tsxstays atdisplay: 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.reportCurrentDocumentdrops the query for the same reason: a pad URL can carry?chatroom=and?msg_id=. - Build the string with
encodeURIComponent, neverURLSearchParams. 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
useReportMessageHandlerwrapper was built and deleted: the row holds no state, and the Delete row in the same file already inlinesopenDialogwithout a hook.