Skip to content

Latest commit

 

History

History
393 lines (324 loc) · 86.7 KB

File metadata and controls

393 lines (324 loc) · 86.7 KB

AGENTS.md

Persistent memory for AI agents working on docs.plus. Preserve these rules unless a maintainer explicitly changes them.

Task router

A task is usually governed by sections filed far from it, so reading in heading order misses rules. Find the task here and read every section listed. Sections not in this file carry their home in brackets — see §Filed by directory for the full map.

  • Any task → §Memory And Rule Boundaries · §Package Manager · §Git And Commits · §Simplified English Mandate · §Workflow And Review Expectations
  • Write or change a test → §Test Policy · §Test Orchestration And Authoring · §Workspace — playground harness · §Standalone Extension Development — round-trip specs [extensions] · §Hypermultimedia Extension — assert rendered DOM, not node attrs [extensions] · §Indent Extension [extensions] · §Webapp-Owned Hyperlink Popovers — data-testid only [extensions] · §Chat Media Attachments [chatroom]
  • Add or bump a dependency → §Package Manager · §Dependencies · §Shared Library Config · §ESLint Config · §Docker · §Test Orchestration And Authoring · §Learned Workspace Facts
  • Build or change a publishable extension → extensions/CLAUDE.md (whole file) · §Shared Library Config · §Extension Package Contract [release-extensions skill] · §Docker
  • Publish or cut a release → RELEASE_POLICY.md (authoritative) · the release-extensions skill · §Git And Commits · §Skills And Prose
  • Editor / ProseMirror → apps/webapp/src/components/TipTap/CLAUDE.md (whole file) · §TipTap Styling [webapp]
  • Webapp UI, styling, theme → .cursor/docs/design-system.md (owns tokens and recipes) · §UI And Theme · apps/webapp/CLAUDE.md (whole file — module layers, pad surfaces, mobile shells, overlays, motion, slug entry, landing)
  • Chatroom or messaging → apps/webapp/src/components/chatroom/CLAUDE.md (whole file) · §Composer Link Dialog And Internal Links [extensions]
  • Document features → apps/webapp/CLAUDE.md §Document Features (access, version history, TOC, comments, filters, panels)
  • Backend, hocuspocus, REST → apps/hocuspocus.server/CLAUDE.md (whole file) · §Docker
  • Next product APIs, Validate, Status, Confirm, Health → §Next Product APIs [webapp] · §HTTP Modules [hocuspocus] · §Product HTTP · §Production And Docker Compose [hocuspocus]
  • Admin dashboard → apps/admin-dashboard/CLAUDE.md (whole file) · §Admin API And Dashboard [hocuspocus]
  • Supabase, SQL, RLS → packages/supabase/CLAUDE.md · §Document Access [webapp] · §Anonymous Chat Read Path [chatroom] · §Admin API And Dashboard [hocuspocus] · §Learned Workspace Facts · .cursor/rules/supabase.mdc
  • Docker, CI, deploy → §Docker · §Production And Docker Compose [hocuspocus] · §Workspace · §ESLint Config
  • Write a standalone script → §Standalone Bun Scripts · §Workspace · §Package Manager
  • Prose: README, CHANGELOG, report, JSDoc → §Skills And Prose · §Documentation And Comments · §Code Quality · §Extension Package Contract [release-extensions skill]
  • Refactor or cleanup → §Code Quality · §Workflow And Review Expectations · §Webapp Module Layers [webapp] · §Test Policy · §Skills And Prose

Agent Operating Rules

Memory And Rule Boundaries

  • AGENTS.md stores durable docs.plus-specific invariants, maintainer preferences, and regression guardrails.
  • Keep large vendor or mechanical references in focused Cursor rules, not copied here:
    • .cursor/rules/design-system.mdc: design-system pointer + cardinal UI rules (source of truth: .cursor/docs/design-system.md; vendor daisyUI class reference is fetched from https://daisyui.com/llms.txt on demand — no longer vendored).
    • .cursor/rules/react-floating-ui.mdc: React 19 ref and Floating UI interaction patterns.
    • .cursor/rules/supabase.mdc: SQL authoring style and focused Supabase file warnings.
    • .cursor/rules/tiptap.mdc: upstream Tiptap/ProseMirror reference workflow.
    • .cursor/rules/scripts-naming.mdc: scripts and Make-target naming convention; auto-attaches when editing package.json, Makefile, workflows, or files under scripts/.
  • Long-form policy docs that an .mdc rule points at live in .cursor/docs/. They are design-system.md, scripts-naming-convention.md, extension-version-cutover.md, and panel-feed-seams.md. design-system.md is the source of truth for the webapp visual language — tokens, themes, elevation species, state recipes, component catalog. scripts-naming-convention.md is the timeless rule and the source of truth for scripts naming. extension-version-cutover.md carries rotating per-package cutover state; see §Extension Version Doctrine. One-shot migration docs may live alongside as siblings and are deleted with the cutover PR that completes them.
  • Durable agent memory lives at .agents/memory/, a symlink to the Claude Code memory store, so Claude Code and Cursor read one copy. .agents/memory/MEMORY.md is the index. It is gitignored and stays that way — it records production hosts, project refs and infra details. Cursor reaches it through .cursor/rules/agent-rules-and-memory.mdc, which also carries the read order, because Cursor does not read CLAUDE.md files at all. Any rule filed only in a CLAUDE.md is invisible in Cursor unless that map names it.
  • The Grep tool does not follow that symlink, and it reports no matches rather than an error. A search of .agents/memory therefore looks clean while the text is really there. Measured 2026-08-19: Grep returned nothing where shell grep -rl .agents/memory/ returned two files. Never conclude from Grep alone that a memory entry is missing, or that a stale fact is already gone. Name the store with a trailing slash — grep -rl <pattern> .agents/memory/. Searching from .agents/ finds nothing under either -r or -R, because neither descends through the link, and find needs -L for the same reason. Read follows the link on a full path.
  • Local working notes live in Notes/ (gitignored). Current reports, briefs, and the hocuspocus backlog sit in Notes/local-docs/. Start at that folder's INDEX.md. Open it when the task is inject-content, Last left, occupancy, restore, REST gaps, scale, auth, composer, extension launch, TOC rail, or chat sash. The Grep tool skips this tree. Search with grep -rl <pattern> Notes/local-docs/. Do not put these files in docs/. Security rows stay here and never become public issues.
  • CONTEXT.md at the repo root is the domain glossary (document-access terms and other named seams). Architecture reviews and deepenings use those names; do not invent parallel vocabulary when a glossary entry already exists.
  • Package-internal rules that don't generalize to the repo live in package-local AGENTS.md files next to the package. Today: extensions/extension-hyperlink/AGENTS.md for that extension's schema, commands, safety, click/preview, and clean-room harness. Cross-package rules (release flow, scripts naming, monorepo toolchain) stay in this root file. Read the package file in addition to the root file when working inside its package.
  • Directory-scoped rules live in per-directory CLAUDE.md files that Claude Code loads only when working under that path — see §Filed by directory. New rules go to the file that owns the directory, not back into this one. A rule that spans directories or names a prohibition stays here. Keep the §Filed by directory map current when a section moves.
  • How the per-directory files actually load (measured 2026-08-03, controlled probes):
    • Reading any file under a directory injects that directory's CLAUDE.md and every ancestor's, in full, automatically.
    • Never Read a CLAUDE.md with limit or offset. A partial read suppresses the automatic full load for the rest of the session. You keep the few lines you asked for and silently lose the rest. Read it whole or not at all.
    • Bash file access (head, grep, cat) does not trigger the load. A session that only shells out never gets the rules.
    • Root CLAUDE.md arrives at session start, not through the path trigger.
    • Untested: the Grep tool, and Edit with no prior Read. Assume neither triggers the load, and open the area file deliberately when planning.
  • When guidance overlaps, keep the project-specific policy in AGENTS.md or .cursor/docs/, and the detailed authoring/reference material in the relevant .mdc file.
  • New rules land in the topical section that owns the subject. ## Learned User Preferences and ## Learned Workspace Facts at the end of this file are inboxes only. Append there when no section fits, one rule per bullet. File inbox entries into their owning section on the next tidy.

Package Manager

  • Use Bun for package management, scripts, binaries, packing, and publishing: bun install, bun add, bun add -d, bun run, bunx, bun publish, bun pm pack.
  • Never use npm, yarn, pnpm, npx, npm publish, or package-lock/yarn-lock flows.
  • Keep bun.lock as the only lockfile. Do not create package-lock.json, yarn.lock, or pnpm-lock.yaml.
  • The user sometimes prefers local command instructions over agent-run installs. Ask or provide the command when dependency changes are not clearly part of the requested task.
  • Use root workspace commands:
    • bun run --filter @docs.plus/webapp dev
    • bun run --filter '*' build
  • Required engines: Node >= 24.11.0 and Bun >= 1.4.0. Dockerfiles and CI runners must not drop below either floor.

Git And Commits

  • Do not commit unless the user explicitly asks.
  • When authoring implementation or execution plans (e.g. superpowers writing-plans, documents under Notes/superpowers/plans/), do not include commit messages, git commit, git add, or generic “commit the changes” steps. End with review and verification only; the developer inspects the diff and commits manually.
  • .superpowers/ and Notes/ are gitignored (local agent scratch and maintainer notes). Do not force-add or commit either. Plans live in Notes/superpowers/plans/. docs/ is tracked, public reader documentation. Never put a plan, a review, or a private note there. Mixing both roles inside docs/ behind ! negations was built and rejected on 2026-08-19. One careless git add docs/ then leaks megabytes of plans and audits.
  • Never write an unanchored bare filename in .gitignore. A pattern with no leading slash matches at any depth. core.ignorecase is true on this macOS checkout, so QUICKSTART.md also swallowed docs/api/quickstart.md. The file never appeared in git status and nothing reported an error. The four "Sensitive documentation" patterns are root-anchored for that reason. After any new ignore rule, prove the result with git check-ignore -v <path>.
  • When executing multi-task plans from Notes/superpowers/plans/, every task ends in a "Review checkpoint" step. Show the touched files and a short summary for review, then stop. Do not run git add, git commit, git push, git stash, or amend. Quality gates (bun run … typecheck|test|build) still run between tasks.
  • Execute plans only in the current workspace (this git worktree). Do not switch execution to another worktree, path, or parallel checkout; all edits, shell commands, and tests must run against the open repo root.
  • Work on main only. Never create a new branch or worktree for any task, step, plan, or subagent. Do not run git checkout -b, git switch -c, git worktree add, or pass isolation: "worktree" to the Agent tool. No "feature branch", no "isolated worktree per task", no "let's branch off main for this fix". All edits happen on the currently checked-out main working tree. The user manages branching themselves outside the agent session.
  • This rule applies to every agent and subagent without exception. It covers every subagent dispatch: the Agent tool, general-purpose, Explore, Plan, and specialized agents like code-reviewer, frontend-developer, tdd-orchestrator, etc. The dispatching prompt must explicitly state "work on the current main working tree; do not create branches or worktrees." Never set isolation: "worktree". Skills (superpowers:using-git-worktrees, superpowers:executing-plans, superpowers:subagent-driven-development, etc.) that suggest isolating per-task are overridden — stay on main. Supervisor, orchestrator, and parallel-agent flows all share the same main working tree.

Release Safety

The release procedure lives in the release-extensions skill, which loads only when invoked. These prohibitions stay here because they bite in sessions that never invoke it — editing a workflow, tidying package.json, or adding a changelog tool.

  • Never put NPM_TOKEN in CI for publishing. npm 2FA-on-write needs an OTP, so publishing happens on the maintainer laptop only.
  • Never run git push --tags. The release script pushes an explicit tag list.
  • Do not reintroduce the removed root scripts release, release:major, release:minor, release:patch, version*, or a parallel publish. The only release entry point is release:family.
  • Do not auto-generate CHANGELOG entries from commit subjects. Lerna, Changesets and Release-Please are deliberately not adopted.
  • Releases are stable-only: no @next dist-tag, no soak window, no promotion step.

Code Quality

  • Keep production code DRY, KISS, YAGNI, SOLID, and industry-standard. Avoid overengineering.
  • Export names must match file names. Fix typos in identifiers during refactors.
  • Vocabulary — the word “chrome” is banned for UI everywhere: symbols, filenames, comments, and docs (2026-07-07 sweep removed every use). It is pre-Google GUI jargon for the trim around content; today it reads as the browser and states nothing about the job. Say what the thing is: *Layout for responsive shells, *Bar for toolbars, *Surface for anchored panels. In prose use “shell” (layout wrapper), “frame” (border/shadow/background styling — the catalog-row label in design-system.md), “surfaces” (docked regions), “controls” (media overlay/hover UI), or plain “UI”. “Chrome” may only ever mean the browser. Reserve Editor / EditorContent for the TipTap/ProseMirror host only — not for a mobile/desktop layout switch. Chat composer canonical exports: ComposerLayout, ComposerDesktopLayout, ComposerMobileLayout, and MessageComposer.EditorContent (TipTap). Do not reintroduce ComposerDesktopChrome, ComposerMobileChrome, or MessageComposer.Editor for the layout picker.
  • Popover vs toolbar — two axes, never one word for both. "Popover" names the positioning container only (anchored, floating, light-dismiss — the floating-popover engine; shells are role-less by default because ARIA has no popover role). Content words name what's inside and carry the matching role. Use toolbar for action rows (the hypermultimedia media bar, hyperlink's preview). Use dialog for floating forms (hyperlink create/edit). Compose when both axes apply (openToolbarPopover). Popover controller kinds are consumer-namespaced open strings (e.g. media-…). Do not rename the popovers.* / mediaToolbar option surfaces onto a single shared word — they correctly name different species. Glossary: extensions/README.md §Vocabulary.
  • No *Classes.ts / *Styles.ts / *ClassNames.ts modules for Tailwind className constants. Extracting 'pointer-events-none shrink-0 stroke-[1.75]' into a named export is over-engineering. Tailwind atoms are already the design tokens, and a second naming layer adds an indirection the reader has to chase without adding meaning. Inline className strings at the point of use (and accept the visual repetition across sibling components — it is locality of behavior, not a DRY violation). The same rule applies to local module-scope const FOO_BTN = '...' for single-file use. If two or more components genuinely share a non-trivial composite, lift to a shared component (<IconButton variant="composer-icon">) or a CVA-style helper — never to a bare-strings module. Pad and chat composer canonical: each toolbar/action button file owns its own className literal; the parent layout file (FormattingToolbar.tsx) owns the layout literals. Do not reintroduce formatToolbarClasses.ts, composerActionClasses.ts, or analogous files.
  • Feature folders use one central type module: types.ts or types/index.ts. Do not scatter feature-owned type / interface declarations across hooks/, commands/, utils/, stores/, or components/.
  • Keep feature layers separate and navigable: index.ts -> types.ts -> hooks/ / commands/ / utils/ / stores/ / components/.
  • Treat performance and memory leaks as part of production readiness, not follow-up work. Audit Tiptap/ProseMirror changes for re-render storms, unsubscribed listeners, and detached views.
  • Any editor.on(...) or getDefaultController().subscribe(...) call must return and call its unsubscribe on unmount unless it is intentionally module-scoped and guarded.
  • Keep debug/info loggers on editor core paths. Do not strip them during cleanup.

Test Policy

  • Default: do not write new tests. Add a test only in these three cases. (a) The user explicitly asks for it. (b) The change pins down a regression that has actually shipped or been reported. (c) The failure mode is a real branching / ordering / race / parsing / projection bug that is hard to verify by hand. If you cannot name the specific failure mode in one sentence, do not write the test. "It seemed like a good idea to add coverage" is not a reason.
  • Prefer integration over unit. When a test is warranted, default to Cypress E2E or real-stack integration that exercises actual user behavior end-to-end. Reach for a unit test only when the unit has branching logic dense enough that an E2E could not isolate a regression to it. Those units are parsers, projections, schema validators, pure utilities, message-grouping projections, and scroll-mode state machines.
  • Never write these test shapes (delete on sight, do not generate): type assertions that re-prove what tsc already proves; framework-behavior tests ("React renders", "Tiptap commands return truthy", "Supabase client exists", "useEffect runs"); mock-only flows where every dependency is faked and the assertion is "the mock was called with X"; snapshots of unstable output (full DOM trees, formatted JSON dumps, ProseMirror node JSON); "renders without crashing" smoke tests; trivial props-passthrough / getter / setter tests; coverage-chasing tests with no named behavior.
  • TDD is opt-in, not default. Follow strict TDD discipline only when the user explicitly asks for it ("write a test first", "let's TDD this", "/tdd"). This overrides any subagent skill that defaults to TDD on every fix or feature. Otherwise: design the change, ship it, verify with bun run build / bun run check and the relevant Cypress suite, and stop.
  • Observe tests pass before declaring done. Any test added in a change must be run locally and observed green — "should pass" is not evidence. Pre-existing tests that fail are fixed in the same change or explicitly .skip'd with a one-line reason and an issue link; never silently disabled.
  • When unsure, ask. If you are mid-test and cannot articulate in one sentence which user-visible failure it prevents, stop and ask whether the test is wanted at all. The cost of a low-value test is review burden, maintenance, and false confidence. That cost is higher than the cost of a missing test we can add later when a real bug appears.
  • Run bun run build after major refactors before claiming completion. Never run next build (or any production build) while a dev server is running against the same .next directory. The production BUILD_ID corrupts the turbopack dev server into Internal Server Error. With a live dev server, verify with tsc --noEmit plus the running app instead.
  • git push runs bun run check:ci through husky. That replica includes webapp build:ci. The hook refuses the push if a Next development server is live, so the build does not write into a live .next. After a successful push, run rm -rf apps/webapp/.next and restart the development server. To diagnose a corrupt development server, run ls apps/webapp/.next: a BUILD_ID or prerender-manifest.json there means a production build ran. Recover with rm -rf apps/webapp/.next and a restart. Never reach for git push --no-verify.
  • Validate full-document paste (⌘A -> ⌘V) on editor changes that can affect paste or document transforms.
  • Authoring conventions and naming live under §Test Orchestration And Authoring.
  • Do not call URL, router-adjacent, floating-menu, landing mobile-keyboard, bookmark/notification panel, or chatroom/slug skeleton UI production-ready or "ship" until the real interaction path is checked. Check that path in the browser, light and dark, on iOS Safari and Android Chrome.
  • A hidden automation tab runs no requestAnimationFrame. Chat realtime merges, Virtuoso, and toolbar re-renders wait on rAF, so a background tab shows a stuck UI that is not real. Check document.visibilityState before you trust a stuck UI, or use headless Playwright. For signed-in checks, use packages/document-swarm actors.
  • On /, verify hero+card vertical centering with the keyboard closed. Verify compact repositioning when the mobile keyboard opens (keyed to keyboard visibility, not slug-input focus). Verify expansion back when the keyboard dismisses even if focus stays on the slug input.
  • history.pushState can leave h/id in the URL bar where stripping only bookmark-style params no-ops — verify URL cleanup the same way. Context menus need pointer cursor, hover contrast, and full-width dividers verified in the browser too.
  • When driving these checks, window._store (like window._editor) is exposed only on the editor playground route, not / or document routes. So toggle theme via data-theme on <html> and apply the component's compact/active classes directly rather than poking the store. Confirm the Next webapp dev port first; it isn't always 3000 under make dev-local, where 3000 may be a backend.
  • The mobile shell is user-agent gated on the server, so resizing the viewport never produces it. getDeviceInfo reads the user-agent request header through mobile-detect in documentServerSideProps, and [...slugs].tsx passes that isMobile prop to useAddDeviceTypeHtmlClass. Browser checks of a mobile pad surface need a mobile user agent (CDP Network.setUserAgentOverride). A narrow window or cy.viewport alone still renders m_desktop, and every mobile selector is then absent. The /editor playground computes no device info in its own GSSP, so it renders no mobileLayoutRoot, no TOC drawer, and none of their controls. A mobile-viewport spec pointed at that route cannot pass under any user agent.
  • Reading scrollTop right after writing it races the editor wrapper's scroll-smooth. Set scrollBehavior = 'auto' first, or the assertion reads the pre-animation position and reports a failure that is not there.

Skills And Prose

  • Cleanup/review skills (the .cursor/skills/code-janitor pipeline and kin; its SKILL.md defines --review and the gated-approval mechanism) are autonomous by default. Do not gate every step; stop only when a decision is genuinely ambiguous.
  • --review is opt-in. Default cleanup applies edits and prints one terse line per file.
  • Reports are terse next-step outlines, not detailed plans.
  • Real senior-level refactors are in scope under the gated-approval mechanism: exported symbol renames, typo fixes, file moves, file splits, and dependency bumps.
  • True safety rules remain absolute: do not change runtime behavior for handled inputs, edit generated files, or commit directly.
  • All prose work routes through tech-writer: README, CHANGELOG, reports, post-mortems, design docs, PR descriptions, and JSDoc/docstrings. Its Simplified English section is the house standard for sentences and vocabulary, and it lists the exempt surfaces. This file is exempt only for the text already in it; new and edited text follows the rules.
  • That standard is mandatory, not advisory: §Simplified English Mandate carries the obligation, the scope, and the subagent-dispatch clause.
  • The rules in §Git And Commits apply to skills without exception. Skills never create branches or worktrees, and they operate in the current directory and branch.

Simplified English Mandate

  • Simplified English is mandatory, not a preference. Every agent working here writes in it. The rules and the exempt surfaces live in tech-writer. This section states the obligation and its scope; that section states the rules. No skill file other than tech-writer §Exempt may create an exemption, and a skill that states one is overridden.
  • Scope: every sentence an agent produces. Chat replies, task and review reports, plan text, code comments, JSDoc, commit bodies, and every file written to disk or to git. Commit subjects stay exempt. A maintainer changes this scope; an agent does not.
  • Brevity an agent chooses never suspends it. A terse report style or a compressed status line changes how much you write, never the rules. Brevity the user invokes is different: caveman overrides chat replies only, and never a file written to disk or to git.
  • This rule applies to every agent and subagent without exception. An output style reaches one system prompt, and a subagent runs its own. The dispatch prompt is therefore the carrier that does not depend on the subagent opening a file. The rule covers the Agent tool, Task, Workflow, general-purpose, Explore, Plan, and specialized agents like code-reviewer, frontend-developer, and tdd-orchestrator. Returned text is prose the parent reads, quotes, and pastes, so it is in scope. Supervisor, orchestrator, and parallel-agent flows carry the same sentence. Every dispatch prompt must explicitly state: "write in Simplified English; your returned text must comply; restate this sentence when you dispatch further agents."

Documentation And Comments

  • Comments and JSDoc explain non-obvious why, never narrate what. Names, types, and structure are the contract.
  • Hard cap: ≤ 4 lines per JSDoc or block comment. If you need more, the code or the name is wrong — fix that instead. No section banners, no "Why X, not Y" preambles, no restating signatures or union members in prose.
  • Cleanup includes deleting comments that violate this. "I didn't write it" is not a reason to keep them.

Workflow And Review Expectations

  • When Task/subagent output is already visible in the UI, avoid repeating or summarizing it unless the user asks or multi-task synthesis requires it. An optional brief third-person completion line is fine — vary wording instead of identical confirmations every time.
  • Do not carry plan or brainstorm labels into durable memory, commit messages, JSDoc, or inline comments. No "v1", "v2", "phase 1", "MVP", or "green doc" shorthand. Describe shipped behavior and intent in plain product/engineering terms; reserve version suffixes for real wire formats (API paths, schema types like CommentAnchorV1, motion token names).
  • When the user asks for a review-first workflow (e.g. "wire a plan", approve-before-execute), draft the plan and pause for explicit approval before implementing. For UI/design tasks (toolbar redesigns, popover/URL-chip variants), the review artifact they expect before any code is a visual preview. That preview is a Cursor canvas or a standalone interactive HTML demo, one mock per variant/URL type, so they can approve the look first. Produce it as part of the plan rather than jumping to implementation.
  • After substantive chatroom/webapp, landing/app-shell, Supabase, or extension-package work, expect a cohesive DRY/KISS pass before treating the work as production-ready. That pass is a dead-file import-graph sweep, redundant store fields, code-simplifier, and thermo-nuclear review when invoked. It is not optional follow-up polish. Landing/shell work also demands a performance pass (route-scoped JS/CSS, deferred auth, no duplicate viewport listeners) before ship.
  • Keep AGENTS.md in full human-readable prose. Do not caveman-compress it or commit token-budget rewrites unless the user explicitly asks. Restore from backup if an agent compresses without approval.
  • For large HoE-style reviews (landing page, app shell, chatroom, TOC, sheets, composer, toolbar panels), build cohesive context from related modules first. Then wait for the explicit task before implementing. Avoid overengineering and overthinking in review output and refactor plans.
  • When the user approves a numbered audit or wishlist backlog ("work on all of them"), implement the full list with a validation checkpoint per item. Full coverage does not justify extra abstraction layers. Keep each step minimal.
  • "Do we pass CI?" asks for a per-gate verdict, not a summary. Run bun run check:ci first. Husky pre-push runs the same command. scripts/check-ci.ts replicates the prod quality gates and prints pass, fail, or skip per gate. It names every gate it skipped, so a green run never implies a job it did not execute. Otherwise run the gates by hand — lint, Prettier, Stylelint, bun run typecheck, webapp Jest, the hocuspocus suite, and build:ci for webapp and admin-dashboard. Report each one as pass or fail. Name the gates you could not run instead of implying green. Backend E2E needs the Postgres and Redis service containers that backend-ci.yml provides. The clean-room extension suites are far cheaper than this file used to claim. Measured 2026-08-27 on Bun 1.4.0, with every dist/ already built: 209 s for all five run one after another, and 89 s for bun run --parallel --filter '@docs.plus/extension-*' test. Per suite: hypermultimedia 90 s, hyperlink 61 s, placeholder 21 s, inline-code 20 s, indent 17 s. A whole bun run check:ci took 4 m 50 s. Those numbers are the warm path only. scripts/run-tests.sh defaults EXTENSION_DIST_READY to 0, and at that default every package runs its own pretest build first. The old "roughly 45 minutes" figure describes that cold path, and nobody has re-timed it. Quote the warm numbers only when dist/ already exists. build:ci is a plain next build with no dotenv layer, so pass NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY through the environment. The §Test Policy ban on a production build sharing a live dev server's .next still applies.
  • The repository is public, so a security finding never becomes a GitHub issue. SECURITY.md forbids a public issue for a vulnerability and routes reports to security@docs.plus. An unfixed weakness in live production is a working recipe once published. On 2026-08-19 an audit backlog was split on this rule. Seventeen ordinary engineering rows became issues #153–#169. Every security row stayed in the local backlog file. The same test governs a commit, not only an issue. Do not commit a report that names an unfixed weakness with file and line references. Split any audit output the same way.

UI And Theme

  • Theme/UI color consistency is first-class. Every surface, including third-party pickers, must follow design tokens.
  • The visual language source of truth is .cursor/docs/design-system.md (tokens, themes, elevation species, shape scale, state recipes, per-component catalog). The design-system skill in .cursor/skills/ is the working protocol. This file keeps the behavioral invariants; that doc owns the look. Update both in the same change when doctrine moves.
  • On DaisyUI-backed surfaces, prefer DaisyUI + Tailwind over bespoke nested hover/active stacks that fight parent controls. Vendor daisyUI class reference: https://daisyui.com/llms.txt (fetch on demand). Use .cursor/rules/react-floating-ui.mdc for generic React/Floating UI pitfalls.
  • daisyUI first, and never hand-roll a state daisyUI already owns. Reach for the component infrastructure first (btn + its btn-ghost/btn-square/btn-circle modifiers, input, select, menu, tab, badge, collapse, skeleton). Then take the recipes in design-system.md §State language, then bespoke CSS — in that order. Never override a daisyUI hover/active/focus state with an inline color-mix() arbitrary value in a component. New colours or effects become :root tokens (+ @theme alias) and are consumed as token classes; that is §Change protocol and it is not optional. Measure before you deviate: mount a probe, hover it, read getComputedStyle().backgroundColor. A guess that a default "won't read on this ground" is not evidence. btn-ghost hover, for instance, resolves to an opaque base-300 that is identical on base-100 and base-200 and tracks the theme. If a default genuinely fails, apply the doc's own escape hatch to the one instance that needs it. That hatch is base-300 for one more step on the well. Never apply it as a global override.
  • Themes are token sets (daisyUI blocks in globals.scss): 3 base (docsplus / docsplus-dark / docsplus-dark-hc) + 2 opt-in premium light/dark pairs (docsplus-graphite[-dark], docsplus-paper[-dark]), added alongside — never replacing — the base. Dark-ness comes from the platform: daisyUI sets color-scheme per theme, so shared dark tokens are light-dark(<light>,<dark>) in :root/@theme and resolve automatically. Use NO theme-name lists, NO companion attribute, and NO Tailwind dark: variant (semantic tokens + light-dark() cover it). applyThemeToDom (stores/themeConfig.ts) writes only data-theme. Adding a theme (design-system.md §Themes → Adding a theme): daisyUI block (with color-scheme) + themeConfig unions/PREFERENCE_TO_THEME (light themes also join LIGHT_RESOLVED_THEMES) + picker entry + per-theme [data-theme] { --pad-well/--color-media-* }. Add the per-theme block only where it differs from the shared default. Non-color light/dark differences (whole shadows) use a color-swap inside a shared structure, not light-dark(). System resolves to the base light/dark pair only, never a premium theme.
  • Floating overlays and modal scrims follow §Webapp UI Systems → Floating Surfaces And Modal Scrims (black-based scrims, unified panel frame, blur rules). Do not invent per-feature backdrop colors or base-content full-screen washes.
  • Desktop pad workspace (TOC column, editor well, document sheet, docked chat) follows §Webapp UI Systems → Pad Workspace Surfaces. Use borders for docked regions. Use shadows only on floating overlays and dark-theme sheet lift.

Monorepo Toolchain

Workspace

  • docs.plus is a Bun monorepo. Root package.json workspaces are "apps/*", "extensions/*", "packages/*". The deployables webapp, hocuspocus.server, and admin-dashboard live under apps/. The five publishable @docs.plus/extension-* live under extensions/. Shared internals and tooling (floating-popover, floating-tooltip, eslint-config, release-tooling, playground, supabase, email-templates, document-swarm) live under packages/.
  • Path-resolution invariants of that layout: every extension resolves ../../tsup.base and ../../tsconfig.base.json against the repo root. apps/webapp/next.config.js keeps the repo root as its tracing root, and the per-package eslint shims import ../../packages/eslint-config/*.
  • @docs.plus/playground is a dev/test-only clean-room harness the extension Cypress suites consume as a workspace:* devDependency. It ships a docs-playground bin and a browser setupPlayground helper. The bin generates the page shell + serves it, symlinking the consumer's main.ts into a temp dir so Bun's HTML bundler resolves it. The helper personalizes title/heading/tokens, wires the theme toggle, and returns #editor. Each extension's test/playground/ holds only main.ts (the editor fixture) and a 1-line tsconfig.json that extends @docs.plus/playground/tsconfig.json; cypress/tsconfig.json extends @docs.plus/playground/cypress/tsconfig.json with a local include. The package ships raw source (no build), is never published, and is never imported by any src/, so it stays out of every dist. Its manifest is COPY'd into each Dockerfile so --frozen-lockfile resolves the devDep.
  • Shared webapp utilities live in apps/webapp/src/utils/; src/lib/ was removed. Keep feature-local helpers colocated. Layer placement rules (utils vs ui/ vs layout shells vs feature folders) live under §Webapp UI Systems → Webapp Module Layers. Follow them before adding or promoting helpers.
  • When trimming playground-harness redundancy, limit to dead CLI, derived manifest fields, and small shell extractions (extension-preflight.sh) — no shared Cypress factories or root screenshot wrappers.

Dependencies

  • Root package.json owns shared devtool versions: ESLint, TypeScript, Prettier, Stylelint, Jest, babel-jest, jest-environment-jsdom, @types/jest, @babel/preset-typescript, and related tooling.
  • Root catalog: centralizes pins where used. Workspaces reference matching deps as "package": "catalog:".
  • Do not duplicate Jest/Babel dev dependencies in package workspaces unless there is an exceptional documented reason.
  • @tanstack/react-query is root-cataloged at v5 for webapp and admin-dashboard. Use object syntax; mutation pending state is isPending, while query isLoading remains valid.
  • Toolchain split (deliberate): root package.json devDependencies run TypeScript 6 and ESLint 10 by maintainer decision. The catalog: pins stay on TypeScript 5.x / ESLint 9.x pending a dedicated full-monorepo migration. tsconfig.base.json carries "ignoreDeprecations": "5.0" — required for extension dts builds under TS 6; do not remove it.
  • The metascraper family STAYS PINNED to 5.50.6. Dropping it broke the production deploy on 2026-08-30, and the pin was restored. Root overrides pins metascraper, @metascraper/helpers and the nine metascraper-* plugins. The history below names the failure the pin prevents. @metascraper/helpers 5.51.1 (the latest at pin time) depends on mime@4 (ESM-only) and require()s it. Bun (1.3.14 at pin time) throws require() async module … unsupported when a CJS module require()s it. Because apps/hocuspocus.server loads metascraper at REST startup (link-metadata module), this crashes the REST server. The crash cascades to the webapp slug page (SSR documentServerSideProps → fetchDocument → REST down → redirect to /500). 5.50.6 is the newest helpers still on nested mime@3 (CJS) — the break landed in 5.51.1 (no 5.51.0 was published). There was no forward fix to "upgrade into" at pin time: helpers/mime/Bun were all already latest, and Bun's require(esm) fix (PR #30016) was canary-only. Hocuspocus's own import mime from 'mime' (v4 ESM) is unaffected — only metascraper's CJS require of mime breaks. Drop the overrides once metascraper ships a CJS-safe helpers or a stable Bun lands the require(esm) fix. After any metascraper bump, verify cd apps/hocuspocus.server && bun -e "await import('metascraper')" loads before shipping.
    • DO NOT DROP THIS PIN, and do not trust a local test that says you can. Attempted 2026-08-30 at ^5.56.2. Deploy run 33365858244 failed in Backend E2E with the exact documented error, on Bun 1.4.0, Linux x64: @metascraper/helpers/src/index.js:6 const mime = require('mime').default → TypeError: require() async module ".../node_modules/.bun/mime@4…". It was the only failing job, and it gated Build Verification and Deploy Production. Reverted by git revert of the unpin commit.
    • Every local check said the drop was safe, and every one of them was wrong. These four all passed and none reproduces the failure — do not repeat them and conclude anything: (a) bun -e "await import('metascraper')"; (b) createRequire(...)('./src/index.js') on the helpers CJS entry; (c) a real REST start that bound :4000 and served GET /api/metadata with a scraped title; (d) bun test at 578 pass. Even after the revert, loading the 5.56.2 CJS entry still succeeds on this machine, so the laptop cannot reproduce the fault at all.
    • Why the local checks lie. bun -e evaluates in an ESM context where Bun can await the module graph. The real failure needs a CJS module on disk doing a synchronous bare require() at module scope, reached through the graph CI builds. REST start is not enough either, because link-metadata reaches metascraper lazily; scripts/e2e-change-attribution.ts reaches the synchronous entry that REST boot does not.
    • The only authoritative signal is the Backend E2E job on a (build): commit. bun run check:ci SKIPS that job locally. So a green check:ci is not evidence about this pin. If you ever revisit the drop, push it alone on a (build): commit and read that job — nothing on a laptop can settle it.
  • Bun 1.4.0 segfaults on a direct require('re2'). Do not add one. Measured 2026-08-27: exit 139 on re2@1.24.1 and re2@1.26.1 alike. Bun 1.3.14 instead refuses the same file with a catchable NODE_MODULE_VERSION error, because the addon on disk is built for ABI 147 while 1.3.14 wants 137. So 1.3.14 looked safe only because it could not load the addon at all. re2 arrives transitively under @metascraper/helpers, and the metascraper pin does not avoid it — the pinned and unpinned trees both carry a copy. Nothing here requires it directly, and url-regex-safe, its real consumer, loads clean on both runtimes. The hazard is latent. Keep it that way.
  • A local bun install --frozen-lockfile does NOT validate the lockfile. Only a cold install does. A warm ~/.bun/install/cache means nothing is re-downloaded, so no tarball integrity hash is re-checked, and a corrupt lockfile passes on the maintainer's machine. Docker installs cold from the registry and verifies every tarball. Measured 2026-08-30: a bun run update sweep produced a bun.lock that passed locally, then failed the webapp Docker build with repeated IntegrityCheckFailed on sass, sharp, eslint-plugin-react-hooks and @floating-ui/react. Reverting only the lockfile, with the same Dockerfile, built clean at exit 0 with zero integrity failures. After any dependency change, prove it with a cold install before trusting it — a Docker build is the cheapest one available. bun run check:ci does not catch this, because it installs warm too.
  • Dependency update flow:
    • Bump version ranges: bun run update (patch + minor only; root catalog + every apps/*, extensions/*, packages/* via scripts/update-packages.sh). Majors: bun run update --upgrade. Preview: bun run update --dry-run. The underlying tool is npm-check-updates — not npm-check, and not a verbose bun update --recursive wrapper; keep scripts/update-packages.sh minimal.
    • After update, run bun install at the repo root. Do not run parallel bun update / installs inside individual packages; shared bun.lock can race with EEXIST.
    • When adding a workspace package, keep bun.lock diffs scoped to that package's graph. Avoid unrelated root devDependency churn from a blind full bun install. Surgically merge lockfile entries if needed.
  • Removed tools/scripts stay removed: per-package update:packages, scripts/reinstall-packages.sh, reinstall:all-packages, update:all-packages.

Test Orchestration And Authoring

  • Unit + E2E stack: Jest and Cypress. Script names and CYPRESS_PARALLEL semantics are defined in the naming convention doc; this section captures docs.plus-specific orchestration, Jest wiring, and authoring conventions. Policy (when to write a test, what shapes to avoid) is in §Test Policy.
  • Run order comes from scripts/publishable-extensions.ts (the SSOT gate list scripts/run-tests.sh reads at :162); each extension runs its own bun run test:
    1. @docs.plus/extension-indent — Jest (local jest.config.cjs) then clean-room Cypress against built dist/.
    2. @docs.plus/extension-hyperlink — clean-room Cypress against built dist/ (preceded by bun test src units).
    3. @docs.plus/extension-hypermultimedia — clean-room Cypress against built dist/.
    4. @docs.plus/extension-inline-code — clean-room Cypress against built dist/.
    5. @docs.plus/extension-placeholder — clean-room Cypress against built dist/.
    6. @docs.plus/webapp Jest (jest --passWithNoTests, so an empty or temporarily absent suite does not fail CI/local runs). CI: stage.docs.plus.yml and prod.docs.plus.yml run bun run --filter @docs.plus/webapp test in the quality-gate stage.
  • Clean-room ports are unique per extension: hyperlink 5173, hypermultimedia 5174, indent 5175, inline-code 5176, placeholder 5177.
  • Prod matrix-gates the extension suites by changed paths, so a green history does not prove a suite ran. prod.docs.plus.yml resolves the matrix through dorny/paths-filter against .github/filters/extensions.yaml. That filter keys each extension on its own directory plus the shared inputs (package.json, bun.lock, tsconfig.base.json, the build-extensions action, and the extension build scripts). A suite nobody touches sits unrun for weeks. The hyperlink suite last ran on 2026-07-15, and it only showed a broken spec when a 2026-07-29 commit touched that directory. Confirm the job actually executed in the run you cite before calling a suite healthy. The unfiltered full-matrix run is prod's weekly cron (Sunday 00:00 UTC). schedule forces all five through FORCE_ALL, and build/deploy stay excluded on that event, so the cron tests without deploying. Do not count on stage.docs.plus.yml. It triggers only on a dev branch that does not exist on origin, so it never runs except by manual workflow_dispatch.
  • Jest wiring:
    • @docs.plus/webapp keeps next/jest in jest.config.js.
    • Library packages that need Jest use a local jest.config.cjs. Configure roots, testMatch, transform, and testEnvironment there.
    • Prefer inline babel-jest options in jest.config.cjs; do not add per-package babel.config.cjs unless package-specific Babel behavior is required.
    • Add a library package test script as "test": "jest --config jest.config.cjs".
    • Do not add package-local Jest stacks to package.json; use the root dev dependencies.
    • Jest 30 uses the plural flag --testPathPatterns, not the singular --testPathPattern from older docs/snippets. Correct it on sight.
    • bun test is Bun's native runner — not a substitute for Jest where Next/Jest or local Jest configs are used.
    • Slice unit tests must call enableMapSet() from immer at module scope. Slice files do not enable it themselves; only stores/useStore.ts and stores/chat/useChatStore.ts do at production load. Isolated slice instantiations otherwise fail with "MapSet plugin not loaded".
  • Cypress conventions:
    • Split tests by concern and include a README for scope.
    • Use it(), not test(). Consolidate overlapping tests.
    • ProseMirror handleDOMEvents.click is not triggered by Cypress realClick() / .click(). Dispatch a native MouseEvent('click', { bubbles: true, clientX, clientY }) using getBoundingClientRect() coordinates. Use the same pattern for popover keydown Escape dismissal.
    • ProseMirror input rules (markInputRule / markPasteRule) do not fire on cy.type() — drive typing with cy.realType() (cypress-real-events) so the real beforeinput pipeline runs. Keymap handlers (Tab / Shift-Tab, arrow-key mark exit) fire from a synthetic cy.get('#editor [contenteditable="true"]').trigger('keydown', { key, keyCode, shiftKey, bubbles: true }) — same family as the click note above. Programmatic insertContent does not trigger input rules; HTML parsing collapses leading whitespace, so build indented/space-led fixtures with real keypresses, not setContent('<p> x</p>').
    • cy.realPress / cy.realType are PARENT commands — a chained subject is silently discarded. cypress-real-events registers them with no prevSubject. cy.get('#editor .ProseMirror').realPress('Backspace') therefore resolves that element, throws it away, and dispatches a CDP key event at whatever the browser happens to focus. It never focuses anything itself. Any spec that sets the caret with editor.commands.focus(pos) and then presses a key is racing real DOM focus. When focus has not landed, the key goes to <body> and the assertion fails with no clue why. Gate on focus first — cy.get('#editor .ProseMirror').should('have.focus') then a bare cy.realPress(…) — which is a retrying assertion, not a cy.wait. This was the 2026-08-08 delete-collision.cy.ts one-in-three flake; sibling specs survived only because unrelated retrying assertions happened to buy the time.
    • cy.wait('@alias') after navigation only matches future requests. Instant cy.intercept mocks often complete during cy.visit() / cy.reload() load, so a post-navigation cy.wait('@alias') retries until timeout and reads as an infinite hang. Chatroom feed specs: register intercepts before cy.visit, then gate on DOM (cy.waitForMessage, [data-msg-id], media expand/view controls visible) — not cy.reload() + alias waits. Canonical: deep-link.cy.ts, open-unread.cy.ts, attachments.cy.ts visitFeed(). Avoid conditional helpers that nest cy.wait('@storageSign') when the alias may never fire.
    • Scroll-tracking assertions must park the anchor mid-viewport before measuring. Cypress's default scrollBehavior: 'top' leaves the target at y≈0, so scrolling the page by any real distance pushes it off-screen. Then floating-ui clamps the popover to its shift padding and marks it hidden via hide({ strategy: 'referenceHidden' }). The assertion fails while the engine behaves correctly, which invites a pointless fix to the popover engine — autoUpdate({ animationFrame: true }) was tried and reverted. Pin the anchor to a known viewport offset first, and assert equal Δtop between popover and anchor rather than against the scroll distance. Canonical: extensions/extension-hyperlink/cypress/e2e/scroll-stickiness.cy.ts.
    • Gallery/lightbox Cypress: @storageSign mocks should return both signedURL and signedUrl. Gallery <img> tags need loadable URLs (a 1px data: URL is fine). Feed tiles may paint via CSS background, so bogus example.test paths can pass feed assertions yet fail once the lightbox mounts real <img> elements.
  • Naming:
    • Cypress E2E directories and files use kebab-case (copy-paste/, keyboard-shortcuts/, clipboard-validation.cy.js); no e2e- or numeric ordering prefixes unless the reason is documented.
    • Unit test files use camelCase and match the source module: <moduleName>.test.ts; performance tests use <moduleName>.performance.test.ts.
    • Cypress support modules use camelCase; fixture files use kebab-case; fixture directories may use camelCase when mirroring a command name.
    • Avoid sprint, phase, audit, or ticket names. Test descriptions describe behavior, not ticket IDs — within a file, use either should ... phrasing or bare verbs consistently.

ESLint Config

  • packages/eslint-config is ESM with "type": "module".
  • Three-layer config:
    • index.js: base TypeScript + Prettier + simple-import-sort; no React.
    • next.js: base + React + hooks; used by webapp/admin-dashboard.
    • library.js: base + explicit-module-boundary-types + no-console: warn; used by extension-* packages.
  • Consumers use 2-line ESM imports. Do not add createRequire bridges.
  • React plugins load only in next.js, never in library or backend configs.
  • packages/eslint-config uses only eslint-config-prettier; its flat config does not load eslint-plugin-prettier.
  • Flat-config shim naming: root / webapp / admin-dashboard use eslint.config.mjs (packages without "type": "module"). hocuspocus.server and extension-* use eslint.config.js (they declare "type": "module"). See packages/eslint-config/README.md.
  • Lint gate: root bun run lint is eslint . --max-warnings=0. bun run check includes it plus format. Husky pre-push runs bun run check:ci. Pre-commit lint-staged formats staged files and enforces zero ESLint warnings on those files.
  • Pre-push: scripts/hooks/pre-push.sh runs bun run check:ci. It refuses the push if a Next development server is live. check:push stays the fast local lint + styles + typecheck gate.

Shared Library Config

  • Keep root-level shared config as the single source of truth.
  • tsconfig.base.json applies to all extension-* packages. Package tsconfig.json files only declare local outDir, rootDir, include, and exclude.
  • Package exclude must list colocated test paths: src/**/__tests__/**, src/**/*.test.ts. Otherwise tsc can expand the computed source root above src and trip TS6059.
  • tsup.base.ts exports defineTiptapExtensionConfig(overrides?).
    • The factory is intentionally Tiptap-specific. It hardcodes @tiptap/core and @tiptap/pm externals.
    • Build shape: ESM + CJS, dts, production sourcemaps/minify, and esbuildOptions.pure = ['console.log', 'console.debug'] in production.
    • Do not use drop: ['console']; it strips console.warn and console.error.
    • A package's tsup.config.ts should call defineTiptapExtensionConfig() through defineConfig(...); pass overrides only for package-specific behavior.
    • Overrides are shallow. Function-valued options such as esbuildOptions, external, and dts replace the base value. If a caller overrides esbuildOptions, it must preserve the base pure policy manually.
  • extension-hypermultimedia intentionally preserves console.error from its Logger wrapper (src/utils/logger.ts, error-only by design — do not reintroduce warn/debug) under the shared tsup factory. Note logger changes in its CHANGELOG.
  • @docs.plus/floating-popover and @docs.plus/floating-tooltip are bundled into each consuming extension's dist, never externalized. Both are private workspace packages (never published), wired as devDependencies workspace:* in extension-hyperlink and extension-hypermultimedia. Their tsup.config.ts external must stay ['@tiptap/core', '@tiptap/pm']. Do not add a '@docs.plus/floating-*' entry, or tsup emits a bare import into the published bundle that throws MODULE_NOT_FOUND for external npm consumers. @floating-ui/dom stays external (real published dep). Verify after any tsup change: built dist/index.{js,cjs} must contain no @docs.plus/floating-* import/require. Neither package ships CSS. Consumers carry lockstep skins with one split rule. .floating-popover* shell blocks use each package's own tokens, but the .floating-tooltip block is identical light-dark() literals by contract. Both bundles style that one global class, and per-package tokens would let cascade order pick one bundle's look for every bubble. scripts/extension-preflight.sh enforces the byte parity.
  • Root LICENSE is the single committed license.
    • Each publishable package adds /LICENSE to package .gitignore.
    • prepack copies the root LICENSE before bun publish or bun pm pack.
    • Symlinks fail because Bun pack drops them. Hard links fail because git stores independent copies.
  • Shared release scaffolding lives in @docs.plus/release-tooling — an internal workspace package exposing release-prepack and release-preflight as bin commands. The scripts are data-driven. They derive the consumer's package name and dist-artifact list from its own package.json (name + exports map), so there is no per-consumer parameterization. Same DRY principle as @docs.plus/eslint-config, tsconfig.base.json, and tsup.base.ts — cross-package scaffolding is hoisted, never copied. Publishable libraries wire prepack / prepublishOnly to these bins per the Type 4 contract in the naming convention doc.
  • Do not centralize package-specific files: README.md, CHANGELOG.md, package source, 3-line eslint.config.mjs / eslint.config.js shims, or package.json fields.

Docker

  • Use one Docker base tag everywhere: oven/bun:1-slim.
  • Do not mix 1-alpine, 1-slim, or hardcoded patch tags.
  • VERIFIED IN PRODUCTION, deploy 33367037524 on 2026-08-31. All jobs green, 9 of 9 containers healthy. Build Docker Images fell from 997.4 s cold to 683 s. The chown -R step has 0 occurrences in the log, and its replacement COPY --from=base --chown= costs 14.0 s for the whole tree. The webapp image fell 727 MB to 332 MB, a true side-by-side because the previous deploy's images were still on the host; the untouched admin image stayed at 186 MB, which is the control. The backend image is 961 MB on amd64. The front-half cache fixes did not pay on that deploy and were not expected to — only 2 of 165 steps were CACHED, because it was the first build with the new layer structure. They pay from the next (build): deploy: both webapp installs should print CACHED, and next build should fall below 277 s.
  • Never chown -R a workspace tree in a Dockerfile. Stamp ownership with COPY --chown instead. A recursive chown rewrites every inode, so Docker stores a second full copy of the tree. Measured 2026-08-28 on apps/hocuspocus.server/docker/Dockerfile.bun: one RUN chown -R appuser:appuser /app layer held 1.91 GB, and removing it took the backend image from 5.67 GB to 3.24 GB. Only the runtime write path needs stamping — storage.local.ts:16 resolves ./temp/<plugin> against process.cwd(), which the entrypoint sets to /app/apps/hocuspocus.server. Leaving node_modules and the generated Prisma client root-owned is a security gain, not a cost.
  • A production install in a service image must be scoped with --filter. A bare root bun install resolves every workspace member, because --frozen-lockfile needs every manifest COPY'd. The backend image therefore shipped next@15.5.21, next@16.2.12, four @next/swc native binaries, react-icons, @emoji-mart/data and typescript — about 950 MB the REST, WS and worker processes never import. --filter '@docs.plus/hocuspocus' took the image from 3.24 GB to 1.42 GB and the --production install from 54.9 s to 17.1 s. Keep the other apps' manifests COPY'd; the filter scopes the install, not the resolution.
  • Docker build timings do not transfer between architectures. Judge a build change from the production log, never from a laptop. Measured 2026-08-28: the same chown -R cost 1.8 s on arm64 macOS with OrbStack and 125.3 s on the amd64 host — about 70x. Sizes and per-step shares do transfer; absolute seconds do not. Profile with --no-cache --progress=plain and report share of total.
  • Compose does NOT duplicate a build when two services share one build definition. rest-api and hocuspocus-server declare the same context, dockerfile and target. Measured: docker compose build --no-cache rest-api hocuspocus-server ran bun install --frozen-lockfile three times, which is one pass through the Dockerfile's three installs, not six. BuildKit shares the layers and only stamps a per-service label, so the two image IDs differ while the content is identical. Do not "fix" this with a build-once-then-docker tag step, and correct any comment claiming it duplicates context transfer.
  • A volatile ENV above an install invalidates it on every build. Declare per-deploy values below the install. An ENV enters a later RUN's cache key; an unchanged COPY does not. Measured in warm production deploy 32946907649: no webapp source changed since the previous deploy, yet the builder bun install still ran 158.8 s while the same install in two sibling stages was CACHED. Fixed 2026-08-28 by moving GIT_HASH and NEXT_PUBLIC_GIT_HASH below the install in apps/webapp/docker/Dockerfile.bun, keeping them above bunx next build, which still needs them because Next inlines NEXT_PUBLIC_GIT_HASH into the client bundle. Verified by a cache-hit assertion changing only GIT_HASH between two builds: 299 s with 7 cached steps became 62 s with 58 cached steps, the install CACHED and next build correctly still running. BUILD_ID stays above, because docker-compose.prod.yml never passes it, so it is a constant empty string.
    • The admin-dashboard half of that edit is NOT safe as written. apps/admin-dashboard/docker/Dockerfile.bun:67 is the LAST entry of a multi-line ENV and carries no trailing backslash. Deleting it alone leaves line 66 ending in \ before a blank line, the documented NoEmptyContinuation shape, and the ENV then swallows the next COPY. Rewrite line 66 in the same edit, or leave admin alone.
  • A BuildKit cache mount survives the deploy's prune, but only while it was used inside the window. Measured 2026-08-28 against a real exec.cachemount record. The deploy's own command, docker builder prune -af --filter "until=24h", reclaimed 0 B and left the record intact, and the next build hit the mount. The control, until=1s, reclaimed 28.55 GB, removed the record, and the next build missed. So until filters on last-used time and cache mounts are in scope. Real deploy gaps are median 2.2 h with 1 of 9 over 24 h, so a mount would be warm on roughly eight deploys in nine. A mount also survives a changing ARG with the layer cache on, which is the webapp case, but comes back empty under --no-cache, so the backend build would gain nothing from one.
  • A runtime stage must not descend from a build stage that installs a toolchain. The webapp runner used FROM base, and base installs python3, make, g++ and nodejs for the build stages, so the shipped image carried a compiler it never used. It now uses oven/bun:1-slim directly, which still satisfies every command that stage runs — groupadd, useradd, rm, find and bun — and already ships ca-certificates. apps/admin-dashboard ran the same Next standalone shape on the plain tag all along. Verified 2026-08-30: zero g++ layers in docker history, and no g++ or python3 binary in the image. node remains, because oven/bun:1-slim ships it.
  • An install whose node_modules nothing copies out is pure waste — delete the install, not the stage. The webapp deps stage ran a full bun install, but build-extensions and builder each copy only manifests and source, then install for themselves. deps is still needed as the shared manifest-layout stage, so removing the whole stage would be wrong. Four lines deleted, not a stage.
  • --parallel is not a docker compose build flag in Compose v2. It is absent from docker compose build --help. Named services already build concurrently. Do not add it back.
  • A cache-ordering fix cannot be measured with --no-cache. A no-cache build has no cache to hit, so it reports zero for a correct change and invites reverting it. Verify those with a binary cache-hit assertion — two builds with the cache on, changing only the intended input, then check the step prints CACHED. That assertion is architecture-independent, so it runs anywhere.
  • Do not copy node_modules between Docker stages. Bun uses symlinks into a virtual store; copied installs can break module resolution.
  • Any stage that runs next build, extension builds, or config that requires deps must run bun install --frozen-lockfile.
  • Copy only package.json, bun.lock, and the workspace tree between stages.
  • Any Dockerfile stage that runs bun run build for @docs.plus/extension-* must also COPY the root-level shared configs tsconfig.base.json and tsup.base.ts into the build context. Each extension's tsconfig.json extends ../../tsconfig.base.json and each tsup.config.ts imports from '../../tsup.base'; missing either file fails the extension build with Could not resolve "../../tsup.base". Affected Dockerfiles: apps/hocuspocus.server/docker/Dockerfile.bun and apps/webapp/docker/Dockerfile.bun (build-extensions stage must copy them via --from=deps).
  • A stage that rebuilds the workspace from a prior stage must copy all three roots, not just packages/. The apps/* + extensions/* + packages/* layout splits the workspace, so any COPY --from=<stage> /app/packages ./packages must be followed by COPY --from=<stage> /app/apps ./apps and COPY --from=<stage> /app/extensions ./extensions before bun install --frozen-lockfile. Without the apps/* and extensions/* members the frozen install fails with lockfile had changes, but lockfile is frozen. Stages that wire this: webapp build-extensions + builder, admin-dashboard builder, hocuspocus.server production. A builder that imports the extensions needs the built extensions/ (with dist/) from the build stage; manifests alone are not enough.
  • Build @docs.plus/floating-popover and @docs.plus/floating-tooltip before the extensions that bundle them — inside Docker too. The hyperlink and hypermultimedia extensions bundle both (not external; see §Shared Library Config). Any stage that runs their bun run build must first COPY and build both packages/floating-* packages. Wired in webapp build-extensions and hocuspocus.server base; scripts/build-extensions.sh and the .github/actions/build-extensions dist-cache key cover both. The admin-dashboard builds no extensions, but it still copies both packages' package.json. Without that copy, the extensions' workspace:* references fail to resolve under --frozen-lockfile.

Standalone Bun Scripts

  • Root tsconfig.json does not include scripts/. IDE lint errors such as missing node:fs or import.meta.dir in scripts/*.ts are usually noise.
  • Smoke-check standalone Bun scripts with:
bun build scripts/<file>.ts --target=bun --outfile=/tmp/out.js
  • Avoid ad-hoc bunx tsc --noEmit <file> unless passing --ignoreConfig; otherwise TS5112 can fire when tsconfig.json exists.

Filed by directory

These sections moved verbatim into files that load only when you work in that directory. Section names are unchanged, so every section reference in this repo resolves here. Router entries tag a section's home in brackets.

apps/webapp/CLAUDE.md · router tag [webapp]

§Webapp UI Systems · §Webapp Module Layers · §TipTap Styling · §Pad Workspace Surfaces (desktop) · §Mobile Bottom Sheets And Overlays · §Mobile Chat Pane · §Mobile Document Pad · §Floating Surfaces And Modal Scrims · §Motion System (motion v1) · §Slug Page Entry And Skeletons · §Collab Provider Status · §Landing Page Shell And PWA · §Next Product APIs · §Document Features · §Document Access · §Document Version History · §TOC And Heading Actions · §Heading Fold Crinkle · §Document Comments · §Document Filters · §Bookmark And Notification Panels

apps/webapp/src/components/TipTap/CLAUDE.md · router tag [TipTap]

§Editor Architecture · §Document Model And Migrations · §Heading Schema · §HeadingScale · §Editor Performance · §Editor State And References

apps/webapp/src/components/chatroom/CLAUDE.md · router tag [chatroom]

§Chatroom And Messaging · §Optimistic Message Lifecycle · §Message Grouping Projection · §Chatroom List (Virtuoso) · §Chatroom Realtime · §MessageComposer · §Chat Media Attachments · §Mention Picker · §Anonymous Chat Read Path · §Chatroom Staged Skeleton

apps/hocuspocus.server/CLAUDE.md · router tag [hocuspocus]

§Persistence · §Retention and schema · §Runtime limits · §Documented hazards we accept · §Backend And Infrastructure · §HTTP Modules · §Hocuspocus Server · §Admin API And Dashboard · §Production And Docker Compose

apps/admin-dashboard/CLAUDE.md · router tag [admin]

The client half of §Admin API And Dashboard. Not a moved section — it exists because the hocuspocus file does not load under apps/admin-dashboard/.

packages/supabase/CLAUDE.md · router tag [supabase]

§Supabase

extensions/CLAUDE.md · router tag [extensions]

§Extension Workflow · §Standalone Extension Development · §Hyperlink Extension · §Webapp-Owned Hyperlink Popovers · §Composer Link Dialog And Internal Links · §Hypermultimedia Extension · §Indent Extension

.cursor/skills/release-extensions/SKILL.md — invoke the skill · router tag [release-extensions skill]

§Publishing And Releases · §Extension Package Contract · §Release And Publish · §Extension Version Doctrine

Learned User Preferences

Inbox only. New cross-cutting working-style preferences go into §Workflow And Review Expectations (or the topical section that owns the subject). Append here only when no section fits, one rule per bullet. File entries into their owning section on the next tidy.

  • After substantive pad/TOC/design-system/chatroom work, run the continual-learning memory updater so regressions are not repeated.
  • Settings Documents list UX should follow a Google Docs-like per-row ⋮ menu, not inline visibility toggles. The menu lists Open, Copy link when public, Rename, Duplicate, Favorite or Unfavorite, then Private and Read-only. Favorite sits after Duplicate and before the Private divider. No toolbar star. Name is Favorite, never Bookmark or Pin (see CONTEXT.md §Documents list). Keep it minimal and size-fit for the settings panel. On mobile the Documents toolbar must stay 44px-target size-fit (flex/truncate sort, icon-only Trash, no desktop-width overflow). Private ON must clear and disable Read-only on that menu and on DocumentSettingsPanel (see §Document Access).
  • Date sorts on Settings → Documents use Date buckets on the rest after Favorites. No Favorites heading.
  • Last opened is a sort. The pad owner stamps lastOpenedAt via POST /api/documents/:documentId/opened. Last modified does not move.
  • Owner live list and Owner Trash list lead with LuFileText, not first-page paper. Grid is 4/3 first-page paper (DocumentPreviewPaper). No screenshot thumbs. Create stays empty-state only.
  • TOC long heading titles must wrap (e.g. text-pretty / whitespace-pre-line), never truncate — use the full row width left of the trailing chat/unread rail. Tree connectors use daisyUI nested menu rails on toc__children only (vertical rails, no L-elbows). Accept stock rail opacity — do not reintroduce a TOC ::before strengthen overwrite.
  • Mobile heading control stays a one-frame stepper, name first: [name | − | +]. Do not replace it with a chip that opens a list. The three cells share one join; no inner box around plus and minus. When the caret is Title, the join uses the same is-active color as other heading levels.
  • TOC chat/unread/presence all live in-flow in TocRowTrail. Desktop presence is <AvatarStack anchor="right"> right of the chat trigger (right-anchored, fixed right edge, grows left, translate-x-3 to the column inner edge). The scrollbar must stay inside the column, so do NOT widen .toc__scroll to overhang presence beyond the wrapper. That widening pushed the scrollbar out over the editor. Fix broken shipped UI motion (e.g. RollingNumber) in place — never strip it for a static fallback unless the maintainer asks.
  • Desktop TOC tick rail shipped 2026-09-18. Durable rules live in webapp §TOC And Heading Actions and §Pad Workspace Surfaces. Look lives in .cursor/docs/design-system.md §TocTickRail. Names live in CONTEXT.md §Pad outline. Settled don'ts live in root CLAUDE.md. Persist last committed wide width only (docsy:toc-width, greater than 240). Stored 240 or less is missing and opens at 320. A short tick stack sits in the middle of the live rail. Chat open and close tween that offset with --motion-panel. Sash drag has no tween. Do not persist 32 or 240. Do not lock the stack to the window mid-line. On a long rail, spy follow is keep-in-view and depth-fit after the fold filter. Do not pin the spy tick to the rail top or bottom (nearest / edge-stick). Do not steal the rail while the pointer is on the minimap.
  • Docked chat sash-close uses the same snap recipe as TOC: linear paint below min, fade the inner column, snap at half min, abort back to min, settle with --motion-overlay-in 120ms. Do not put a transform on the chat panel. The Close button stays. Do not persist a below-min height. Wide TOC stays full height when chat is open (L-shape). Pad seams stay --pad-divider / base-300. Do not restyle docked borders in a session.
  • Chat feed image tiles are album-style: single scales from intrinsic size. Multi-image uses absolute rects (2–4 pack + complex pack for n≥5 / wide ratios) with cover-crop inside cells, not a fixed grid and not a separate tall-stack mode. Show all visuals up to the attach cap (no feed +N overlay).
  • Do not use Telegram/Discord (or other peer-product) names in code symbols, filenames, or identifiers. Use domain vocabulary (feed album, visual layout, mosaic pack). Peer names may appear only in prose when citing UX parity.
  • Chat message feed must stay vertically scrollable only — media cards must fit the column and never introduce horizontal overflow (especially on mobile).
  • Local-dev public DX is one command (make dev-local): bun install (if needed), DB migrate, and extension dist bootstrap stay inside that flow and must fail loud. Do not advertise internal preflight helpers as Makefile help / README targets, and keep the README local-dev path minimal.
  • Chat media gallery zoom/pan/keyboard and mobile ⋯ overflow changes are not done until verified in the browser. Verify desktop click/wheel/keyboard; iOS pinch/double-tap/pan; and mobile overflow opening a body-portaled bottom sheet, never nested under the pill’s backdrop-blur. Do not claim fixed from code inspection or DevTools assumptions alone.
  • Hypermultimedia media-toolbar ⋯ overflow must toggle on repeated click. It must sit flush under the toolbar’s right edge (bottom-end on the bar, tight top gap), not a large offset under the ⋯ alone. @docs.plus/floating-popover outside-dismiss must ignore the toggle trigger or the menu race-reopens and never appears to toggle.
  • Chatroom feed error / empty-load states should stay Discord-like and minimal. Use calm plain-language copy (no raw 403 / permission denied strings), design-system tokens, and the same composition on mobile sheet + desktop docked panel. Research and compare before shipping a louder custom layout.
  • After a local check:ci or husky pre-push build:ci, do not ask the maintainer to run rm -rf on a .next tree. scripts/check-ci.ts and make dest-local already drop a leftover production .next.
  • Mobile bottom sheets must use the library grabber. Empty Sheet.Header mounts react-modal-sheet's DragIndicator. Do not add a house grabber.
  • Path A is the product shell: deepen the installed PWA (same site, Home Screen / dock). Do not start Electron, Tauri, store-listed native apps, or a native rewrite. Last opened on Home is for every signed-in owner, not only an installed window. Do not gate that list on isPWAInstalled.
  • Copy success uses daisyUI swap / swap-active (copy fades to a check). Do not remount the control or play doc-region-in. Do not use transition-all. Do not add a CopyFade wrapper. CopyButton is the standalone control. Menus and sheets keep their own host button and put aria-hidden on the swap root. A menu that closes after copy uses useCloseAfterHold and COPY_FADE_HOLD_MS (MOTION_PANEL_MS × 2 in apps/webapp/src/utils/motion.ts), and only after a successful copy. Documents ⋮ must cancel that hold on Favorite, Private, and Read-only. The notification bell is stacked opacity for three states, not swap; disable it while an update is in flight. TOC Copy link, gallery copy, and chat file-card copy stay hard cuts until asked.
  • Hide Report on the chat message card menu. Keep the Report row in useMessageActionMenuItems.tsx at display: false so the desktop context menu and the mobile long-press menu both omit it. Document Report in Settings stays. Do not restore the chat Report row. Do not tell /terms readers to open the chat menu and choose Report.
  • Chat message action titles stay shared for the right-click menu and the hover ⋯ menu: Copy Link, Copy to Doc, Edit, Delete, in messageActionMenu.ts. Edit sits above Delete in both. Do not rename Copy Link to Share message link when the message has files. Do not put Message on Edit or Delete. Do not let the two menus drift apart.
  • Own empty profile card is one row: "No bio or links yet." on the left and "Add bio and links" on the right. That button closes the card and opens Settings on the Profile tab. Someone else's empty card stays the plain line only. Do not restore a filled primary button, a centered column, or a pencil icon on that empty state.
  • Change digest email body must read like the pad. Keep the mail header, greeting, document title, Open, and footer. A line sits under the changed-since line. Each changed heading is bold and links to that place on the pad. The passage sits under the heading. Added text is green. Removed text is red and struck through. A new chat sits under its heading. A chat in a channel that is not a heading stays in its card. Do not put Chat or View on the heading row. Quiet headings stay out of the mail. The default sends one mail per document. Admin Notifications can switch to one combined mail. The same card sets the HTML size limit in KB. The default is 90. The range is 10 to 102. A missing Redis value stays on one mail per document and 90 KB.

Learned Workspace Facts

Inbox only. New workspace facts go into the topical section that owns the subject. Append here only when no section fits, one rule per bullet. File entries into their owning section on the next tidy.

  • Local working notes (09-17). Reports, briefs, and the hocuspocus backlog live in Notes/local-docs/. Start at that folder's INDEX.md. Route by inject-content, Last left, occupancy, restore, REST gaps, scale, auth, composer, extension launch, TOC rail, or chat sash. gio.md is a copied Awesome Supabase list in Notes/scratch/.
  • docs.plus is a claimed, verified Context7 library at https://context7.com/docs-plus/docs.plus. Context7 feeds Cursor, Claude Code and Windsurf over MCP, so it is what an AI assistant reads about this project. It does not re-crawl on push. After a major documentation change — a README rewrite, a changed extension API, a release CHANGELOG entry, new pages under docs/, or an edit to context7.json — open the library page and press Refresh, or every agent keeps serving the previous snapshot. Parsing config lives in context7.json at the repo root, never the dashboard, so it stays reviewable in a diff. The authoritative field list is https://context7.com/schema/context7.json, which is additionalProperties: false and caps description at 200 characters and each rules item at 255; the claim error blames "unknown fields" when the real failure is a length limit. Ranking cannot be tuned: library search is name-based and description is only a fallback the model overrides.
  • pg_cron email jobs (compile_digest_emails, process_email_queue, digest cleanup) run on Supabase cloud (tglymsfloxmouzjuoycu), not the app's DATABASE_URL DigitalOcean Postgres. Grafana cron-stale alerts read worker metrics fed by Supabase RPC get_cron_job_health.
  • Email notification path: pg_cron SQL → pgmq.send('email_notifications_queue') → hocuspocus-worker consume_email_queue RPC → BullMQ → SMTP/Resend/SendGrid (pg_cron never calls HTTP). Prod public.email_queue check (email_queue_status_check) can drift from repo and omit processing; align with packages/supabase/scripts/07-5-email-notifications-pgmq.sql.
  • The hocuspocus/Prisma DB (DocumentMetadata/Documents; schema apps/hocuspocus.server/prisma/schema.prisma) runs locally as docker container docsy-postgres-local (host :5432, db docsplus) — separate from Supabase local (supabase_db_docsplus_supabase, :54322). DATABASE_URL is in root .env.local; no host psql, so query via docker exec -i docsy-postgres-local psql … and strip the ?connection_limit=…&pool_timeout=… params (libpq rejects them). DocumentMetadata.ownerId is a nullable bare Supabase uid (no local FK).
  • Soft-deleted documents stamp DocumentMetadata.deletedAt as a tombstone; a purge/reaper removes the document footprint after retention (documentPurge.service + matching Supabase purge SQL).
  • make dev-local / make dev-backend run scripts/dev-local-preflight.ts before starting processes (env, deps, DB, extension dist); Next fails to resolve @docs.plus/extension-* when extension dist/ is missing.
  • Preflight env bootstrap: on first run it creates .env.development from .env.example. It also writes .env.local through localizeEnvForHost (REDIS_HOST/SERVER_RESTAPI_URL/SUPABASE_URL → localhost, DATABASE_URL appended from DB_USER/DB_PASSWORD/DB_PORT). It never mutates an existing .env.local — it only notes key names missing vs .env.example. Keep the Docker→localhost mapping in LOCAL_HOST_OVERRIDES, nowhere else.
  • Preflight Supabase gate: after the :54321 port check it probes docker exec supabase_db_docsplus_supabase psql … 'select 1 from public.workspaces limit 1' and fails loud (hint: bun --filter @docs.plus/supabase_back reset) on a foreign or unseeded instance. Never auto-run db reset from preflight — the reset decision stays with the developer. Local supabase migration up is also wrong by design (migrations are remote-only; scripts/ + seed are canonical locally).
  • concurrently is a pinned root devDependency consumed via bunx in dev-local/dev-backend with --kill-others-on-fail (one crashed process tears the run down loudly). Do not remove the pin — unpinned bunx concurrently fetches latest from npm at dev start. Hocuspocus REST/WS/worker entrypoints must not export default a Hono app. Bun auto-serves the default export on :3000, which races the webapp (or wins and restart-storms it). The real listener is the explicit Bun.serve on APP_PORT (:4000). That double-listen caused the 2026-08-04 make dev-local REST death. Fatal/uncaughtException/bind failures must exit non-zero after drain. process.exit(0) from graceful shutdown masks the crash, so --kill-others-on-fail leaves the rest of the stack up and the webapp hits ECONNREFUSED on REST.
  • SUPABASE_SERVICE_ROLE_KEY in .env.example is the Supabase CLI's public local-demo JWT, in the same demo-secret family as the anon key. It lets the admin gateway and worker queue consumers work on a zero-edit clone; cloud/prod replaces it.
  • Pad providerStatus saving→synced after edits is a ~300ms local timer — not a durable Postgres ACK. Durable persist runs after Hocuspocus debounce (10s idle / 60s max) via the BullMQ worker and confirms with document:saved. Reload before that lands restores the prior DB snapshot (content-disappear class).
  • @docs.plus/document-swarm (packages/document-swarm) is a private, never-published internal Bun + Playwright CLI. It drives N seeded browser "Swarm Actors" against a public editable document URL in Demo (paced, headed) or Stress (higher-concurrency, headless) mode. The glossary is in CONTEXT.md §Document swarm. Subcommands are provision (idempotently seed Supabase auth users via the Admin API into actors.json) and run --mode demo|stress --users N --url <doc>. Make sugar is swarm-demo / swarm-stress. Stable invariants: the host allowlist refuses production (local + stage.docs.plus/*.stage.docs.plus only). Per-host Actor caps (local 20 / stage 10) need --force to exceed. The Swarm Target must be public and not Read-only/soft-deleted. Actors sign in by injecting the @supabase/ssr chunked sb-<ref>-auth-token cookie via context.addCookies before navigation (not localStorage, not Google One Tap). Pad writes are non-destructive; never select-all or wipe the doc. Each actor resolves a Write Target / section. Demo contention defaults section-isolated. Optional Shuffle uses a weighted script mix. First Ctrl+C must immediately browser.close() so in-flight Playwright waits abort (do not hang draining actor loops; second SIGINT or ~5s timeout → process.exit(130)). Its eslint.config.js uses the base eslint-config/index.js preset (not library.js) because console is the CLI interface.
  • Do not hotlink raw.githubusercontent.com for demo or media. That host is a source viewer, not a CDN, and it answers 429. Serve demo files from the app (/demo-assets/… on docs.plus). Unknown production paths can return 200 HTML, so check Content-Type, not status. GitHub raw 503/429 on extension README images can also be a short outage — do not convert extensions/extension-*/README.md <picture> blocks to markdown because of it. Wait and re-check the same URL. The shipped form stays HTML <picture> with a light PNG and a dark source.
  • Backend, REST, and operations work is filed in BACKLOG-hocuspocus-backend.md under Notes/local-docs/ (gitignored). Public rows are issues #153–#169.
  • CodeQL default setup is not configured. The live security gate is bun audit plus .github/scripts/audit-gate.ts (FAIL_ON = 'high'). The old Etherpad CodeQL alerts were dismissed. Current main never contained those files.
  • Production paging is Telegram, not Discord. Grafana, deploy failure, and the runner watchdog post to Telegram. Discord is release notes plus optional push summaries. Push summaries omit (build): deploy-trigger commits from the list. A push that is only those commits stays silent. Do not gate the whole Discord post on head_commit alone — a mixed push then still printed the trigger lines. Do not add *(continued)* labels between Discord chunks.
  • scripts/check-ci.ts forces NODE_ENV=production on build:ci. Bun sets NODE_ENV=development on bun run, and that breaks next build prerender of /404. After each build:ci the replica deletes apps/webapp/.next and apps/admin-dashboard/.next.
  • Prod CI on pull_request to main does not skip Dependabot. Only discord-activity.yml skips that actor. A red Dependabot PR is a real gate fail.
  • Webapp Next stays on Pages Router. Issue #172 is a version bump on pages/. Do not add app/. Do not follow the official Supabase Next.js quickstart onto App Router. Admin on Next 16 Pages is the in-repo proof. Vendor App Router gains (Instant Navigations, the 22% request number) are not a reason to rewrite the pad.
  • Next product APIs moved to rest-api (09-09, issue #246). Next pages/api keeps only Health. Validate is POST /api/email/validate. Status is a Supabase users.status write, not a Hono route. Confirm is deleted. Do not rebuild Confirm. Do not add Status to rest-api. Do not retarget Health. Glossary: CONTEXT.md §Product HTTP. Details: webapp §Next Product APIs and hocuspocus §HTTP Modules.