Instruction file imported from furystack/json-tools (
.cursor/rules/CODE_STYLE.mdc). Copyright stays with the author.
Code Style
Prettier + ESLint enforced. Run yarn prettier and yarn lint. Generate code that satisfies @furystack/eslint-plugin from the start — don't rely on the linter to catch violations.
Naming
| Kind | Convention | Example |
|---|---|---|
| File | kebab-case .ts / .tsx |
session.ts, theme-switch.tsx |
| Spec | co-located, .spec.ts / .spec.tsx |
session.spec.ts |
| E2E spec | e2e/<flow>.e2e.spec.ts |
e2e/page.spec.ts |
| Class / type / component / service token | PascalCase | MonacoModelProvider, ScrollService |
| Function / variable | camelCase, verb-led | getCurrentUser, validateEmail |
| Constant | UPPER_SNAKE_CASE | MAX_RETRY_COUNT |
| Boolean | is* / has* / should* / can* prefix |
isAuthenticated |
| Event handler (internal) | handle* |
handleSubmit |
| Event prop callback | on* |
onSave |
| Type alias | prefer type over interface. No I prefix, no Type suffix |
type User = { ... } |
| Component props | PascalCase + Props suffix |
type LoginFormProps |
| Shade element name | kebab-case, unique, namespaced | customElementName: 'shade-login' |
Imports
Order, blank line between groups:
- Node built-ins
- External deps
@furystack/*packages- Workspace packages (
common) - Relative imports
- Type-only imports last in each group
import { join } from 'path'
import { defineService } from '@furystack/inject'
import { Shade } from '@furystack/shades'
import { ScrollService } from '../services/scroll-service.js'
import type { JsonSchemaSelectorProps } from './types.js'
Group same-package imports together. Sort alphabetically when many.
Workspace layout
json-tools/
├── frontend/ # Shades app (single workspace)
│ └── src/
│ ├── components/<x>.tsx # leaf components
│ ├── components/<x>/index.tsx + assets
│ ├── pages/<x>.tsx # route targets (compare, validate, home)
│ └── services/<x>.ts # frontend services (defineService)
└── e2e/ # Playwright specs
Use .js import suffix on relative paths (NodeNext ESM). No backend service or common workspace.
File structure
// 1. imports
import { defineService } from '@furystack/inject'
// 2. types
type SessionState = 'initializing' | 'unauthenticated' | 'authenticated'
// 3. constants
const DEFAULT_TIMEOUT_MS = 5000
// 4. helpers
const formatError = (err: unknown): string =>
err instanceof Error ? err.message : 'Unknown error'
// 5. main export
export const ScrollService = defineService({
name: 'app/ScrollService',
lifetime: 'singleton',
factory: ({ onDispose }) => { /* ... */ },
})
Service / component structure
Component render order: services → host props → refs → observable subscriptions → local state → handlers → JSX.
export const JsonSchemaSelector = Shade<JsonSchemaSelectorProps>({
customElementName: 'json-schema-selector',
render: ({ props, injector, useObservable, useState }) => {
const monacoModels = injector.get(MonacoModelProvider)
const [schema] = useObservable('schema', monacoModels.activeSchema)
const [draft, setDraft] = useState('draft', '')
const handleSave = () => { /* ... */ }
return <div>{/* ... */}</div>
},
})
JSX
Multi-line for >2 attrs. Single-line for trivial cases. No nested ternaries — use early returns for >2 branches.
// ✅
<button type="button" onclick={handleClick} style={{ padding: '8px' }}>
Save
</button>
// ❌ nested ternary
{isLoading ? <Loading /> : error ? <Err /> : <Content />}
JSDoc
Required on exported services / components / endpoints. For internal symbols, JsDoc is optional — only add it when it passes the value test below.
Value test
Before writing or keeping JsDoc, strip the type signature mentally and read the JsDoc alone. If it still tells the reader something the type does not, keep it. If it just narrates the type, delete it.
Keep when JsDoc explains:
- Intent — why this exists, what problem it solves
- Trade-offs — design choices that have alternatives
- Constraints — runtime invariants, ordering, lifecycle, side effects
- Non-obvious usage — patterns the type allows but discourages, or shapes the caller is expected to follow
Delete when JsDoc:
- Restates the function name (
isJsonObject→ "Runtime type guard for a JSON object.") - Restates parameter or return types already in the signature
- Says "Service token for monaco models" when the type is literally
Token<MonacoModelProvider, 'singleton'>
@example
Include when usage is not obvious from the signature. Examples must be copy-paste compilable — if you copy the block into a fresh .ts file and add the imports shown, tsc --noEmit passes. Drift is the main failure mode; treat examples as code, not prose.
Cross-link instead of duplicating. Write See {@link MonacoModelProvider} for active-model semantics rather than re-explaining it on every consumer.
Cross-file {@link} references
When a {@link Symbol} references a symbol declared in another file, add a type-only import for that symbol at the top of the referencing file:
import type { MonacoModelProvider } from '../services/monaco-model-provider.js'
TypeScript recognizes JsDoc references as usage (no noUnusedLocals complaint) and IDE navigation works. Type-only imports are erased under verbatimModuleSyntax: true, so circular type-only imports between two files are fine.
Example — value test in practice
// ✅ keeps — explains intent + trade-off, irreplaceable by the type
/**
* Schemas are cached by URL; the cache is process-lifetime since validators
* are pure and re-fetching adds latency on every editor change.
*/
const schemaCache = new Map<string, JsonSchema>()
// ❌ deletes — narrates the type, no extra signal
/**
* Runtime type guard for {@link JsonSchema}.
*/
export const isJsonSchema = (value: unknown): value is JsonSchema => { /* ... */ }
Comments
Only for non-obvious intent / trade-offs / constraints. Never narrate what code does.
// ✅
// Exponential backoff: 1s, 2s, 4s, 8s, capped at 30s
const delay = Math.min(1000 * 2 ** attempt, 30000)
// ❌
// Set count to 0
const count = 0