Style Guide
This guide documents the code style and conventions used in Sightline.
TypeScript
General Rules
- Strict mode - All code must pass strict TypeScript checks
- No
any- Use proper types;unknownif type is uncertain - Explicit returns - Exported functions should have explicit return types
- Prefer interfaces - Use
interfacefor object shapes,typefor unions
Type Definitions
// Good: Interface for object shapes
interface UserData {
id: string;
name: string;
email?: string;
}
// Good: Type for unions
type Status = 'pending' | 'active' | 'archived';
// Good: Explicit return type
function getUser(id: string): UserData | null {
// ...
}
// Avoid: any type
function processData(data: any) { } // Bad
// Good: unknown with type guards
function processData(data: unknown) {
if (isUserData(data)) {
// data is now UserData
}
}Imports
// Good: Specific imports
import { useState, useCallback } from 'react';
import type { Incident, LocationData } from '@disclosureos/sightline-core';
// Avoid: Namespace imports for types
import * as Types from '@disclosureos/sightline-core'; // Bad
// Good: Separate type imports
import { formatDate } from '@/lib/utils';
import type { DateFormat } from '@/lib/utils';React
Component Structure
'use client'; // Only if needed
/**
* ComponentName - Brief description
*
* @example
* <ComponentName prop="value" />
*/
import { useState, useCallback, useMemo } from 'react';
import type { ComponentProps } from './types';
// Types at top
interface ComponentNameProps {
/** Description of requiredProp */
requiredProp: string;
/** Description of optionalProp */
optionalProp?: number;
/** Callback when something happens */
onAction?: (value: string) => void;
}
// Component
export function ComponentName({
requiredProp,
optionalProp = 10,
onAction,
}: ComponentNameProps) {
// Hooks first
const [state, setState] = useState(false);
// Memoized values
const computedValue = useMemo(() => {
return expensiveComputation(requiredProp);
}, [requiredProp]);
// Callbacks
const handleClick = useCallback(() => {
setState(true);
onAction?.(requiredProp);
}, [onAction, requiredProp]);
// Early returns
if (!requiredProp) return null;
// Render
return (
<div className="component-name">
{/* JSX content */}
</div>
);
}Hooks Rules
// Good: Stable dependencies
const handleSubmit = useCallback((data: FormData) => {
submitData(data);
}, []); // Empty deps if no dependencies
// Good: Correct dependencies
const filteredItems = useMemo(() => {
return items.filter(i => i.status === status);
}, [items, status]); // All used values in deps
// Avoid: Object/array literals in deps
useEffect(() => {
fetchData({ type: 'user' }); // Bad: object created each render
}, [{ type: 'user' }]);
// Good: Stable reference
const params = useMemo(() => ({ type: 'user' }), []);
useEffect(() => {
fetchData(params);
}, [params]);Server vs Client Components
// Default: Server Component (no 'use client')
// src/app/page.tsx
export default function Page() {
return <ServerRenderedContent />;
}
// Client Component: Only when needed
// src/components/interactive-widget.tsx
'use client';
import { useState } from 'react';
export function InteractiveWidget() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}CSS / Tailwind
Class Organization
// Order: Layout → Sizing → Spacing → Typography → Colors → Effects → States
<div className="
flex flex-col {/* Layout */}
w-full max-w-md {/* Sizing */}
p-4 gap-2 {/* Spacing */}
text-sm font-medium {/* Typography */}
bg-card text-foreground {/* Colors */}
rounded-lg shadow-md {/* Effects */}
hover:bg-accent {/* States */}
">Responsive Design
// Mobile-first approach
<div className="
p-2 text-sm {/* Mobile */}
md:p-4 md:text-base {/* Tablet */}
lg:p-6 lg:text-lg {/* Desktop */}
">Theme Variables
/* Use CSS variables for theme colors */
.component {
background: hsl(var(--background));
color: hsl(var(--foreground));
border-color: hsl(var(--border));
}
/* Or Tailwind equivalents */
<div className="bg-background text-foreground border-border" />File Naming
| Type | Convention | Example |
|---|---|---|
| Components | kebab-case directory, index.tsx | filter-sidebar/index.tsx |
| Utilities | kebab-case | format-utils.ts |
| Types | kebab-case | incident-types.ts |
| Tests | same name + .test | format-utils.test.ts |
| Hooks | camelCase with use | useMapInteractions.ts |
Directory Structure
src/
├── core/ # Core functionality
│ ├── types/ # Type definitions
│ ├── index.ts # Public exports
│ └── *.tsx # Core components
├── adapters/ # Data adapters
├── controls/ # UI controls
│ └── control-name/
│ ├── index.tsx # Main export
│ ├── types.ts # Local types (if needed)
│ └── utils.ts # Local utilities (if needed)
├── hooks/ # Custom hooks
├── lib/ # Shared utilities
└── ui/ # Base UI componentsTesting
Test File Structure
// component.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { ComponentName } from './index';
describe('ComponentName', () => {
// Setup
const defaultProps = {
requiredProp: 'test',
};
// Basic render test
it('renders without crashing', () => {
render(<ComponentName {...defaultProps} />);
expect(screen.getByText('test')).toBeInTheDocument();
});
// Interaction test
it('calls onAction when clicked', () => {
const onAction = vi.fn();
render(<ComponentName {...defaultProps} onAction={onAction} />);
fireEvent.click(screen.getByRole('button'));
expect(onAction).toHaveBeenCalledWith('test');
});
// Edge case
it('handles missing optional props', () => {
render(<ComponentName {...defaultProps} optionalProp={undefined} />);
// Assertions
});
});Test Naming
// Describe: Component/function name
describe('formatDate', () => {
// It: Behavior description
it('formats ISO date to readable string', () => {});
it('returns "Unknown" for invalid dates', () => {});
it('handles null input gracefully', () => {});
});Git Conventions
Branch Naming
feat/add-timeline-keyboard-nav
fix/marker-clustering-zoom
docs/update-configuration-guide
refactor/extract-filter-hooks
test/add-e2e-theme-tests
chore/update-dependenciesCommit Messages
Follow Conventional Commits:
feat: add keyboard navigation to timeline
fix: resolve marker clustering at high zoom levels
docs: update configuration reference
refactor: extract filter logic to custom hook
test: add E2E tests for theme switching
chore: update dependencies to latest versions
# With scope
feat(timeline): add keyboard navigation
fix(map): resolve marker clustering at high zoomPR Descriptions
## Summary
Brief description of changes
## Changes
- Added X
- Fixed Y
- Updated Z
## Testing
How to test these changes
## Screenshots
(If UI changes)Documentation
JSDoc Comments
/**
* Formats an incident date for display.
*
* @param date - ISO date string (YYYY-MM-DD)
* @param format - Output format (default: 'long')
* @returns Formatted date string
*
* @example
* formatIncidentDate('2024-01-15') // "January 15, 2024"
* formatIncidentDate('2024-01-15', 'short') // "Jan 15"
*/
export function formatIncidentDate(
date: string,
format: 'long' | 'short' = 'long'
): string {
// Implementation
}Component Documentation
/**
* FilterSidebar - Displays filter controls for incidents
*
* Provides filtering by:
* - Location (country, site type, sensitivity)
* - Classification (Hynek, Vallee)
* - Evidence (detection methods, evidence types)
* - Quick toggles (radar, witnesses, etc.)
*
* @example
* // Basic usage
* <FilterSidebar />
*
* // With custom visibility
* <FilterSidebar
* showQuickFilters={false}
* showClassification={true}
* />
*/