# API contract v1 (Phase 0 — frozen)

**Status:** Frozen for implementation. Changes require a new `contractVersion` bump and an IMPLEMENTATION_PLAN note.

| Field | Value |
|-------|-------|
| `contractVersion` | `1.0.0` |
| Engine math space | OKLCH (culori) |
| Canonical color storage | sRGB `#RRGGBB` (lowercase preferred) |
| Related | [IMPLEMENTATION_PLAN.md](./IMPLEMENTATION_PLAN.md), [Brand Color Suggestion API for AI Agents.md](./Brand%20Color%20Suggestion%20API%20for%20AI%20Agents.md) |
| Machine constants | [api-constants.v1.json](./api-constants.v1.json) |

This file is the **source of truth** for types and mode policy. `IMPLEMENTATION_PLAN.md` §4 should mirror or link here — do not fork definitions.

---

## 1. Canonical types

```ts
/** Contract / package version stamped on every BrandSystem */
type ContractVersion = '1.0.0';

type ColorMode = 'solid' | 'neon' | 'gradient' | 'hybrid';

type ThemeId = 'light' | 'dark' | 'midnight';

type ContrastTarget = 'AA' | 'AAA' | 'APCA';

type BriefSource = 'seed' | 'presets' | 'website' | 'image';

type BrandBrief = {
  source: BriefSource;
  /** Primary brand seed — hex. Required for seed path; derived for website/image. */
  seed?: string;
  /** Dominant / scraped colors (website | image). */
  extracted?: string[];
  presetId?: string;
  industry?: string;
  keywords?: string[];
  theme?: ThemeId;
  target?: ContrastTarget;       // default 'AA'
  preferMode?: ColorMode;        // optional override of classifier
  /** Force neon despite industry gate (records a warning). */
  forceNeon?: boolean;
  /** Ranked secondary combinations to return (default 3, max 8). */
  variantCount?: number;
  /**
   * Lock seed hex onto this brand scale step (50|100|…|950). Default 500.
   * Additive (v1.0.0-compatible); stamped on `provenance.params.pinStep`.
   * Set `pinSeed: false` to disable pinning.
   */
  pinStep?: number;
  /** When false, do not pin the seed to any stop (overrides pinStep). */
  pinSeed?: boolean;
  /**
   * Optional hex overrides for named role ramps.
   * `error` maps to danger. Missing keys derive from harmony / fixed status hues.
   */
  roles?: {
    secondary?: string;
    tertiary?: string;
    success?: string;
    info?: string;
    warning?: string;
    error?: string;
    danger?: string;
  };
  /** When false, omit status ramps (default true). */
  includeStatus?: boolean;
};

type Stop = {
  step: number;                  // e.g. 50 … 950
  /** Canonical storage — always sRGB hex */
  hex: string;
  rgb?: string;
  hsl?: string;
  oklch?: string;
  lab?: string;                  // optional
  p3?: string;                   // optional display-p3
};

type HarmonyStrategy =
  | 'auto'
  | 'analogous'
  | 'complement'
  | 'split'
  | 'triad'
  | 'tetrad'
  | 'mono';

/** How a single color value is written (industry notations). */
type ColorNotation =
  | 'hex'
  | 'rgb'
  | 'hsl'
  | 'oklch'
  | 'lab'    // optional v1
  | 'p3';   // optional v1

/**
 * How the whole system is packaged.
 * Notation is chosen independently of package.
 */
type PackageFormat =
  | 'css'
  | 'tailwind'
  | 'tokens.json'
  | 'agent.json'
  | 'harmony.json'
  | 'scale.json'
  | 'scss'     // optional v1.1
  | 'figma';  // later

/** @deprecated Prefer PackageFormat */
type ExportFormat = PackageFormat;

type GradientStyle = 'soft' | 'vivid' | 'mesh';

type GradientSpec = {
  id?: string;                   // e.g. 'hero'
  type: 'linear' | 'radial' | 'mesh';
  angle?: number;                // degrees; linear default 135
  stops: { color: string; pos: number }[];  // pos 0..1
  css: string;
  tailwind?: string;
  style: GradientStyle;
  /** Required when gradient is used behind text */
  scrim?: ScrimSpec;
};

type ScrimSpec = {
  strategy: 'bottom' | 'full' | 'center' | 'none';
  color: string;                 // usually near-black / near-white
  opacity: number;               // 0..1
  /** Worst-case sampled WCAG ratio after scrim (body text size) */
  sampledRatio?: number;
  passesTarget?: boolean;
};

type NeonBlock = {
  accent: string;
  hover: string;
  policy: '60-30-10';
  /** Max UI area share for neon accent (always 0.10 in v1) */
  accentShareMax: 0.10;
  base: {
    bg: string;                  // dominant 60%
    surface: string;             // structure 30%
    text: string;                // body on base (WCAG)
  };
  /** OKLCH of accent before clamp — for audit / provenance */
  accentOklch?: { l: number; c: number; h: number };
  inGamut: boolean;
};

type ThemeTokens = {
  surface: string;
  surface2: string;
  border: string;
  text: string;
  muted: string;
  primary: string;
  primaryHover: string;
  primarySoft: string;
  accent: string;
  header: string;
  success: string;
  danger: string;
  onPrimary: string;
  [key: string]: string;
};

type AuditPair = {
  fg: string;
  bg: string;
  role: string;
  ratio: number;
  passAA: boolean;
  passAAA: boolean;
  /** Present when neon CTA audited with APCA (Phase 4) */
  apcaLc?: number;
};

type AuditReport = {
  target: ContrastTarget;
  pairs: AuditPair[];
  score: number;                 // 0..100 heuristic
  failures: number;
};

type Provenance = {
  contractVersion: ContractVersion;
  algorithms: string[];          // e.g. ['oklch-scale','harmony-triad','neon-box-v1']
  params: Record<string, unknown>;
};

type BrandSystem = {
  version: ContractVersion;
  mode: ColorMode;
  seed: string;
  brandScale: Stop[];
  neutrals: Stop[];
  /** Harmony-derived (or roles.secondary override) full ramp */
  secondaryScale?: Stop[];
  /** Harmony-derived (or roles.tertiary override) full ramp */
  tertiaryScale?: Stop[];
  status: {
    success: Stop[];
    warning?: Stop[];
    danger: Stop[];
    info?: Stop[];
  };
  harmony: {
    strategy: HarmonyStrategy;
    secondaries: number;
    colors: {
      role: string;
      hex: string;
      rgb?: string;
      hsl?: string;
      oklch?: string;
    }[];
  };
  themes: Record<ThemeId, ThemeTokens>;
  accents?: { role: string; hex: string; hsl?: string; oklch?: string }[];
  neon?: NeonBlock;
  gradients?: GradientSpec[];
  audit: AuditReport;
  warnings: string[];
  /** Ranked alternatives when suggestBrand / suggestFromSeed used variantCount > 1 */
  variants?: BrandSystem[];
  /**
   * Ranked color-theory recipes (same order as variants after score sort).
   * Prefer this for agents that only need strategy + ΔE + scores.
   */
  combinations?: ColorCombination[];
  provenance: Provenance;
};

/** Thin combination recipe (also returned by suggestCombinations). */
type ColorCombination = {
  strategy: HarmonyStrategy;
  /** Mean ΔE from primary to other harmony colors */
  separation: number;
  auditScore: number;
  rankScore: number;
  colors: { role: string; hex: string }[];
};

type ExportOptions = {
  format: PackageFormat;
  notation?: ColorNotation;      // default: 'hex' (tailwind); prefer 'oklch' for modern css
  notations?: ColorNotation[];   // agent.json / tokens.json multi-embed
  theme?: ThemeId;
  include?: Array<
    'scale' | 'harmony' | 'themes' | 'neon' | 'gradients' | 'audit'
  >;
};
```

---

## 2. Frozen v1 — Neon OKLCH box

Neon occupies a narrow high-chroma band. Generation **must** keep accents inside this box; if gamut clamp moves a color outside, reject or pull back (do not ship muddy “almost neon”).

### 2.1 Bounding box (OKLCH)

| Param | Min | Max | Notes |
|-------|-----|-----|-------|
| Lightness `L` | **0.68** | **0.92** | Primer HSL lightness ~50–70% maps here for vivid neons |
| Chroma `C` | **0.14** | **0.32** | Below 0.14 → not neon; above ~0.32 rarely in sRGB |
| Hue `h` | `0` | `360` | Unrestricted; family chosen by seed / preset |

**Acceptance after sRGB clamp:**

- Re-convert clamped hex → OKLCH.
- Fail if `C < 0.12` **or** `L` outside `[0.65, 0.94]` (slightly wider tolerance post-clamp).
- Prefer exporting `oklch` + `hex`; add `p3` only when `inGamut === true` for display-p3 and C would exceed sRGB.

### 2.2 Reference anchors (must remain valid under the box)

| Name | Hex | Approx OKLCH |
|------|-----|----------------|
| Neon Cyan | `#00CAFF` | L≈0.78 C≈0.15 h≈225 |
| Volt Green | `#2CFF05` | L≈0.87 C≈0.29 h≈142 |
| Neon Magenta | `#FF2BD6` | L≈0.69 C≈0.28 h≈338 |

### 2.3 Hover / family

- One neon **family** per system (same hue ±8°).
- Hover = seed OKLCH with `L` nudged ±0.04 (toward mid for CTAs) and `C` nudged ±0.02, still inside the box.

### 2.4 Base surfaces (never neon fills)

Allowed dominant bases: deep midnight, dark gray, or crisp white.

| Role | Allowed examples (v1 defaults) |
|------|--------------------------------|
| `base.bg` | `#05060A`, `#0B0D12`, `#12141A`, `#FFFFFF` |
| `base.surface` | muted slate / charcoal near neutral scale 800–900 or 100–200 |
| `base.text` | WCAG AA vs `base.bg` (body) — **not** neon |

### 2.5 Industry gate

| Action | Industries / keywords (lowercase match) |
|--------|----------------------------------------|
| **Block** neon (unless `forceNeon`) | `law`, `legal`, `bank`, `banking`, `insurance`, `government`, `healthcare`, `medical`, `finance` (conservative corp) |
| **Favor** neon | `crypto`, `web3`, `gaming`, `esports`, `cyberpunk`, `synthwave`, `ai`, `nightlife`, `fitness`, `hyperpop`, `beverage` |

Blocked → classifier returns `solid` (or `hybrid` without neon) + warning. `forceNeon: true` → allow + warning `"neon forced for blocked industry"`.

---

## 3. Frozen v1 — 60-30-10 policy

Encoded on every `BrandSystem.neon` and enforced in audit:

| Role | Share | Token mapping | Allowed content |
|------|-------|---------------|-----------------|
| Dominant base | **60%** | `neon.base.bg` (+ page chrome) | Midnight / dark gray / white only |
| Structure | **30%** | `neon.base.surface`, borders, muted panels | Muted mid-tones from neutrals |
| Neon accent | **≤10%** | `neon.accent`, `neon.hover` | CTAs, logo mark, active/hover only |

**Hard rules:**

- `accentShareMax` is always `0.10` in v1.
- Neon must **not** be used as page background, body text, or large cards.
- Body copy on surfaces uses WCAG relative luminance.
- Neon-on-dark CTAs: prefer **APCA** when available (Phase 4); until then, record WCAG ratio + warning `"apca_pending"` rather than auto-rejecting high-chroma accents solely on WCAG.

---

## 4. Frozen v1 — Gradient + scrim

### 4.1 Generation

| Rule | Value |
|------|-------|
| Interpolation space | OKLCH lerp (existing `smoothGradient`) |
| Styles | `soft` (low ΔC), `vivid` (higher ΔC / hue span), `mesh` (multi-stop, soft) |
| Default linear angle | `135` |
| Default stop count | `5` (soft/vivid); mesh ≥ `6` |
| Usage | **Hero / marketing only**; solids for body UI |

### 4.2 Scrim (text on gradient)

When any text is declared on a gradient, attach `GradientSpec.scrim`:

| Strategy | When | Defaults |
|----------|------|----------|
| `bottom` | Default for hero headlines | color `#000000`, opacity **0.45** (dark text → use `#FFFFFF` @ 0.50) |
| `full` | Dense copy blocks | opacity **0.55** |
| `center` | Badge / centered lockup | opacity **0.40** |
| `none` | Decorative only — **no text** | `opacity: 0`; audit fails if text roles present |

**Pass criteria (v1):**

1. Sample ≥ **9** points on a 3×3 grid over the gradient (+ scrim composite).
2. For each sample, contrast of chosen text color vs composite ≥ target (**4.5** AA / **7** AAA for body; **3.0** AA large text if role is `display`).
3. Worst sample stored as `scrim.sampledRatio`; `passesTarget` must be true to ship text-on-gradient without a blocking warning.

If sampling fails: auto-increase opacity in steps of `0.05` up to `0.75`, then fall back to solid `surface` + warning.

### 4.3 Hybrid

`mode: 'hybrid'` = full solid system + optional `gradients[]` (e.g. `id: 'hero'`) + optional `neon` accent block. Solids remain the source of body UI tokens.

---

## 5. Defaults & limits (v1)

| Key | Value |
|-----|-------|
| `target` | `'AA'` |
| `theme` | `'light'` (neon presets may default `'midnight'`) |
| `variantCount` | `3` (clamp 1..8) |
| Brand scale steps | `50,100,200,300,400,500,600,700,800,900,950` |
| Seed pin | step **500** |
| Default harmony | `'auto'` |
| Default notations in `agent.json` | `['hex','hsl','oklch','rgb']` |
| Core network | **None** — URL/image extract is browser/host only |

---

## 6. Error codes (typed)

| Code | Meaning |
|------|---------|
| `INVALID_HEX` | Seed / stop not parseable |
| `EMPTY_EXTRACT` | Website/image produced no colors |
| `NEON_OUT_OF_BOX` | Accent failed neon box / gamut |
| `NEON_INDUSTRY_BLOCKED` | Neon blocked without `forceNeon` |
| `GRADIENT_TEXT_FAIL` | Scrim could not meet contrast |
| `PRESET_NOT_FOUND` | Unknown `presetId` |
| `VARIANT_COUNT` | `variantCount` out of range |

---

## 7. Intent API (names frozen; bodies in later phases)

```ts
suggestBrand(brief: BrandBrief): BrandSystem
suggestFromSeed(seed: string, opts?: Partial<BrandBrief>): BrandSystem
suggestFromPreset(presetId: string, opts?: Partial<BrandBrief>): BrandSystem
suggestFromExtractedColors(colors: string[], opts?: Partial<BrandBrief>): BrandSystem

buildBrandScale(seed: string, opts?: { pinStep?: number; pinSeed?: boolean; hue?: number; maxChroma?: number; chromaScale?: number }): Stop[]
buildNeutrals(seed: string, tint?: number): Stop[]
buildStatusScales(seed: string): BrandSystem['status']
buildRoleScales(seed: string, roles?: object, opts?: object): {
  secondaryScale: Stop[]; tertiaryScale: Stop[]; status?: BrandSystem['status'];
  secondarySeed: string; tertiarySeed: string;
}

/** Brand stops (rows) × neutrals + theme surface/text/primary (cols). */
buildContrastMatrix(
  input: BrandSystem | { brandScale: Stop[]; neutrals: Stop[]; status?: object; themes?: object },
  opts?: { includeStatus?: boolean; includeApca?: boolean; target?: ContrastTarget; theme?: ThemeId }
): {
  rows: { id: string; hex: string }[];
  cols: { id: string; hex: string }[];
  cells: {
    fgId: string; bgId: string; fg: string; bg: string;
    wcag: number; grade: string; apcaLc?: number;
  }[];
  target: ContrastTarget;
}

suggestHarmony(seed: string, count: number, strategy: HarmonyStrategy, shuffleSeed?: number)
suggestHarmonyVariants(seed: string, opts?: object)
/** Ranked combinations alias — same recipes as BrandSystem.combinations */
suggestCombinations(
  seed: string,
  opts?: {
    count?: number;
    variantCount?: number;
    strategies?: HarmonyStrategy[];
    strategy?: HarmonyStrategy;
    industry?: string;
    secondaries?: number;
    theme?: ThemeId;
    target?: ContrastTarget;
  }
): ColorCombination[]
rankedHarmonyStrategies(industry?: string, strategies?: HarmonyStrategy[]): HarmonyStrategy[]
themeFromHarmony(colors: string[], mode: ThemeId): ThemeTokens

formatColor(hex: string, notation: ColorNotation): string
enrichStop(stop: Stop, notations?: ColorNotation[]): Stop
exportDesignTokens(system: BrandSystem, options: ExportOptions): string | object

auditPalette(system: BrandSystem, opts?: { target?: ContrastTarget }): AuditReport
classifyMode(input: { industry?: string; keywords?: string[]; preferMode?: ColorMode }): ColorMode
```

---

## Document history

- **2026-09-26 — Additive (still `1.0.0`):** `BrandSystem.combinations` + `suggestCombinations`; industry → harmony strategy weights (`catalogue/harmony-weights.v1.json`); OASF discovery record in `docs/oasf/`.
- **2026-09-26 — Additive (still `1.0.0`):** `patchStop` / `rescaleFromStop` / `applyStopPatch`; `userEdited` provenance flag.
- **2026-09-26 — Additive (still `1.0.0`):** `ContrastTarget` includes `APCA`; `APCA_TARGETS` thresholds; `auditTheme` / matrix grade by |Lc| when target is APCA; `fixLightnessApca`.
- **2026-09-26 — Additive (still `1.0.0`):** `BrandBrief.roles` / `includeStatus`; `BrandSystem.secondaryScale` / `tertiaryScale`; `buildRoleScales`; CSS/Tailwind/tokens emit role + status ramps.
- **2026-09-25 — Additive (still `1.0.0`):** optional `BrandBrief.pinStep` / `pinSeed`; `buildContrastMatrix` helper (not stamped on BrandSystem by default).
- **2026-09-25 — Phase 0:** Initial freeze of types, neon OKLCH box, 60-30-10, gradient scrim, defaults, error codes.
