- Use Bun exclusively — never npm/yarn/pnpm (
bun install,bun add,bun run,bunx) - Do not commit unless explicitly asked; short descriptive messages, no AI/Cursor branding or internal doc references
- Production code quality: DRY, KISS, SOLID, industry-standard; avoid overengineering and overthinking; export names must match file names; fix typos in identifiers on every refactor
- TDD for fixes/features; schema-first test design; fix behavior when the spec is wrong — don't weaken tests to match broken behavior
- Always run
bun run buildafter major refactors to verify type-safety before claiming completion - Before large migrations, audit all config/doc/rule paths (.cursor/rules, Notes/, AGENTS.md) for stale references to removed concepts
- Notes/ folder is local-only (.gitignore); never commit local notes or plans;
.cursor/hooks/state/continual-learning.jsonandcontinual-learning-index.jsonare local hook churn — keep out of commits (restore if staged). Committed agent context for regressions:AGENTS.md(e.g. Mobile document pad (iOS Safari) under Learned Workspace Facts) — mirror important findings there; do not rely only on the continual-learning plugin JSON - Keep debug/info loggers on editor core paths; don't strip them for "cleanup"
- Cypress tests: split by concern with README for scope; kebab-case dirs,
it()nottest(); consolidate overlapping tests - Theme/UI color consistency is first-class — all surfaces (including third-party pickers) must follow design tokens
- Full-doc paste (⌘A→⌘V) is a critical scenario to validate
- User sometimes prefers commands/instructions to run locally rather than the agent auto-installing
- Bun-only for install/run (
bun install,bun run,bunx); see docs/engineering/toolchain.md for phases, CI parity, and version policy. - Quality gates:
bun run check= lint + Prettier check + typecheck;bun run check:fulladds Stylelint (used at end ofpre-push);bun run check:static= lint + Prettier + Stylelint (notsc, used in CI lint job). - Shared devtool versions (ESLint, TypeScript, Prettier, Stylelint, etc.) are owned at the repo root — avoid drifting copies in leaf packages; root
catalog:inpackage.jsoncentralizes pins where used — workspaces reference matching deps as\"package\": \"catalog:\". - Jest / library unit tests:
jest,babel-jest,jest-environment-jsdom,@types/jest,@babel/preset-typescript— repo rootdevDependenciesonly (single version pin). Library packages use a localjest.config.cjs(inline config is fine); no per-package Jest stack inpackage.json.@docs.plus/webappkeepsnext/jestfor the app suite; see.cursor/rules/monorepo-jest.mdc. - Tests: root
test:all→scripts/run-tests.sh; Jest (unit) + Cypress (E2E); parallel viaCYPRESS_PARALLELenv. - Unit test order:
run-tests.shruns@docs.plus/extension-indentJest (jest.config.cjsin that package) then@docs.plus/webappJest; webapptestusesjest --passWithNoTestsso an empty or temporarily absent app Jest suite does not fail CI/local runs. - Dependency updates:
npm-check-updatesand per-packageupdate:packagesscripts are removed;scripts/reinstall-packages.sh/reinstall:all-packagesare gone. Usebun updatefrom the repo root, orbun run update:all-packages(scripts/update-packages.sh), thenbun installat root if the lockfile or install tree needs healing. Do not run parallelbun updatein multiplepackages/*directories — sharedbun.lock/ hoisted installs can race and fail withEEXIST. - Stay on ESLint 9.x / TypeScript 5.x until a dedicated migration — ESLint 10 and TS 6 have breaking changes.
- docs.plus / docsy — Bun monorepo (packages/*); main app @docs.plus/webapp (Next.js Pages Router), backend @docs.plus/hocuspocus; editor code under packages/webapp/src/components/TipTap/ (extensions, nodes, plugins);
src/lib/removed — all shared utilities insrc/utils/, feature-local helpers stay colocated; server-sideTiptapTransformer.toYdoc/ nested→flat migration must use an extension set that covers every node/mark in stored docs (e.g. TaskList / TaskItem from@tiptap/extension-listaligned with the webapp), not StarterKit alone — missing types fail encode, not flatten logic; batch migrations must fail closed (do not overwrite stored Yjs for a doc when transform/encode fails — keep prior bytes and surface the doc id); run migration CLI viabun run migrate:nested-to-flatfrompackages/hocuspocus.serverafter rootbun install— invoking the script path alone from an arbitrary cwd can break Bun resolution ofyjsfor@hocuspocus/transformer - Editor uses flat heading schema (
heading block*) with decoration-based sections;attrs['toc-id']renders asdata-toc-id; shared heading utilities (computeSection, moveSection, canMapDecorations, transactionAffectsNodeType, matchSections) in TipTap/extensions/shared/; section reorder is TOC-only (useTocDrag/moveHeading+moveSection) — there is no in-editor heading drag handle extension - HeadingScale (mandatory spec):
extensions/heading-scale/heading-scale.ts— dynamic heading font size by rank within a section, not fixed per HTML level or a Google-style ladder. Each H1 starts a new section; within a section, distinct heading levels are sorted and sizes are interpolated evenly between 20pt (max) and 12pt (min); same level twice in one section → same visual size; one distinct level in a section → 20pt. Title (first top-level H1) is included as part of section 1. Decorations only (--hd-size,--hd-rank,--hd-total); never write sizes into the document. Plugin state{ fingerprint, decorations }with fingerprint = top-level heading levels in order (e.g.1,2,4,1,3); full rebuild when fingerprint changes ory-sync$meta; else map the decoration set. Do not replace this with fixed per-level pt maps — that breaks the agreed behavior. - Editor perf: jank is React/Zustand re-renders, not ProseMirror; never put UI flags in
useEditordeps;shouldRerenderOnTransaction: falseon collab; decoration plugins should avoid full rebuilds on every keystroke — usetransactionAffectsNodeType(tr, 'heading')or a cheaper structural check (HeadingScale uses heading-level fingerprint, not onlytransactionAffectsNodeType); placeholder uses@docs.plus/extension-placeholder(O(1) via state.init/apply) — do NOT replace with TipTap's built-in (O(N) doc.descendants) - Zustand: monolithic 7-slice store; all
useStorecalls must use leaf selectors — never(state) => stateor(state) => state.settings. ProseMirror:doc.nodeAt(pos)can throw RangeError for out-of-range — guards must not assume null-only;transaction.beforeis the pre-step documentNode, notEditorState— never callPluginKey.getState(transaction.before); for fold-driven UI (e.g. TOC) snapshot heading-fold plugin state fromeditor.stateand diff across transactions - TipTap pad-only SCSS lives under
packages/webapp/src/styles/editor/and loads viastyles.scss→components/_index.scss→@use '../editor'; do not add parallel.scssnext to TipTap extensions (single source of truth). Pad chrome: PadTitleborder-bfor header↔toolbar;tiptap__toolbarusesborder-bonly (noborder-tagainst PadTitle); pad sheet top border from_blocks.scssfor toolbar↔editor; mobile.m_mobile .tiptap__toolbarin_blocks.scssfor floating bar. Scrollbars: shared:roottokens inglobals.scss;scrollbar-custom scrollbar-thinon.editorWrapperand TOCScrollArea— one system, avoid ad-hoc scrollbar styling on the pad column - Mobile document pad (iOS Safari) —
packages/webapp(html.m_mobileinstyles/_mobile.scss):html/bodyposition: fixed;.mobileLayoutRoottrackswindow.visualViewportviasyncVisualViewportToCssVars(utils/visualViewportCss.ts) andAppProvidersvisualViewportresize + scroll (rAF-coalesced). Do not skip CSS sync when height deltas are small — after a large keyboard resize, WebKit can emit further sub-threshold steps; stale--visual-viewport-heightleaves a dead band above Safari’s accessory bar.useVisualViewportCssSyncOnFocus(hooks/useVisualViewportCssSyncOnFocus.ts):focusin(capture) on.mobileLayoutRoot .tiptap__editor.docy_editor(pad + mobile history) re-runssyncVisualViewportToCssVarswhen a finalresizeis missing. Do not usetransform: translateZ(0)on.editor.editorWrapperorcontain/will-change: heighton.mobileLayoutRoot— WebKit often mis-paints the contenteditable caret (e.g. over the pad header). In-pad scroll targets:scrollElementInMobilePadEditor(utils/scrollMobilePadEditor.ts) for headings/TOC/deep links; avoid rawElement.scrollIntoViewon doc nodes — it scrolls the layout viewport and fights fixed chrome + CSS vars. Virtual keyboard / store:innerHeight - visualViewport.heightcan stay 0 while the keyboard is up after cycles; useapplyVirtualKeyboardToStoreinutils/virtualKeyboardMetrics.ts(peakvisualViewport.heightwhen closed,documentElement.clientHeight - vvh,scrollY + vvhwhenwindow.scrollY > 0).useVirtualKeyboardandnudgeVirtualKeyboardOpenFromVisualViewportboth call that path; listen to vv scroll and resize.useEditableDocControl: neverisEditable = isKeyboardOpenon every effect — keyboard opens beforeresize; only clearisEditableon keyboard close (true → false). The 500ms DOM sync must not setcontenteditable: falsewhensettings.editor.isEditableis still true.AppProviders: if.mobileLayoutRoot,visualViewport.offsetTop > 0, andwindow.scrollY > 0,window.scrollTo(0, 0)— layout scroll breaks fixed-shellgetBoundingClientRectfor ProseMirror. Edit entry:EditFABand double-tap shareenableAndFocus()(hooks/useCaretPosition.ts); FAB usesonTouchEnd+ suppress syntheticclick.enableAndFocus:editor.commands.focus()only — do not chain TipTapscrollIntoView()withensureCaretVisible/scrollCaretIntoView(double nudge). Mobile caret scroll:behavior: 'auto';ensureCaretVisible: 2× rAF + one ~300ms retry - Production: docker-compose.prod.yml with Traefik; dev compose backend services need
context: .(repo root) to match Dockerfile.bun. Hocuspocus image:migration-extensions.tsimports@docs.plus/extension-hypermultimediaand@docs.plus/extension-inline-codeat runtime (main→dist/); root.dockerignoreexcludes**/dist, so those packages must be built inside the image — copying onlypackage.jsonstubs is not enough (other@docs.plus/extension-*may stay stubs for lockfile/workspace only). Prod WebSocket issues: verifyhocuspocuscontainers are healthy and readdocker logsbefore chasing Traefik; crash loops often explain edge 404s or no backend. - Supabase client architecture (Pages Router): browser singleton at
utils/supabase/index.ts, factory incomponent.ts, GSSP inserver-props.ts, API route inapi.ts, URL resolver inurl.ts; all browser code importssupabaseClientsingleton;types/supabase.tsfor generated DB types - Standalone extension packages (
extension-hyperlink,-hypermultimedia,-indent,-inline-code,-placeholder) share identical structure: TypeScript + tsup build +@tiptap/corepeer dep; GFM markdown via@tiptap/markdown, paste atextensions/markdown-paste/, import/export inutils/markdown.ts+toolbar/desktop/DocumentSettingsPanel;sanitizeJsonContenton paste and import paths.@docs.plus/extension-indent: keep pad (TipTap.tsx) and chat composer (useTiptapEditor) on the sameIndent.configure({ indentChars: '\t' })(or widen together). Gating:allowedIndentContexts— allowlist of{ textblock, parent }pairs (TipTaptype.name) where literal indent/outdent runs; default body + blockquote paragraphs only;[]disables literal indent. Tab / Shift-Tab: sink/lift list (listItem/taskItemwhen in schema) → table cell nav when table extension is present → literal indent/outdent; extensionpriority25 + delegation. Pad/chat default is paragraph under doc / blockquote only; other textblocks need explicitallowedIndentContextsrules. Cypress:packages/webapp/cypress/e2e/editor/indent/; Jest:packages/extension-indent. - Document version history (Hocuspocus): Stateless
history.list/history.watch; server unicasts{ msg: 'history.response', type, response }on the requesting connection (notbroadcastStateless). Prisma always uses the collab room’s document id (Hocuspocusdocument.name); if the client sends a differentdocumentId, respondhistory_failed. Currenthistory.listreturns{ versions, latestSnapshot }for one RTT; client still accepts a legacy plainHistoryItem[].applyHistoryItemToEditor(pages/history/applyHistoryToEditor.ts) is the single TipTap hydration path.loadingHistoryclears only after a successful apply (not merely after the network response);useHistoryEditorApplyWhenReadyapplies when the editor mounts after data arrives; whilependingWatchVersionis set (in-flighthistory.watch), the apply-when-ready hook must not re-apply staleactiveHistory, and latehistory.listmust not reset pending or hydrate fromlatestSnapshotover that watch. Onhistory_failed, clearpendingWatchVersionso the next watch isn’t dropped. Shareable revision URLs: same pathname/query +#history?version=<n>with<n>=HistoryItem.version;pages/history/historyShareUrl.ts—parseHistoryHash,buildHistoryShareUrl,replaceHistoryHashVersion - TOC + heading chrome:
components/toc/withtocClasses.tskept in sync withstyles/components/_tableOfContents.scss;--color-docsy=var(--color-primary)in both@themeand:root(globals.scss), auto-tracks DaisyUI light/dark/HC; heading widgets inTipTap/extensions/HeadingActions/plugins/(hoverChatPlugin,selectionChatPlugin) styled bystyles/components/_heading-actions.scss(shared$ha-hit-sizewith plugins, DRY$ha-group-has-unread:has()selector for unread tray visibility);_unread-badge.scssonly styles[data-unread-count]on.ha-chat-btn+ notification bell — no.toc__chat-trigger/.ha-grouprules; TOC uses ReactUnreadBadgeonly,UNREAD_SYNCclearsdata-unread-counton.toc__chat-trigger; active chat icon usestoc__chat-icon--activeclass withfill: none(Lucide icons are stroke-based); when nestedul.toc__childrenlives under the parentli, hide folded subtrees with&.closed > .toc__children { display: none }— fold class still comes from editor state, not CSS alone. TOC data path:useToc.tsthrottles heading-driven TOC rebuilds (lodash/throttle); flat heading list → recursiveNestedTocNodetree viabuildNestedTocatTocDesktop/TocMobileroots (utils.ts);useHeadingScrollSpy.tsdebounces scroll/active-heading work (lodash/debounce) - Heading fold crinkle: widget decoration driven by
data-fold-phaseattr for CSS animation; uniqueDecoration.widgetkey per phase (fold-${id}-folding/-unfolding/-${id}) forces ProseMirror remount so animation fires each toggle; width usesmargin-left/right: calc(-1 * var(--tiptap-inline-pad-end))to span full sheet; SCSS variables$crinkle-fold-duration/$crinkle-easingfor timing (no CSS custom properties);Decoration.nodeon heading-section removed — animations live on the widget itself; strip count usesMIN_FOLD_STRIPS/MAX_FOLD_STRIPS/CONTENT_HEIGHT_PER_STRIPinheading-fold-plugin.ts— ifMIN_FOLD_STRIPS === MAX_FOLD_STRIPS, strip count is fixed regardless of content height