Coding Style Guide

This document describes the coding conventions for the Reddit Moderator Toolbox codebase. It is prescriptive — it describes what code should look like, including cases where the existing codebase has not yet been fully migrated.

Tooling

Most style rules are automatically enforced. Before submitting a pull request, run:

npm run fmt         # check formatting (dprint)
npm run fmt:fix     # auto-fix formatting
npm run lint        # check linting (ESLint), then the lint:guard scanners
npm run lint:css    # check CSS linting (stylelint)
npm run lint:params # check parameter order and naming
npm run typecheck   # type-check without emitting (tsc)

The full CI gate (which also runs the tests and the build) is listed in Building and Testing.

Tool

Enforces

dprint

Formatting: indentation, line width, quotes, semicolons, trailing commas

ESLint

Code quality: variable declarations, arrow callbacks, strict equality, restricted imports, React Hooks rules

stylelint

CSS quality: no hex colors, no named colors, no rem, --toolbox-* custom property naming, radius/shadow tokens, type scale

TypeScript (strict mode)

Type safety, unused locals, optional property exactness


1. Formatting

Formatting is handled by dprint and should never require manual effort. The canonical settings are in dprint.json. The rules below describe what dprint produces.

Indentation

Tabs, not spaces. One tab character per indent level. This respects each contributor’s preferred visual width without encoding that preference into the file.

Line width

120 characters. Wrap at natural break points (before operators, between arguments) rather than mid-expression.

Quotes

  • TypeScript/TSX: always single quotes — 'hello'

  • JSX attributes: double quotes (HTML convention) — <div className="foo">

  • Template literals: use when the string contains interpolation or a literal quote — `Hello, ${name}!`

Semicolons

No semicolons. ASI (Automatic Semicolon Insertion) handles termination correctly in all cases that dprint would produce. Semicolons add visual noise without functional benefit when a formatter is in the loop.

The only ASI pitfall — a line beginning with [, (, or ` — is not an issue because dprint reformats multi-statement patterns.

Braces

Always required for control flow bodies, even single-line:

// correct
if (condition) {
	doSomething()
}
// incorrect
if (condition) doSomething()

Arrow function parentheses

Always include parentheses around arrow function parameters, even for a single argument:

// correct
const doubled = (x,) => x * 2
items.map((item,) => item.id)
// incorrect
const doubled = x => x * 2
items.map(item => item.id)

Consistent parentheses make it easier to add a second parameter, apply a type annotation, or destructure without restructuring the function signature.

Trailing commas

Use trailing commas in all multi-line arrays, objects, and parameter lists:

const config = {
    name: 'toolbox',
    version: '7.0.0',
}

function createNote(
    subreddit: string,
    author: string,
    text: string,
) { ... }

2. Naming Conventions

Case

Used for

UPPER_SNAKE_CASE

Build-time / environment-level primitives only (e.g. BUILD_TYPE, BUILD_SHA)

camelCase

Variables, const bindings, function parameters, object properties, module-level functions

kebab-case

Non-component filenames, CSS custom properties, CSS class names in global CSS

PascalCase

React components, classes, TypeScript interfaces, type aliases, enums, enum members

PascalCase filenames

React component files only (e.g. RemovalReasonsOverlay.tsx, ActionButton.tsx)

Important distinctions

UPPER_SNAKE_CASE is not for every const. Runtime constants are camelCase:

// correct
const maxRetries = 3
const defaultLabel = 'Moderator'

// incorrect — these are not build-time constants
const MAX_RETRIES = 3
const DEFAULT_LABEL = 'Moderator'

External field names keep their source format. Use camelCase for application-owned object properties, but preserve API, storage, DOM dataset, Reddit payload, and other wire-format names when matching those external contracts. Normalize to camelCase at the app boundary when it makes the calling code clearer.

Enum members use PascalCase, not UPPER_SNAKE_CASE (TypeScript best practice):

// correct
enum LoadState {
	Pending,
	Loaded,
	Failed,
}

// incorrect — Java-ism, not idiomatic TypeScript
enum LoadState {
	PENDING,
	LOADED,
	FAILED,
}

Abbreviations

Avoid abbreviations unless universally understood in context. Acceptable short forms: id, url, css, ui, dom, api, bg (in CSS variable names only). When in doubt, spell it out.


3. TypeScript

Variable declarations

  • Always const; use let only when the binding is reassigned

  • Never var

Import style

Mark type-only imports as such: import type when everything imported is a type, or an inline type on the individual names in an import that also brings in values. It signals which imports have runtime effects. ESLint enforces this (consistent-type-imports), and npm run lint:fix rewrites violations:

import {useState,} from 'react'
import type {ReactNode,} from 'react'
import {Component, type ErrorInfo,} from 'react'

Types vs. interfaces

  • interface for object shapes (extendable, produces better error messages)

  • type for unions, intersections, mapped types, and aliases

// object shape → interface
interface Props {
	subreddit: string
	author: string
}

// union → type
type LoadState = 'pending' | 'loaded' | 'failed'

Optional properties

Mark optional props with ?. Do not redundantly add | undefined — the project uses exactOptionalPropertyTypes, so ? and | undefined have different semantics:

// correct
interface Props {
	title?: string
}

// incorrect — redundant under exactOptionalPropertyTypes
interface Props {
	title?: string | undefined
}

Type assertions and narrowing

Avoid as assertions. Prefer type narrowing via guards:

// correct
if (value instanceof Error) {
	log.error(value.message,)
}

// avoid — suppresses the type system
const message = (value as Error).message

Use the satisfies operator when you want both the inferred type and a constraint check:

const config = {
	name: 'toolbox',
	env: 'dev',
} satisfies BuildConfig

Platform-exclusive code

Guard code that only one browser needs on isFirefoxBuild from util/infra/buildenv. It is fixed at build time from BUILD_TARGET, so Rollup removes the guarded code from the other browser’s bundle instead of shipping it unused.

The guard must be an if statement outside any try block. Rollup keeps everything referenced inside a try, and does not always drop a ternary’s dead branch; either way the code silently stays in the Chrome bundle.

// correct — the container path is dropped from the Chrome bundle
function send (options: ForwardedRequestInit,): Promise<Response> {
	if (isFirefoxBuild && container) {
		return fetchInContainer(container, url, options,)
	}
	return fetch(url, toRequestInit(options,),)
}

// avoid — inside a try, so fetchInContainer ships in Chrome anyway
try {
	return isFirefoxBuild && container
		? await fetchInContainer(container, url, options,)
		: await fetch(url, toRequestInit(options,),)
} catch (error) {
	log.error('Fetch request failed:', error,)
	throw error
}

Tests run as the Firefox build; to exercise the Chrome path, mock util/infra/buildenv. To confirm the guard works, build and grep build/chrome for the guarded code.


4. Functions and Arrow Functions

Callbacks

Anonymous callbacks must be arrow functions — prefer-arrow-callback is enforced by ESLint (with allowNamedFunctions, so a named function-expression callback such as a module’s function init() is still permitted). Arrow functions do not have their own this, which avoids an entire class of bugs:

// correct
items.filter((item,) => item.active)

// incorrect
items.filter(function (item,) {
	return item.active
},)

Top-level module functions

Use function declarations for top-level utilities. They are hoisted (callable before their definition), named in stack traces, and visually distinct from value bindings:

// correct — function declaration
export function formatDate (date: Date,): string {
	return date.toLocaleDateString()
}

// also acceptable — arrow const
export const formatDate = (date: Date,): string => date.toLocaleDateString()

Arrow body style

Implicit returns are expected for simple expressions: ESLint’s arrow-body-style: as-needed rejects a braced body whose only statement is a return. Use braces and an explicit return for multi-statement logic:

// simple transform — implicit return is fine
const ids = items.map((item,) => item.id)

// multi-line logic — use braces and explicit return
const result = items.map((item,) => {
	const label = formatLabel(item,)
	return {id: item.id, label,}
},)

5. Imports

Organize imports into three groups, separated by blank lines:

  1. External packages — npm dependencies (react, webextension-polyfill, etc.)

  2. Internal absolute paths — store, util, shared components

  3. Relative imports — sibling files (./, ../)

import {useState,} from 'react'
import type {ReactNode,} from 'react'

import {ActionButton,} from '../../../shared/controls/ActionButton'
import store from '../../../store'
import createLogger from '../../../util/infra/logging'

import css from './AddUserNotePopup.module.css'

No re-export barrel files. When moving or refactoring files, update all importers to the new path directly. Re-export shims obscure the real location and create dead code.


6. JSDoc and Comments

File-level JSDoc

Every file must begin with a single-line /** ... */ comment describing its purpose. This appears at line 1, before imports:

/** Popup for creating and viewing usernotes, with tabs for both Toolbox notes
 * and native Reddit mod notes. */

import {useState,} from 'react'

Exported symbols

Every exported function, component, hook, class, interface, and type alias must have a JSDoc block. Use @param for parameters and @returns when the return value is non-obvious:

/**
 * Attaches a delegated event listener to `parent` that fires `handler` when
 * an event of `type` bubbles up from a descendant matching `selector`.
 * @param parent Element or document to attach the listener to.
 * @param type DOM event type (e.g. `'click'`).
 * @param selector CSS selector to match against event targets.
 * @param handler Called with the matching element and original event.
 */
export function delegate<E extends Event = Event>(
    parent: Element | Document,
    type: string,
    selector: string,
    handler: (target: Element, event: E) => void,
): void { ... }

For short, self-explanatory functions, a single-line JSDoc is fine:

/** Shorthand for `element.querySelector`. */
export function qs<T extends Element = Element,> (
	selector: string,
	parent: Element | Document = document,
): T | null {
	return parent.querySelector<T>(selector,)
}

Inline comments

Only comment on why, never on what — well-named identifiers already describe what. A comment is warranted when:

  • There is a non-obvious constraint or invariant

  • The code works around a specific external bug or browser quirk

  • The behavior would surprise a reader unfamiliar with the context

// correct — explains a non-obvious constraint
// composedPath() must be called synchronously; it is empty after the event
// handler returns
const path = event.composedPath()

// incorrect — restates what the code already says
// get the path of the event
const path = event.composedPath()

7. React Components

Component structure

  • Functional components only; no class components

  • Named exports for components (not default exports)

  • Props typed via a local interface Props with JSDoc on any non-obvious prop

  • Co-locate the .module.css file with the component

/** Badge showing a user's note count for a subreddit. */

import css from './UserNotesBadge.module.css'

interface Props {
    subreddit: string
    author: string
    /** Text shown when no note exists. */
    defaultText: string
    onClick: React.MouseEventHandler<HTMLButtonElement>
}

export function UserNotesBadge({subreddit, author, defaultText, onClick}: Props) {
    ...
}

Event handler naming

  • on* for callback props passed from outside — onClose, onSave, onChange

  • handle* for internal handlers defined in the component — handleSave, handleRemove, handleKeyDown

CSS Modules

Use CSS Modules for all component-scoped styles. Import as css and apply with className={css.className}:

import css from './Foo.module.css'

export function Foo () {
	return <div className={css.container}>...</div>
}

8. State Management and Async

Local state

  • useState for independent state fields

  • useReducer for complex state machines with multiple interdependent fields

Redux

Redux (via RTK) is reserved for:

  • User settings (the settings slice)

  • Cross-component UI feedback: spinners, toast messages, context menus

Do not put ephemeral component state into Redux.

Async patterns

Prefer async/await with try/catch:

async function handleSave () {
	try {
		await saveNote(note,)
		dispatch(positiveTextFeedback('Note saved',),)
	} catch (error) {
		log.error('Failed to save note:', error,)
		setError('Could not save note. Try again.',)
	}
}

Use .then().catch().finally() only for fire-and-forget operations where the caller intentionally does not await:

onRemoveNote(noteId,)
	.then(() => setNotes((prev,) => prev.filter((n,) => n.id !== noteId)))
	.catch((error,) => log.error('Remove failed:', error,))
	.finally(() =>
		setBusyNoteIds((prev,) => prev.filter((id,) => id !== noteId))
	)

9. CSS

Automatically enforced (stylelint — npm run lint:css)

Color format — No hex colors, no named colors, modern rgb() notation with space-separated values and / for alpha:

/* correct */
color: rgb(0 0 0 / 50%);
background: rgb(206 227 248);

/* incorrect — all three are linting errors */
color: rgba(0, 0, 0, 0.5);
background: #cee3f8;
color: red;

CSS variable naming — Custom properties must start with --toolbox- (stylelint enforces the prefix). Follow the --toolbox-<category>-<purpose> convention for the rest of the name:

--toolbox-accent-color
--toolbox-error-bg
--toolbox-button-bg
--toolbox-text-heading

No rem units — rem resolves to the document root font size, which Reddit controls. Use px, em, %, or vh/vw instead.

Geometry tokens — Corner radii and drop shadows come from the --toolbox-radius-* and --toolbox-shadow-* tokens in css/base.css, so a theme can reshape the UI in one place. Literal px/em radii, --toolbox-sp-* used as a radius, and literal drop shadows are errors. 50% (circles), 0, inset shadows, and blur-less edge markers stay literal. A rule that deliberately matches a host-page element’s shape keeps its literal value behind a stylelint-disable-next-line comment that says so. Inline React styles follow the same rule (borderRadius: 'var(--toolbox-radius-md)'), though nothing lints them.

/* correct */
border-radius: var(--toolbox-radius-md);
box-shadow: var(--toolbox-shadow-md);

/* incorrect */
border-radius: 3px;
box-shadow: 0 2px 8px rgb(0 0 0 / 25%);

Type scale — font-size, line-height, and font use the --toolbox-fs-* tokens (fs-N = N px at the 12px base, 1em for 12), never the --toolbox-sp-* spacing scale. The two scales have the same values today, but keeping them apart lets spacing be retuned without resizing text. There is no fs-12 or sp-12 token, and stylelint rejects both: write 1em.

/* correct */
font-size: var(--toolbox-fs-11);
padding: var(--toolbox-sp-4);

/* incorrect */
font-size: var(--toolbox-sp-11);

!important (warning) — Every use of !important is flagged. See the manual section below for when it is acceptable.

Descending specificity — A rule that appears later but has lower specificity than an earlier rule targeting the same property is an error.

CSS Module class names — Classes in *.module.css files are camelCase (selector-class-pattern). Global classes referenced through :global() keep their kebab-case names behind a stylelint-disable selector-class-pattern comment.


Manually enforced (code review)

Colors should use CSS variables — Every color should come from extension/data/css/base.css. Direct color values in component CSS almost always indicate a missing variable. When no variable exists for the role, use rgb() with an /* intentional: <reason> */ comment:

/* correct */
background-color: var(--toolbox-bg);
color: var(--toolbox-text-body);

/* only when no variable exists for this semantic role */
background-color: rgb(255 255 22); /* intentional: bright yellow is the canonical text-search highlight color */

!important requires an explanatory comment — Only acceptable when overriding Reddit or RES styles where specificity escalation alone cannot win. The comment must name the rule being overridden:

/* acceptable — Reddit's stylesheet sets this with high specificity */
.toolbox-scope a {
	color: var(--toolbox-link-color) !important; /* overrides Reddit's .entry a rule */
}

/* never — do not use !important within toolbox's own component styles */
.container {
	display: flex !important;
}

Unit choice — Toolbox UI should use a predictable component font base.

  • Shadow DOM (mountReactInBody, reactRenderer): :host sets the Toolbox UI base font size.

  • Light DOM (mountReactInLightBody, mountToTarget with shadow: false): components may inherit Reddit/page font sizing unless mounted under a Toolbox UI root that sets the same base.

  • Shared React components should not depend on Reddit’s ambient page font size. If a component may render in both contexts, ensure its mount root provides the Toolbox base before using em-based typography.

Toolbox’s UI typography base is 12px. Convert typography relative to that base: 10px = 0.8333em, 11px = 0.9167em, 12px = 1em, 13px = 1.0833em, 14px = 1.1667em.

Use px for borders, radii, fixed icon sizes, and fixed control geometry; em for typography and text-coupled spacing inside Toolbox UI components; % for container-relative widths; vh/vw for full-page overlays.

Design tokens and themes — extension/data/css/base.css defines the token families: colors (--toolbox-bg, --toolbox-text-*, --toolbox-accent-*, and so on, with dark-mode values set in the same file), spacing (--toolbox-sp-*), type scale (--toolbox-fs-*), corner radii (--toolbox-radius-*), shadows (--toolbox-shadow-*), and stacking (--toolbox-z-*). Much of the UI renders in shadow roots, which only inherited custom properties can reach, so a theme overrides tokens and never targets component selectors. The classic theme (css/classic.css, active while the General Settings module puts .toolbox-theme-classic on <html>) is the reference. When a theme needs a component-specific tweak that no shared token covers, the component exposes hook properties with fallbacks to its default, for example var(--toolbox-general-button-bg, var(--toolbox-action-bg)) in shared/controls/GeneralButton.module.css, and the theme sets the hooks. The classic dark palette is generated from the light palette in base.css; after changing a light color, regenerate it with UPDATE_CLASSIC_DARK=1 npx vitest run extension/data/css/classic.test.ts (the test fails until you do).

CSS Modules vs. global CSS — CSS Modules (.module.css) for all React component styles; global .css for legacy Reddit DOM injection and theme variables. Do not add new global CSS rules for React components.

Selector specificity — Keep specificity low. Prefer class selectors over element selectors chained with classes. Use :is() for forgiving selector lists:

/* correct */
:is(.toolbox-scope, :host) .toolbox-button { ... }

/* avoid — higher specificity than necessary */
div.toolbox-scope div.toolbox-button { ... }

The one exception to “prefer class over element” is when the element type is semantically meaningful — specifically, when a class is applied to multiple element types and the rule should only apply to one of them.

Specificity overrides — When a Toolbox rule must beat a Reddit or subreddit CSS rule and !important is not appropriate, a specificity lift may be required. Two approved patterns:

  • Class lift — repeat .toolbox-scope as a compound selector. Raises specificity from (0,2,0) to (0,3,0). Use when competing against multi-class Reddit rules:

    /* beats Reddit's .site-table .link .author at (0,3,0) */
    .toolbox-scope.toolbox-scope .toolbox-tagline .toolbox-comment-author { ... }
    
  • Element lift — qualify the scope anchor with body. Raises specificity from (0,2,0) to (0,2,1). Use when competing against element-qualified Reddit rules:

    /* beats Reddit's a.author at (0,1,1) */
    body.toolbox-scope .toolbox-submission-author { ... }
    

The two patterns are not interchangeable: (0,3,0) and (0,2,1) are in different columns and don’t beat each other. Pick based on the actual adversary.

Every specificity lift must have a comment naming the rule being beaten and its specificity. Without the comment, the doubled class looks like a copy-paste error.