20 KiB
AGENTS.md
Guidance for the web/ frontend. Shared principles and TS/JS style live in
the root AGENTS.md.
Nuxt 4 + Vue 3, shipping as a web app (SSR, Cloudflare Pages preset; the
docker image uses node-server) and an Electron desktop app (static SPA).
Commands
pnpm workspace with nodeLinker: hoisted (Electron Forge needs the
npm-style layout).
pnpm setup # deps + nuxt prepare + playwright chromium (browser-mode tests)
pnpm start:dev:web # nuxt dev on :3000
pnpm start:dev:desktop # nuxt dev + electron-forge start, DESKTOP_BUILD=hybrid
pnpm build:web # production web build
pnpm package:desktop / make:desktop # nuxt generate + electron-forge package / make
pnpm check-lint # dedupe --check + check-types + check-eslint + check-fmt + check-knip
pnpm lint # fixing variant (knip --fix removes dead exports/deps/files)
pnpm test # vitest run --coverage (node + browser + nuxt projects); test-watch
pnpm qa # check-lint + test; qa-fix = lint + test
pnpm check-types # nuxt typecheck (regenerates .nuxt, then vue-tsc -b) + tsc on electron/
Build modes
DESKTOP_BUILD drives a Vite define producing __DESKTOP_BUILD__
(nuxt.config.ts, index.d.ts):
- unset/
0: web build, literalfalse, SSR on. 1: desktop build, literaltrue, SSR off, nitrostatic, renderer served fromoxynote://app/index.html.hybrid: dev only; one dev server serves both the Electron renderer and the browser opened for OAuth, so__DESKTOP_BUILD__becomes a runtime probe ofwindow.__isElectron(set by electron/preload.ts).
Branch on platform only through __DESKTOP_BUILD__, never
process.platform, import.meta.client or feature detection. The literal
substitution is the security boundary: no desktop code path can carry a
session cookie.
Auth
Better Auth with @better-auth/electron.
- Web:
app/plugins/02.auth.tscreates a standard client withcredentials: "include". - Desktop: the renderer's client is
credentials: "omit". The session lives in main (electron/auth-client.ts,electron-storeencrypted viasafeStorage); the renderer reaches auth only throughwindow.__host.auth.*IPC (electron/auth-ipc.ts, electron/preload.ts). Adding an auth operation: handler inauth-ipc.ts, expose inpreload.ts, type inindex.d.ts, desktop branch in app/composables/useAuthSession.ts. Main injects the cookie into renderer requests viawebRequest.onBeforeSendHeaders, except/api/auth/*, which must stay on the IPC bridge. - Desktop OAuth:
window.requestAuth({ provider })→ system browser at${APP_BASE_URL}/login→ redirect tooxynote://→setupMain()exchanges the code →onAuthenticatedover the bridge → app/plugins/electron-auth.client.ts refetches and navigates.
API clients
app/plugins/03.api-fetch.ts provides $coreAPIClient
(NUXT_PUBLIC_CORE_API_BASE_HTTP_URL) and $authRealtimeAPIClient
(NUXT_PUBLIC_AUTH_REALTIME_API_BASE_HTTP_URL). Both propagate SSR request
headers (captured eagerly at plugin setup; the H3 context is lost inside
onRequest on workerd) and redirect to /login on 401. When
NUXT_CORE_API_INTERNAL_HTTP_URL / NUXT_AUTH_REALTIME_API_INTERNAL_HTTP_URL
are set, SSR fetches use them (needed inside a container where the public
origin is unreachable).
Only API composables and plugins touch the clients. A component, store or util that needs a request calls a composable, adding a query or mutation there when none exists.
Data, editor, routing
- Pinia Colada (
useQuery/useMutation) is the data primitive. Auto-refetch is configured but off by default; opt in per query withautoRefetch: true. API composables: app/composables/api/; request/response types: app/utils/api/; both re-exported throughindex.tsfor auto-import. - Editor: app/components/editor/, TipTap +
Yjs/Hocuspocus (
NUXT_PUBLIC_AUTH_REALTIME_API_BASE_WS_URL).blocks/(custom nodes;upload-handler.tsroutes dropped files),comments/,diff/,drag-handle/,slash/,link/,ai/,hooks/. Editor-wide state is app/stores/editor.ts. - Diff view: adding or changing a block or node includes its branch
diff. Expose
diffStatuson its node view and style it as added, removed and modified. A block changed through its attributes rendersDiffChangeMarkerwith a<Block>DiffDetails.vuecard in its own directory. Attributes that count as one change, count per list item or are never set by a user are declared in diff/change-count.ts. - Routing: one dynamic page,
app/pages/[[organizationSlug]]/[[documentSlug]].vue; app/middleware/01.redirect.global.ts handles auth gating, onboarding and the root redirect.definePageMeta({ skipAuth: true })marks signed-out pages. - Branch links: a document URL without
?branch=<branchId>opens the default branch. The page rewrites the address bar to name the active branch, so a link built from the route already carries it; a link built elsewhere adds the param itself. The default branch is named only when the link that opened it did. - Page load: the content container mounts its editors hidden as soon
as the branch syncs and reports
initial-load-completeonly once every asynchronously rendered block has settled (settledBlockRendersin the editor store; the block types are listed in blocks/async-render.ts), so the fade-in shows final layout. Itsfade-in-completesetsusePageReady(), whichrouter.options.tsawaits before scrolling to a hash.
i18n
All user-facing text lives under i18n/locales/en/, never
inline. Each JSON file has a root namespace key; a new file is registered in
nuxt.config.ts under i18n.locales[0].files. <i18n-t> always carries
scope="global" (the default parent scope finds no ancestor and warns per
render). vee-validate uses its own messages (see README.md).
UI
- shadcn-vue components in app/components/shadcn/ui/,
prefix
ShadcnUi; Tailwind v4, theme in app/assets/css/main.css. - DESIGN.md is the style book. A change to anything it
describes (a token in
main.css, a shadcn variant, a size, radius, shadow, icon or motion convention) updates it in the same task. - Icons via
@nuxt/icon(CSS mode). The desktop build bundles the selectable title icons (selectableIconList()in app/utils/icon.ts); the web build resolves them via the server. The picker uses modules/icon-picker-css.ts, one rule per icon, loaded after the page's load event. Custom SVGs: app/assets/custom-icons/, prefixcustom-icons:. - A block's icon is defined once, in
app/utils/block-icon.ts (also heading levels
and metric chart types). Never write a block's icon name anywhere else.
The icon scanner reads
.vuefiles only, so an icon named only in a.tsfile has to be listed inclientBundle.icons, as that file's are. - Avoid
lucide:icons where possible; the app is migrating off Lucide. MingCute (mingcute:) is one good source of alternatives. - Every dialog and sheet renders a
DialogDescription(sr-onlywhen nothing visible plays that role;as-childaround<i18n-t tag="p">when interpolated), or reka-ui warns per mount. Settings action components render theirs insideActionModal, so their tests mount throughmountUnderDialogRoot.
Formatting & TS
Prettier: tabs, no semicolons, trailing commas. ESLint is type-aware
(*-type-checked via eslint.config.typescript.tsconfigPath), plus
eslint:recommended, typescript-eslint strict + stylistic,
@intlify/eslint-plugin-vue-i18n and eslint-plugin-vuejs-accessibility
(shadcn wrappers are exempt from the label-association and static-element
rules).
- A cold
check-eslinttakes about a minute; the cache (node_modules/.cache/eslint) is per-file while results depend on other files' types, so after cross-file type changesrm -rfit. CI runs cold. - ESLint cannot resolve
.vueimports, so calls through a component ref carry the disableeslint's ts program resolves .vue imports as error typed, vue-tsc accepts this. - Every
eslint-disablestates a reason after--, for false positives only. Stale disables are errors;--max-warnings 0. no-explicit-anyandprefer-function-typeare off.
knip (knip.ts) resolves auto-imports through .nuxt, so unused
components, stores and utils are detected. Blind spots: app/composables
are entry points (their internal exports go unreported) and
app/components/shadcn/ is ignored. unlisted is off by policy (hoisted
layout, transitive imports relied on). The cache is keyed by knip version:
after editing knip.ts, rm -rf node_modules/.cache/knip.
pnpm dedupe --check is a lint gate: with nodeLinker: hoisted Nitro
externalizes every resolved version, so two versions of vue in the
lockfile ship both into the server bundle and SSR crashes (Cannot read properties of null from currentRenderingInstance). Dependency bumps cause
the split without touching package.json.
TypeScript (in nuxt.config.ts): noUnusedLocals, noUnusedParameters,
noUncheckedIndexedAccess, noImplicitOverride, verbatimModuleSyntax,
noImplicitAny, allowUnreachableCode: false (mirrored in
electron/tsconfig.json). @/* and ~/* both point
to app/*. Under noUncheckedIndexedAccess, a variable filled inside a
forEach/descendants callback stays narrowed to its initializer; prefer a
sentinel or a restructure over a non-null assertion.
Testing
Layout
-
Tests are co-located 1:1 (
string.ts→string.test.ts,CalendarInput.vue→CalendarInput.nuxt.test.ts), never a mirror tree. A test spanning two files lives with the one that initiates the behaviour. Top-leveldescribes follow the source file's order. -
Test file shape: top-level constants and variables above the first
describe, never between tests; file-local helper functions below all tests, at the bottom. -
The suffix encodes the environment, never
.spec.ts, never an@vitest-environmentpragma:suffix environment for .test.tsnode pure logic; electron/withvi.mock("electron").nuxt.test.tsnuxt runtime composables/stores and all component tests ( mountSuspended, real children, mocked network).browser.test.tsheadless chromium DOM behaviour happy-dom fakes (geometry, DOMPurify) .test-d.tstypecheck compile-time contracts ( expectTypeOf).bench.tsvitest benchhot paths, only when a regression bites -
e2e lives in the repo-root e2e/ package. There is no separate integration tier: component tests are the integration layer.
-
Nuxt's default
ignoreexcludes**/*.{spec,test}.*from its scanners, so co-location insideapp/is safe. -
app/components/shadcn/is out of scope: vendored, regenerated by the CLI, excluded from collection and coverage. Rendering one as a real child, or importing one as a harness, is fine.
Naming
describe names the subject by its greppable identifier (every file has a
root describe; never bare it); it completes "it …" with observable
behaviour and its condition, present tense, no "should", specific enough to
diagnose from the failure line (it("renders the fallback avatar when the image 404s")). One level of nesting is usual, two the ceiling; describe("when X") groups tests sharing a precondition. Name components from the user's
perspective.
Parameterized tests
it.for for cases differing only in inputs and outputs; cases differing in
setup, mocks or assertions are separate its. Use %s/%i or $field
placeholders so every row has a distinct title, or a name field as the
whole title when placeholders cannot form a sentence. Fields: name,
input, expected. Hoist bulky data into builders; store mutable case
values as thunks so rows cannot share state. Never it.each (it provides no
test context).
Mocks & path coverage
- Every exported function is covered; every path gets a test (success,
plus one failure per collaborator). Entry-point modules
(
electron/main.ts,preload.ts) are tested through their side effects: mock the boundaries, import, assert the wiring. // NOCOV: <lowercase reason>.as the first line of a branch that cannot be reproduced (__DESKTOP_BUILD__splits, SSR guards, browser quirks). It is a reviewer covenant, not wired to the coverage provider, and never excuses a testable path.- Per-test behaviour via
vi.fn()configured inline.vi.mockis hoisted and file-level, so anything that must differ between tests is injected. - Repeated stub shapes become local factory closures; shared dependencies
become
test.extendfixtures; helpers shared across a directory live in a colocatedtest-helpers.ts(never re-exported for app use; excluded from coverage invitest.config.ts). - One act per test; assert every facet of that one outcome.
- Account for every injected dependency: the calls that must have happened (count and arguments) and zero counts for those that must not, even when the return value already proves the outcome.
- Only e2e gets a real backend. No network, filesystem or IPC here;
registerEndpointand stubs. - Test through public exports; never export for tests.
- Plain
expectfor preconditions;expect.softonly in the final outcome-accounting block.
Component tests
mountSuspended inside the nuxt runtime, driven like a user. Helpers:
app/components/test-helpers.ts.
- Never hardcode a translation. Assert through
t()from the helpers, interpolation included (t("sidebar.search.no-results", { query: "run" })), for assertions, lookup text and props alike. Pick the key the component actually renders (many share an English value).t()needs the nuxt context, so call it inside a test or hook, never at module scope. - Context providers:
useSidebar,useSidebarWidthand the tooltip context are page-level; mount throughmountUnderSidebarProvider/mountUnderTooltipProvider. - Overlays live in
<body>; reach them withmenuItem(),teleportedButton(),openTooltipText(). Suites driving overlays callclearTeleportedOverlays()inbeforeEach; sheet-based suites must not (removing a node vue still has mounted breaks its next patch). - vitest.nuxt-setup.ts stubs a signed-out session
for the whole nuxt project (the route middleware asks for it on every
mount) and pins
@nuxt/icontoprovider: "none". Signed-in suites useseedAuthSession/seedAuthOrganization/seedAuthAccounts. - Register an endpoint under the url the client asks for:
$coreAPIClienthas an empty base in tests (bare path),$authRealtimeAPIClientan absolute one; better-auth needs both spellings viamockAuthEndpoint(). - A failing endpoint throws
createError({ statusCode }), never a bareError(h3 dumps non-H3Errors to stderr). - happy-dom has no
matchMedia; pick a side withstubViewportMatches(). - Pointer/geometry behaviour:
mockNuxtImportthe vueuse primitives (useDraggable,useMouseInElement,useElementBounding) and drive the captured callbacks, returning refs, not plain objects (seeuseSidebarDraggable.nuxt.test.ts). - Settling:
settleMutations()for pinia-colada invalidation,settleActionSubmit()for settings action modals (mounted withmountWithFrozenClock()for theirdelay(300)). better-auth's backoff and vee-validate's scheduler are the documentedvi.waitForexceptions. - Shared app state forces
{ concurrent: false }:vi.mocksingletons,useState, pinia stores,usePersistentState,vi.stubGlobal, fake timers, the teleport target. Mark each describe that touches them, nested ones included. - A component that keeps watching after its test (colour-mode watcher,
branch sync, teleported overlay) is taken down with
enableAutoUnmount(afterEach).
Editor component tests
- Node views mount through
mountNodeView()(test-helpers/node-view.ts), withmakeNode()/makeEditor(); the command chain is recorded, so assert which commands ran. - Anything painting theme colours (charts, metric blocks, carets) needs
stubThemeColorContext()(test-helpers/theme.ts) first; happy-dom has no 2d context. - Charts are asserted via the echarts option (
chartOption()) withvue-echartsstubbed. Virtualized lists (vue-virtual-scroller) are mocked with a pass-through component. editor.commandsis rebuilt on every access; shadow the getter with a recording proxy to spy.- A live editor with real extensions when the behaviour reads the document;
collaboration components also need a provider stand-in (
document,awareness,on/off).
Snapshots, independence, determinism
- Snapshots only for golden-style serialized output (ProseMirror JSON, diff structures); explicit assertions everywhere else.
- Every test builds its own state; repeated setup goes in
beforeEachor atest.extendfixture, never module-level mutable state. - All tests run concurrently (
sequence.concurrent), so assertions and snapshots use the context-localexpect(it("…", ({ expect }) => …)). A test needing sequential execution is a smell; the one exception is call accounting onvi.mocksingletons under{ concurrent: false }with a comment. restoreMocks,unstubGlobals,unstubEnvsare set globally. Since vitest 4restoreMocksdoes not touch hand-madevi.fn()singletons; reset those inbeforeEach.- Never sleep: await
nextTick(),flushPromises(), an event or the unit's promise. Drive time with fake timers andvi.setSystemTime(); assert exact values.vi.waitForis the last resort and always carries a justifying comment.
Code style
- One assignment per statement (
no-multi-assign): no chaining, no assignment inside an expression such asconst group = (groups[key] ??= …).??=as its own statement is fine. <script setup>order: imports; compiler macros (defineProps,defineEmits,defineModel,defineExpose, …); store and composable initializations;ref/computedand plain variables; lifecycle hooks; watchers; functions.- Plain
.tsmodules follow the same shape: imports, then constants and module-level variables, then functions. No declarations between functions. - Tailwind utilities by default; custom rules go in
main.cssand are applied by class. No<style>blocks or static inlinestyle=. - Lengths are rem or screen proportions (
dvh,dvw,%), neverpx, including in arbitrary values:max-h-[min(90dvh,60rem)].
PromQL grammar
@oxynote/lezer-promql forks
Prometheus's grammar to add Grafana-style placeholders ($__interval). It is
a published package reaching the app through a pnpm override in
pnpm-workspace.yaml (so @prometheus-io/codemirror-promql
resolves it too). A grammar change is a release from its own repo, then a
bump in the override and in minimumReleaseAgeExclude.
Electron packaging
forge.config.ts allowlists only /.vite and /.output/public. The
oxynote:// protocol is registered at install for OAuth deep links. Fuses
disable RunAsNode and EnableNodeOptionsEnvironmentVariable and require
ASAR integrity. Main and preload are forced to .cjs
(vite.electron.config.ts) because package.json
is "type": "module". __API_BASE_URL__ and __APP_BASE_URL__ are baked
at build time; the build fails if they are missing.