|
20 | 20 | - **Bun-only** for install/run (`bun install`, `bun run`, `bunx`); see [docs/engineering/toolchain.md](docs/engineering/toolchain.md) for phases, CI parity, and version policy. |
21 | 21 | - **Quality gates:** `bun run check` = lint + Prettier check + typecheck; `bun run check:full` adds Stylelint (used at end of `pre-push`); `bun run check:static` = lint + Prettier + Stylelint (no `tsc`, used in CI lint job). |
22 | 22 | - **Shared devtool versions** (ESLint, TypeScript, Prettier, Stylelint, etc.) are owned at the **repo root** — avoid drifting copies in leaf packages. |
| 23 | +- **Jest / library unit tests:** `jest`, `babel-jest`, `jest-environment-jsdom`, `@types/jest`, `@babel/preset-typescript` — **repo root `devDependencies` only** (single version pin). Library packages use a **local** `jest.config.cjs` (inline config is fine); no per-package Jest stack in `package.json`. **`@docs.plus/webapp`** keeps **`next/jest`** for the app suite; see `.cursor/rules/monorepo-jest.mdc`. |
23 | 24 | - **Tests:** root `test:all` → `scripts/run-tests.sh`; Jest (unit) + Cypress (E2E); parallel via `CYPRESS_PARALLEL` env. |
| 25 | +- **Unit test order:** `run-tests.sh` runs `@docs.plus/extension-indent` Jest (`jest.config.cjs` in that package) then `@docs.plus/webapp` Jest; webapp `test` uses `jest --passWithNoTests` so an empty or temporarily absent app Jest suite does not fail CI/local runs. |
24 | 26 | - Stay on ESLint 9.x / TypeScript 5.x until a dedicated migration — ESLint 10 and TS 6 have breaking changes. |
25 | 27 |
|
26 | 28 | ## Learned Workspace Facts |
|
29 | 31 | - Editor uses flat heading schema (`heading block*`) with decoration-based sections; `attrs['toc-id']` renders as `data-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 |
30 | 32 | - **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** or `y-sync$` meta; else **map** the decoration set. **Do not** replace this with fixed per-level pt maps — that breaks the agreed behavior. |
31 | 33 | - Editor perf: jank is React/Zustand re-renders, not ProseMirror; never put UI flags in `useEditor` deps; `shouldRerenderOnTransaction: false` on collab; decoration plugins should avoid full rebuilds on every keystroke — use `transactionAffectsNodeType(tr, 'heading')` **or** a cheaper structural check (HeadingScale uses **heading-level fingerprint**, not only `transactionAffectsNodeType`); placeholder uses `@docs.plus/extension-placeholder` (O(1) via state.init/apply) — do NOT replace with TipTap's built-in (O(N) doc.descendants) |
32 | | -- Zustand: monolithic 7-slice store; all `useStore` calls must use leaf selectors — never `(state) => state` or `(state) => state.settings` |
33 | | -- ProseMirror: `doc.nodeAt(pos)` can throw RangeError for out-of-range — guards must not assume null-only; `transaction.before` is the pre-step document `Node`, not `EditorState` — never call `PluginKey.getState(transaction.before)`; for fold-driven UI (e.g. TOC) snapshot heading-fold plugin state from `editor.state` and diff across transactions |
| 34 | +- Zustand: monolithic 7-slice store; all `useStore` calls must use leaf selectors — never `(state) => state` or `(state) => state.settings`. ProseMirror: `doc.nodeAt(pos)` can throw RangeError for out-of-range — guards must not assume null-only; `transaction.before` is the pre-step document `Node`, not `EditorState` — never call `PluginKey.getState(transaction.before)`; for fold-driven UI (e.g. TOC) snapshot heading-fold plugin state from `editor.state` and diff across transactions |
34 | 35 | - TipTap pad-only SCSS lives under `packages/webapp/src/styles/editor/` and loads via `styles.scss` → `components/_index.scss` → `@use '../editor'`; do not add parallel `.scss` next to TipTap extensions (single source of truth). **Pad chrome:** PadTitle `border-b` for header↔toolbar; `tiptap__toolbar` uses `border-b` only (no `border-t` against PadTitle); pad sheet top border from `_blocks.scss` for toolbar↔editor; mobile `.m_mobile .tiptap__toolbar` in `_blocks.scss` for floating bar. **Scrollbars:** shared `:root` tokens in `globals.scss`; `scrollbar-custom scrollbar-thin` on `.editorWrapper` and TOC `ScrollArea` — one system, avoid ad-hoc scrollbar styling on the pad column |
35 | | -- Production: docker-compose.prod.yml with Traefik; dev compose backend services need `context: .` (repo root) to match Dockerfile.bun |
| 36 | +- Production: docker-compose.prod.yml with Traefik; dev compose backend services need `context: .` (repo root) to match Dockerfile.bun. **Hocuspocus image:** `migration-extensions.ts` imports `@docs.plus/extension-hypermultimedia` and `@docs.plus/extension-inline-code` at runtime (`main` → `dist/`); root `.dockerignore` excludes `**/dist`, so those packages must be **built inside the image** — copying only `package.json` stubs is not enough (other `@docs.plus/extension-*` may stay stubs for lockfile/workspace only). **Prod WebSocket issues:** verify `hocuspocus` containers are healthy and read `docker logs` before chasing Traefik; crash loops often explain edge 404s or no backend. |
36 | 37 | - Supabase client architecture (Pages Router): browser singleton at `utils/supabase/index.ts`, factory in `component.ts`, GSSP in `server-props.ts`, API route in `api.ts`, URL resolver in `url.ts`; all browser code imports `supabaseClient` singleton; `types/supabase.ts` for generated DB types |
37 | | -- Standalone extension packages (`extension-hyperlink`, `-hypermultimedia`, `-indent`, `-inline-code`, `-placeholder`) share identical structure: TypeScript + tsup build + `@tiptap/core` peer dep; GFM markdown via `@tiptap/markdown`, paste at `extensions/markdown-paste/`, import/export in `utils/markdown.ts` + `toolbar/desktop/DocumentSettingsPanel`; `sanitizeJsonContent` on paste and import paths |
| 38 | +- Standalone extension packages (`extension-hyperlink`, `-hypermultimedia`, `-indent`, `-inline-code`, `-placeholder`) share identical structure: TypeScript + tsup build + `@tiptap/core` peer dep; GFM markdown via `@tiptap/markdown`, paste at `extensions/markdown-paste/`, import/export in `utils/markdown.ts` + `toolbar/desktop/DocumentSettingsPanel`; `sanitizeJsonContent` on paste and import paths. **`@docs.plus/extension-indent`:** keep pad (`TipTap.tsx`) and chat composer (`useTiptapEditor`) on the same `Indent.configure({ indentChars: '\t' })` (or widen together). Gating: **`allowedIndentContexts`** — allowlist of **`{ textblock, parent }`** pairs (TipTap `type.name`) where literal indent/outdent runs; default body + blockquote **paragraphs** only; **`[]`** disables literal indent. Tab / Shift-Tab: sink/lift list (`listItem` / `taskItem` when in schema) → table cell nav when table extension is present → literal indent/outdent; extension **`priority` 25** + delegation. Pad/chat default is **paragraph** under **doc** / **blockquote** only; other textblocks need explicit `allowedIndentContexts` rules. Cypress: `packages/webapp/cypress/e2e/editor/indent/`; Jest: `packages/extension-indent`. |
38 | 39 | - **Document version history (Hocuspocus):** Stateless `history.list` / `history.watch`; server **unicasts** `{ msg: 'history.response', type, response }` on the requesting connection (not `broadcastStateless`). **Prisma always uses the collab room’s document id** (Hocuspocus `document.name`); if the client sends a different `documentId`, respond `history_failed`. Current `history.list` returns **`{ versions, latestSnapshot }`** for one RTT; client still accepts a legacy plain `HistoryItem[]`. **`applyHistoryItemToEditor`** (`pages/history/applyHistoryToEditor.ts`) is the single TipTap hydration path. **`loadingHistory` clears only after a successful apply** (not merely after the network response); **`useHistoryEditorApplyWhenReady`** applies when the editor mounts after data arrives; while **`pendingWatchVersion` is set** (in-flight `history.watch`), the apply-when-ready hook must **not** re-apply stale `activeHistory`, and **late `history.list` must not** reset pending or hydrate from `latestSnapshot` over that watch. On **`history_failed`**, clear **`pendingWatchVersion`** so the next watch isn’t dropped |
39 | 40 | - TOC + heading chrome: `components/toc/` with `tocClasses.ts` kept in sync with `styles/components/_tableOfContents.scss`; `--color-docsy` = `var(--color-primary)` in both `@theme` and `:root` (globals.scss), auto-tracks DaisyUI light/dark/HC; heading widgets in `TipTap/extensions/HeadingActions/plugins/` (`hoverChatPlugin`, `selectionChatPlugin`) styled by `styles/components/_heading-actions.scss` (shared `$ha-hit-size` with plugins, DRY `$ha-group-has-unread` `:has()` selector for unread tray visibility); `_unread-badge.scss` only styles `[data-unread-count]` on `.ha-chat-btn` + notification bell — no `.toc__chat-trigger`/`.ha-group` rules; TOC uses React `UnreadBadge` only, `UNREAD_SYNC` clears `data-unread-count` on `.toc__chat-trigger`; active chat icon uses `toc__chat-icon--active` class with `fill: none` (Lucide icons are stroke-based); when nested `ul.toc__children` lives under the parent `li`, hide folded subtrees with `&.closed > .toc__children { display: none }` — fold class still comes from editor state, not CSS alone. **TOC data path:** `useToc.ts` throttles heading-driven TOC rebuilds (`lodash/throttle`); flat heading list → recursive `NestedTocNode` tree via `buildNestedToc` at `TocDesktop`/`TocMobile` roots (`utils.ts`); `useHeadingScrollSpy.ts` debounces scroll/active-heading work (`lodash/debounce`) |
40 | 41 | - Heading fold crinkle: widget decoration driven by `data-fold-phase` attr for CSS animation; unique `Decoration.widget` key per phase (`fold-${id}-folding`/`-unfolding`/`-${id}`) forces ProseMirror remount so animation fires each toggle; width uses `margin-left/right: calc(-1 * var(--tiptap-inline-pad-end))` to span full sheet; SCSS variables `$crinkle-fold-duration`/`$crinkle-easing` for timing (no CSS custom properties); `Decoration.node` on heading-section removed — animations live on the widget itself; strip count uses `MIN_FOLD_STRIPS` / `MAX_FOLD_STRIPS` / `CONTENT_HEIGHT_PER_STRIP` in `heading-fold-plugin.ts` — if `MIN_FOLD_STRIPS === MAX_FOLD_STRIPS`, strip count is fixed regardless of content height |
0 commit comments