Imported from tatran0195/itsplan (
apps/web/AGENTS.md). Install upstream withnpx skills add tatran0195/itsplan --skill web. Copyright stays with the author.
web (Next.js) — rules
Next.js App Router, SSR (not SPA). Tailwind v4 + shadcn/ui. See root AGENTS.md.
Imports
- A feature is a self-contained module: imports within the same feature use relative paths
(
./,../); imports of the shared layer or another feature use the@/alias. - A feature imports the shared layer (
@/utils,@/lib,@/hooks,@/context,@/services,@/components/*), never another feature — one accepted one-way exception:work-itemsmay composeissuepresentational components. Keep it one-directional, no cycles; if a second such need appears, move the shared piece tocomponents/common. - The shared layer never imports a feature.
app/routes stay thin: mount the feature page and providers only. @/cloudis the seam for the hosted edition. It resolves tosrc/ce/index.ts, which exports what a self-hosted instance runs — a screen it does not sell renders nothing. A cloud-only component is imported from there and nowhere else. The hosted build pointsCLOUD_UI_ENTRYat its own module exporting the same names, andWEB_TRACING_ROOTat the root its workspace has; unset, both are what this repository needs.
Feature structure & decomposition
- One component per file — no exceptions. A file exports exactly one component, even when the extra one is three lines long. On finding a file with two, split it: each component moves to its own file named after it, and the imports are updated.
- Keep components small and single-purpose. Split when one grows past ~120 lines or mixes concerns (layout + fetch + mutation + dialog). The entry component becomes a thin composition; each part is its own file. Push state to where it is used; the parent holds only what it coordinates.
- A feature's own parts go in purpose folders:
components/,hooks/,context/,services/,utils/. Split by purpose even when a folder holds one file. A React context and itsuse*reader go incontext/, nothooks/. The feature root holds only the entry component (named after the feature) — nopages/folder (routing issrc/app/). - A feature with more than two pages groups its components by page.
components/gets one subfolder per page, named after that page (components/profile/,components/security/), and each page's parts go in its own folder. A component used by two or more of the feature's pages stays directly incomponents/. A feature with one or two pages keepscomponents/flat. - Types live next to the code that produces them (API-response type in its service, prop type in
its component), not in a
types/folder. Add atypes/file only for a standalone shape shared by several modules with no single owner. - Shared vs local decides feature-folder vs
src/: a part used by more than one feature goes to the shared layer (src/utils,src/lib,src/hooks,src/context,src/services,components/{common,ui}); a part used by one feature stays in it. Promote only when a second feature needs it (YAGNI) — don't pre-share. - The shared layer splits by what a module depends on:
src/libholds wrappers over external packages (api.ts,auth-client.ts,markdown.ts,dnd.ts) plus shadcn'sutils.ts(cn, fixed bycomponents.json);src/utilsholds own helpers and constants with no external package behind them.src/contextholds shared React contexts and theiruse*readers. components/commongroups by purpose:agent-chat/,attachments/,chart/,editor/,fields/,hotkeys/,inputs/,overlay/,page/,permissions/,share/,skeleton/,timeline/. A component that fits none of them stays at thecommon/root. Imports of a sibling in the same folder are relative; everything else uses@/.- Component files use the feature name as a PascalCase prefix, file name = exported name. Service
files carry a
.service.tssuffix (passkeys.service.ts). Other non-component files use plain descriptive names (the folder gives the context).
Translations
next-intl, language from the NEXT_LOCALE cookie — no [locale] route segment.
messages/<locale>/<namespace>.json, one file per namespace, namespaces are domains
(issue, settings) not pages.
- English is the source:
src/i18n/messages.tsimports it statically, merges the chosen language over it, and types everyt('…'). A new key goes there first or typecheck fails; an untranslated one renders its English text. - A new namespace needs its file in every language plus an entry in
defaultMessages. - A new language:
messages/<code>/,src/i18n/locales.ts,src/hooks/useDateFnsLocale.ts, andLOCALES/Localeinapps/api/src/modules/user-preferences/. No migration — thelocalecolumn has no CHECK. One written right to left also goes inRTL_LOCALES. - Don't subset messages per route with
pick(): a missing namespace then fails at runtime instead of at typecheck. - The layout mirrors for a right-to-left language, so position new components with the logical
utilities —
ms/me,ps/pe,start/end,border-s/border-e,text-start/text-end— notml-,left-ortext-left, which compile to physical CSS and ignoredir. Aleft/translate-xcentring pair and an edge the caller names (Sheet side="right") stay physical; content that is not prose (timelines, charts, code) sits in adir="ltr"container, and user-written text getsdir="auto".docs/dev/i18n.mdhas the whole picture. bun run lintchecks the message files themselves (eslint-plugin-i18n-json, wired ineslint.config.mjs): every language carries every namespace ofmessages/enwith the same key set, and each message parses as ICU. A key added to English alone fails CI. Angle brackets in a message are rich-text tags to next-intl — writeowner/repo, not<owner>/<repo>, or the message does not parse.
docs/dev/i18n.md has the whole picture, including how a switch reaches the server render.
Rules
- Prefer Server Components and server-side data fetching; reach for client components only for interactivity/hooks.
- A screen that has to stay live calls
useLiveRefresh({ scope, targets })with a scope from@/utils/revScopes— never its own polling.SyncProviderpolls every registered scope in one request and invalidates the targets of the ones that moved. - A paged list holds its window in
usePaging()and rendersListPager. The hook ownspage/pageSize; a filter change calls itsreset(), andListPagerpulls the page back when deleting rows leaves the reader past the end. Spreadpaging.paramsstraight into the query — the API takespage/pageSizeand answers{ items, total, page, pageSize }. A "show more" list reads the same route throughuseInfiniteQuerywithgetNextPageParam: nextPageParam(useCompletedCyclesQueryis the shape to copy); a feed reads a cursor route instead. A picker that needs every row calls the list's/optionsendpoint — never a paged one with a largepageSize. - The API client is split by domain.
lib/api/core/holds the transport —client.ts(API_URL,request(),uploadFile(),ApiError,apiFailure, the 401 sign-out),paging.ts(Page<T>,PageParams,pageQuery,nextPageParam) andmedia.ts(mediaUrl).lib/api/endpoints/holds one flat file per domain, named after the module ofapps/api/src/modulesit calls: an endpoint on the API inmodules/cycles/is reached fromlib/api/endpoints/cycles.ts. A file keeps its DTOs next to the functions that return them, and exports both by name — there is noindex.tsanywhere underlib/api, and no barrel re-exporting the domains. Types cross domains withimport type, which the compiler erases, so a composite likeProjectDetailis assembled in the domain that owns the route without a runtime cycle. A moved type whose name matches a DOM global (Permissions,Notification,Storage) resolves to that global when its import is missing, so tsc stays silent while the type is wrong: import it explicitly. - Call the backend over HTTP at the API origin.
lib/api/core/client.tstakes it fromutils/runtimeEnv, which readsAPI_URLin the server process and hands it to the browser through the inline script incomponents/runtime-env-script.tsx. A per-instance value goes through there — neverprocess.env.NEXT_PUBLIC_*in a component, whichnext buildinlines and which pins the image to one instance. - Avatars and attachments render from
/media/...on the web origin (app/media, which streams them from the api), not from an absolute api url. That keeps them local images fornext/image:images.remotePatternsis frozen into the standalone build, so an api origin listed there would only be valid for the instance that built the image. - Every write to the API tells the user how it went. A failed mutation is toasted by the
MutationCacheincomponents/providers.tsx, so a call site adds nothing for the failure; it adds thetoast.success(...)for the success, from the mutation'sonSuccess, with a translated message naming what was saved. A write whose result the screen already shows — a row that appears, a field that fills in, a dialog that closes on the created entity — needs no success toast; one whose effect is invisible does. A mutation that renders its own error instead opts out withmeta: { suppressErrorToast: true }. - A reader gets values, not disabled controls. When the current user may not change a setting, render its state — an icon plus a word, a plain row — instead of a switch, input, or button that is disabled. A disabled control reads the same whether it is off or merely locked. Say once, next to the setting, who can change it. And when a switch that gates a whole section is off, drop the section: the settings under it and the instructions that depend on them change nothing until it is on. One read-only state looks the same everywhere: the same icon and the same wording for on and off, in the row the control would have taken — reuse the component that already renders it rather than styling a second variant.
- Add shadcn components with
bunx shadcn@latest add <name>(config incomponents.json). - Don't edit
src/components/ui/— those files are generated and re-adding a component overwrites them. Style them from the outside instead: every primitive carries adata-slotattribute, so a rule inglobals.csstargeting[data-slot='…']survives the update (the overlay shadows and the right-to-left corrections both work this way). Such a rule has to sit outside@layer: Tailwind orders its layerstheme, base, components, utilities, so anything in@layer componentsloses to the utility classes on the element. When the change is a prop the component already exposes (Sidebar side), pass it from the caller rather than writing CSS. One prop reaches neither the caller nor CSS and has to be re-applied after a re-add:chart.tsxpassesdebounceto recharts'ResponsiveContainer, without which a chart in a container that resizes itself loops until React reports "Maximum update depth exceeded". - Tailwind v4: no
tailwind.config; tokens live insrc/app/globals.css(@theme, CSS vars). - Nothing per-instance may reach the bundle: the image is published once and serves every
instance. New config of that kind belongs in
utils/runtimeEnv. - Don't remove
output: "standalone"+outputFileTracingRootfromnext.config— the Docker image depends on them. - When touching
localStorage/windowin a render path (e.g. auseStateinitializer), guard withtypeof window === 'undefined'— client components still server-render.
This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in node_modules/next/dist/docs/ (resolved from this file's directory; in monorepos the next package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by next dev — verify at node_modules/next/dist/server/lib/generate-agent-files.js. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.