opsinjs
HandbookContributing

Code style

The conventions a reviewer will hold you to. The named-props-interface rule, what the formatter owns, and the handful of choices that are not negotiable.

The short version

Formatting is not a discussion. Prettier owns it, ESLint owns correctness, and a review comment about a line break is a bug in the configuration rather than a bug in the change.

What is left is a short list of decisions the tools cannot make:

  • A named exported props interface per component. Non-negotiable. The API tables are generated from it.
  • Erasable-syntax-only TypeScript in scripts/*.mts. No enums, no parameter properties, no namespaces; plain node runs these by type-stripping.
  • No raw colour values, anywhere.
  • British spelling in prose, American in code identifiers and CSS.
  • Named exports. One default export per module is one export you cannot find by searching.

How it works

What the tools own

  • Prettier owns formatting, plus Tailwind class ordering. The Tailwind plugin needs "tailwindStylesheet": "./app/globals.css" in .prettierrc or class sorting silently does nothing under Tailwind v4.
  • ESLint runs eslint-config-next plus the project rules. Generated output is excluded: .source/, lib/generated/, registry/__index__.ts, public/r/. Without those exclusions a clean clone lints upstream's generated code and reports errors nobody can act on.
  • TypeScript runs with strict: true. allowImportingTsExtensions is on because the .mts scripts import lib/color/*.ts with the explicit extension Node requires; noEmit makes it legal.

app/globals.css and app/product.css are in .prettierignore deliberately. The formatter reflows the linear() spring tokens to one stop per line and rewrites the block the shadcn CLI produced, which destroys the ability to diff that file against a fresh shadcn init. In a file whose order is its content, that is a bad trade.

The conventions the tools cannot check

  • One component per file, named for the file. See Naming conventions.
  • Props destructured in the signature, with className and ...props last, so a caller's className reaches cn() in the right position.
  • Early return over nested conditionals. A component with three levels of ternary in its JSX is a component that wants to be two components.
  • JSDoc on every public prop. It is not a comment; it becomes the description column of a published table.
  • Comments explain why, not what. The what is in the code; the why is the thing that gets lost.

Do this

  • Run pnpm run check before opening a pull request. It runs four things: check:generated, check:ia, check:a11y and check:llms. It does not run pnpm typecheck or pnpm lint, and CI runs those as separate steps along with pnpm build, so run those too. The four together are what the pull-request gate asks.
  • Name the interface <Component>Props and export it. This is the one convention that has a visible consequence in public if you get it wrong: an empty API table.
  • Put className last in a prop spread you are forwarding.
  • Prefer a plain function to a class. There are no classes in this codebase, and adding one to a .mts script would also break type-stripping.
  • Keep .mts scripts dependency-free. They run under plain node with no build step; that is a property worth protecting.

Not this

  • Do not add a formatter or lint rule for personal preference. Every rule costs everyone.
  • Do not use any. unknown plus a narrowing is the same amount of typing and does not disable the compiler.
  • Do not use a default export. The exception is where a framework requires one: a Next.js page, a route handler, a config file.
  • Do not use an enum. It is not erasable syntax and it will fail at runtime in a .mts script, with an error that does not mention enums.
  • Do not disable a lint rule for a file. Disable it for a line, with a reason.
  • Do not commit a generated file you edited by hand. CI regenerates and diffs.

Gotchas

  • Tailwind class sorting no-ops without tailwindStylesheet. No warning; the classes simply stay as typed and a reviewer notices before the tool does.
  • eslint 9.x prints a deprecation notice. Every 9.x release now does, because 10.x is latest. It is a deprecation notice, not a peer warning, and the pin is deliberate. See the note in the root package.json.
  • A clean clone fails typecheck before pnpm install. .source/ is gitignored and regenerated by postinstall, so the type checker points at lib/source.ts for a problem that is not there.
  • allowImportingTsExtensions without noEmit is TS5097, and the error points at the import rather than at the configuration.
  • Prettier and the CSS import order disagree by design. That is why the two stylesheets are ignored; do not "fix" it by removing the ignore.
  • A .mts script on Node 20 fails with a syntax error. The version guard at the top of each script exists to turn that into a readable message.

On this page