Sprinter Docs

Contributing

Coding standards, testing expectations, file organization, auth patterns, and quality rules for the Sprinter Platform.

Contributing

This guide covers the standards and conventions for contributing to the Sprinter Platform. These rules are enforced across the codebase and apply to all contributors -- human and AI alike.

Coding standards

Use shadcn/ui for all interactive UI

Every button, input, select, card, dialog, and interactive element must come from @/components/ui/. Never use raw HTML <button>, <input>, or <select> elements.

// Correct
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";

// Wrong
<button onClick={handleClick}>Save</button>
<input type="text" onChange={handleChange} />

No hardcoded colors

Never use Tailwind color classes like text-blue-600, bg-red-50, or text-green-800. Use CSS variables from the design system:

NeedClass
Primary text/bgtext-primary, bg-primary
Muted backgroundsbg-muted, text-muted-foreground
Bordersborder
Success statustext-status-success, bg-status-success/10
Error statustext-status-error, bg-status-error/10
Warning statustext-status-warning, bg-status-warning/10
Info statustext-status-info, bg-status-info/10

All neutral chrome colors must be zero-chroma: oklch(X 0 0). Only --primary has a hue (deep navy: oklch(0.35 0.05 260)).

No "entities" in user-facing copy

In user-facing UI, use "records", "data", "data types", or "items" instead of "entities". The word "entity" is fine in admin pages, code comments, variable names, and API routes.

Use cn() for conditional classes

Always use the cn() utility from lib/utils.ts for conditional class merging:

import { cn } from "@/lib/utils";

<div className={cn("p-4", isActive && "bg-primary text-primary-foreground")} />

Entity types are DB-driven

Never hardcode entity type slugs (like "opportunity" or "company") in platform code (features/ except features/custom/). Entity types are defined in the database. If platform behavior depends on a specific entity type, make it configurable via agent config or tenant settings.

Zod validation on all API routes

Every POST, PUT, and PATCH handler must validate input with a Zod schema at the route boundary:

import { z } from "zod";
import { apiErrorResponse } from "@/lib/api-utils";

const updateSchema = z.object({
  title: z.string().min(1),
  content: z.record(z.unknown()).optional(),
});

export async function PATCH(request: Request) {
  const body = await request.json();
  const parsed = updateSchema.safeParse(body);
  if (!parsed.success) {
    return apiErrorResponse("Invalid input", 400);
  }
  // ... use parsed.data
}

Component guidelines

200-line limit

Components must stay under 200 lines. If a component approaches that limit, extract sub-components into the same directory:

features/entities/components/
  entity-detail/
    entity-detail.tsx          # Main component (~150 lines)
    entity-header.tsx          # Extracted header
    entity-sidebar.tsx         # Extracted sidebar
    entity-field-list.tsx      # Extracted field list

Accessibility

  • Every icon-only button must have an aria-label
  • Custom interactive elements need tabIndex and onKeyDown handlers
  • Use semantic HTML where possible (even with shadcn/ui wrappers)

Card padding convention

The Card component uses py-4 gap-3 (vertical) + px-4 on children (horizontal) for uniform padding. Block components should not add manual padding overrides -- use Card defaults.

Auth patterns

Always use the auth adapter

Import auth functions from @/features/tenant/auth -- never call Supabase auth directly:

import { getUserId, requireAuth, requireAdmin, hasPermission, requirePermission } from "@/features/tenant/auth";

// API route: require authentication
export async function GET() {
  const context = await requireAuth(); // throws 401 if not authenticated
  // context.userId, context.tenantId, context.role available
}

// API route: require admin
export async function DELETE() {
  const context = await requireAdmin(); // throws 403 if not admin
}

// API route: check specific permission
export async function PATCH() {
  const allowed = await hasPermission("entities.team.update");
  if (!allowed) return apiErrorResponse("Forbidden", 403);
}

Supabase client selection

  • Authenticated client (createClient()) -- for user-scoped reads/writes. RLS applies.
  • Admin client (createAdminClient()) -- only for cross-user/system operations: tenant management, user provisioning, background jobs, extraction.
// Correct: authenticated client for user operations
import { createClient } from "@/lib/supabase/server";
const supabase = await createClient();

// Correct: admin client for system operations
import { createAdminClient } from "@/lib/supabase/server";
const supabase = createAdminClient();

Tenant scoping

All database operations must be tenant-scoped:

import { getTenantContext } from "@/features/tenant/context";
import { DEFAULT_TENANT_ID } from "@/features/tenant/constants";

const { tenantId } = await getTenantContext();

// Always filter by tenant_id
const { data } = await supabase
  .from("entities")
  .select("*")
  .eq("tenant_id", tenantId);

Never hardcode the default tenant UUID. Use DEFAULT_TENANT_ID from features/tenant/constants.ts.

Testing

Co-located tests

Every new file with exported logic must have a co-located .test.ts file:

features/entities/lib/
  scoring.ts
  scoring.test.ts
features/tools/
  registry.ts
  registry.test.ts

What to test

File typeWhat to test
Pure functionsInput/output, edge cases, error conditions
Zod schemasValid inputs pass, invalid inputs fail, optional fields
Constants/enumsAll expected values present, cross-referencing
Server actionsMock Supabase, verify query construction, error handling
Custom toolsSchema validation + execute function (use captureTool() pattern)
Bridge/transform functionsInput shape to output shape, filtering logic, empty inputs

Running tests

pnpm test        # Run Vitest unit + integration tests
pnpm typecheck   # TypeScript type checking
pnpm e2e         # Playwright browser tests
pnpm lint        # ESLint
pnpm build       # Full production build

Test patterns

import { describe, it, expect, vi } from "vitest";

describe("myFunction", () => {
  it("should handle normal input", () => {
    expect(myFunction("hello")).toBe("HELLO");
  });

  it("should handle edge cases", () => {
    expect(myFunction("")).toBe("");
    expect(myFunction(null as unknown as string)).toBeNull();
  });
});

Use as unknown as Type instead of as any when mocking in tests.

Regression assets

Every escaped bug must leave behind a regression asset at the appropriate layer:

  • Unit test -- for logic bugs in pure functions
  • Integration test -- for bugs in server actions or API routes
  • E2E test -- for bugs in user flows
  • Visual test -- for layout or rendering bugs
  • Failure-mode test -- for error handling bugs

File organization

Feature modules

Each feature module is self-contained with a consistent internal structure:

features/entities/
  types.ts               # Types, Zod schemas, constants
  server/
    actions.ts           # Server actions (DB operations)
  components/
    entity-list.tsx      # UI components
    entity-detail.tsx
  lib/
    scoring.ts           # Pure functions
    scoring.test.ts
  hooks/
    use-entities.ts      # Client-side hooks
  extraction/
    types.ts             # Extraction-specific types
    extract-field.ts     # Core extraction logic
    run-extraction.ts    # Orchestration

App routes

The app/ directory contains thin route handlers. Business logic belongs in feature modules:

// app/(app)/opportunity/page.tsx -- THIN
import { EntityListPage } from "@/features/entities/components/entity-list-page";

export default function OpportunityPage() {
  return <EntityListPage />;
}

Import boundaries

  • Platform code (features/ except features/custom/) must never import from features/custom/
  • Custom code (features/custom/) can import from platform features freely
  • App routes import from features, never the reverse
  • Components/ui are pure primitives with no feature imports

Shared utilities

Use canonical shared utilities instead of duplicating logic:

UtilityLocationPurpose
cn()lib/utils.tsConditional class merging
slugify()lib/utils.tsString to URL-safe slug
humanize()lib/utils.tsSlug to human-readable text
apiErrorResponse()lib/api-utils.tsConsistent API error responses
CHART_COLORSlib/chart-colors.tsShared chart color palette
getUserId()features/tenant/auth.tsCurrent user ID (zero network calls)
requireAuth()features/tenant/auth.tsAuth check + tenant context
getActiveTenantId()features/tenant/context.tsActive tenant ID
DEFAULT_TENANT_IDfeatures/tenant/constants.tsDefault tenant UUID
ROLE_IDSfeatures/tenant/roles.tsRole UUID constants (including guest)
parseAgentConfig()features/agents/types.tsSafe parse of agent config JSONB
TOOL_GROUPSfeatures/agents/types.tsTool group constants
formatDisplayValue()features/tools/lib/format.tsFormat values for display
formatFileSize()features/documents/lib/format.tsHuman-readable file sizes
recordAnalyticsEvent()features/analytics/record.tsFire-and-forget event tracking
EXTRACTION_SOURCESfeatures/entities/types.tsExtraction source constants

Quality rules summary

These are non-negotiable across the codebase:

  1. No explicit any -- use unknown, generics, or proper types. In tests, use as unknown as Type.
  2. No hardcoded colors -- CSS variables only. Zero-chroma neutrals (oklch(X 0 0)), navy primary.
  3. Components under 200 lines -- extract sub-components when approaching the limit.
  4. Co-located tests -- every file with exported logic gets a .test.ts file.
  5. Zod at the boundary -- every API route validates input with Zod.
  6. Auth via adapter -- never import Supabase directly for auth checks.
  7. Tenant scoping -- every DB query filtered by tenant_id.
  8. Entity writes log activity -- create an activity record on create/update/delete.
  9. No "entities" in frontend copy -- use "records", "data", "data types", "items".
  10. Entity types are DB-driven -- never hardcode slugs in platform code.
  11. AI SDK v6 only -- streamText, convertToModelMessages, DefaultChatTransport.
  12. Run pnpm db:types after migrations -- keep TypeScript types in sync with the schema.

Pre-push checklist

Before pushing any change:

pnpm lint        # Must pass
pnpm typecheck   # Must pass
pnpm test        # Must pass
pnpm build       # Must pass

For UI changes, visual verification is also required at desktop (1280x720) and mobile (375x667) viewports. No force-pushing allowed.

Adding a custom tool

The most common extension point. Four steps:

  1. Create the definition at features/custom/tools/[name]/definition.ts:
import { registerTool } from "@/features/tools/registry";
import { z } from "zod";

const inputSchema = z.object({
  revenue: z.number().describe("Annual revenue in dollars"),
  costs: z.number().describe("Implementation costs"),
});

registerTool({
  slug: "my-calculator",
  name: "My Calculator",
  description: "Calculates something useful",
  category: "analysis",
  inputSchema,
  execute: async (input) => {
    const roi = (input.revenue - input.costs) / input.costs;
    return { roi, paybackMonths: Math.ceil(12 / roi) };
  },
});
  1. Create tests at features/custom/tools/[name]/definition.test.ts using mockToolRegistry() and captureTool().

  2. Register the import in features/custom/tools/index.ts.

  3. (Optional) Add custom UI -- create input-form.tsx and/or output-display.tsx, register via registerToolUI() in features/custom/tools/ui.ts. Without custom UI, the generic form and output renderers handle everything automatically from the Zod schema.

The tool automatically appears in the /tools library, gets its own page at /tools/[slug], and is available to all agents in chat.

On this page