sailop
blogscanpricing
← Back to blog
February 16, 20268 min read

Procedural Design Systems: How Sailop Generates Infinite Palettes

Sailop uses seed-based procedural generation to create unique design systems. Here's how the HSL math, WCAG contrast, and reproducibility work.

Every Sailop design system is unique. Not hand-picked-from-a-list unique. Mathematically unique. Generated procedurally from a seed, with constraints that guarantee usability, accessibility, and visual distinction from AI defaults. Here's how it works under the hood.

Why Procedural Generation?

The traditional approach to design systems is curation. A designer picks colors, fonts, spacing values. This works beautifully but doesn't scale. You can't hand-curate a design system for every new project, especially when the goal is to break away from AI defaults.

Sailop takes a different approach: procedural generation with constraints. This is the technical foundation behind the complete guide to anti-AI design. Given a seed string, it produces a complete design system that is:

  • Unique: Different seeds produce visually different systems
  • Reproducible: Same seed always produces the same system
  • Accessible: WCAG contrast ratios are guaranteed
  • Non-AI: Every generated value falls outside the AI default ranges

This is the same principle behind procedural terrain generation in games: deterministic randomness within constraints.

The Seed System

Every design system starts with a seed -- any string:

sailop generate --seed "my-project-2026"
sailop generate --seed "client-acme-rebrand"
sailop generate --seed "hackathon-demo"

The seed is hashed into a deterministic random number generator (using a variant of SFC32). This RNG feeds every decision in the generation pipeline. Same seed, same sequence of "random" numbers, same design system.

// Simplified seed-to-RNG pipeline
function seedToRng(seed: string): () => number {
  let hash = cyrb128(seed);
  return sfc32(hash[0], hash[1], hash[2], hash[3]);
}

const rng = seedToRng("my-project-2026");
rng(); // 0.7234... (always the same for this seed)
rng(); // 0.1891... (always the same)
rng(); // 0.5523... (always the same)

Color Generation: HSL With Exclusion Zones

Colors are generated in HSL (Hue, Saturation, Lightness) space, never RGB. HSL makes it possible to reason about color relationships and enforce constraints:

Step 1: Primary Hue Selection

The RNG generates a hue between 0 and 360, but with an exclusion zone:

function generatePrimaryHue(rng: () => number): number {
  // Exclude hue 200-290 (blue/indigo/violet AI band)
  const AI_BAND_START = 200;
  const AI_BAND_END = 290;
  const AI_BAND_SIZE = AI_BAND_END - AI_BAND_START; // 90

  // Generate in the allowed range (0-270 mapped to 0-360 minus exclusion)
  const allowed = 360 - AI_BAND_SIZE; // 270 degrees available
  const raw = Math.floor(rng() * allowed);

  // Map back to full hue circle, skipping the AI band
  return raw < AI_BAND_START ? raw : raw + AI_BAND_SIZE;
}

This guarantees the primary hue is never in the blue-indigo-violet range that AI defaults to. For a full explanation of why AI converges on blue, see why every AI-generated website looks the same.

Step 2: Saturation and Lightness

With the hue chosen, saturation and lightness are generated within usable ranges:

function generateAccentColor(hue: number, rng: () => number) {
  const saturation = 45 + rng() * 35;  // 45-80% (vivid but not neon)
  const lightness = 35 + rng() * 20;   // 35-55% (readable on light bg)
  return { h: hue, s: saturation, l: lightness };
}

Step 3: Background Hue-Shifting

The background is never pure white (#fff). It's always tinted toward the primary hue:

function generateBackground(primaryHue: number, rng: () => number) {
  const bgLightness = 93 + rng() * 4;    // 93-97% (very light but not white)
  const bgSaturation = 8 + rng() * 12;   // 8-20% (subtle tint)
  return { h: primaryHue, s: bgSaturation, l: bgLightness };
}
// Seed "my-project-2026" might produce: HSL(28, 12%, 95%) = #f5f0eb

Step 4: Complementary Secondary

The secondary color uses color theory. Sailop generates either a complementary (180 degrees opposite) or split-complementary (150-210 degrees) hue:

function generateSecondary(primaryHue: number, rng: () => number) {
  const strategy = rng() > 0.5 ? 'complementary' : 'split';
  const offset = strategy === 'complementary'
    ? 180
    : 150 + rng() * 60; // 150-210 degrees
  const secondaryHue = (primaryHue + offset) % 360;
  return generateAccentColor(secondaryHue, rng);
}

Step 5: Neutral Palette

Neutrals (text, borders, muted elements) are generated from the primary hue with decreasing saturation:

function generateNeutrals(primaryHue: number) {
  return {
    fg:       { h: primaryHue, s: 15, l: 10 },   // near-black, hue-tinted
    fgBody:   { h: primaryHue, s: 12, l: 28 },   // body text
    fgMuted:  { h: primaryHue, s: 10, l: 48 },   // secondary text
    fgFaint:  { h: primaryHue, s: 8,  l: 68 },   // disabled/placeholder
    border:   { h: primaryHue, s: 10, l: 85 },   // default border
    borderStrong: { h: primaryHue, s: 10, l: 78 }, // emphasized border
  };
}

WCAG Contrast Validation

Every generated color pair is checked against WCAG 2.1 contrast requirements:

function validateContrast(fg: HSL, bg: HSL): boolean {
  const ratio = getContrastRatio(hslToRgb(fg), hslToRgb(bg));
  return ratio >= 4.5; // AA standard for normal text
}

function validatePalette(palette: Palette): Palette {
  // If body text fails contrast against background, darken it
  while (!validateContrast(palette.fgBody, palette.bg)) {
    palette.fgBody.l -= 2;
  }
  // If accent fails contrast, adjust saturation and lightness
  while (!validateContrast(palette.accent, palette.bg)) {
    palette.accent.l -= 2;
    palette.accent.s += 1;
  }
  return palette;
}

This loop guarantees that no generated palette produces inaccessible text. The contrast ratio is always at least 4.5:1 for body text and 3:1 for large text.

Typography Generation

Font pairing is also procedural. Sailop maintains a curated list of non-AI-default fonts, categorized by style:

const DISPLAY_FONTS = [
  { name: 'Bitter', category: 'serif', personality: 'warm' },
  { name: 'Playfair Display', category: 'serif', personality: 'elegant' },
  { name: 'Cormorant Garamond', category: 'serif', personality: 'refined' },
  { name: 'Bricolage Grotesque', category: 'sans', personality: 'playful' },
  { name: 'Space Mono', category: 'mono', personality: 'technical' },
  // ... more options
];

const BODY_FONTS = [
  { name: 'Karla', category: 'sans', personality: 'humanist' },
  { name: 'DM Sans', category: 'sans', personality: 'geometric' },
  { name: 'Work Sans', category: 'sans', personality: 'neutral' },
  { name: 'Barlow', category: 'sans', personality: 'clean' },
  // ... more options
];

The RNG selects a display font and a body font, with a preference for cross-category pairing (serif display + sans body). For more on why these pairings matter, see why Inter is killing your brand:

function generateTypography(rng: () => number) {
  const displayIdx = Math.floor(rng() * DISPLAY_FONTS.length);
  const bodyIdx = Math.floor(rng() * BODY_FONTS.length);
  return {
    display: DISPLAY_FONTS[displayIdx],
    body: BODY_FONTS[bodyIdx],
    mono: { name: 'JetBrains Mono' }, // Always JetBrains Mono for code
  };
}

Spacing Generation

Spacing values intentionally break the 4px grid:

function generateSpacing(rng: () => number) {
  // Generate 3 "off-grid" micro values (not multiples of 4)
  const offGrid = [
    2 + Math.floor(rng() * 4),   // 2-5px
    12 + Math.floor(rng() * 6),  // 12-17px (includes 13, 14, 15)
    20 + Math.floor(rng() * 6),  // 20-25px (includes 21, 22, 23)
  ];

  // Generate section paddings (varied, not uniform)
  const heropad = 130 + Math.floor(rng() * 40); // 130-170px
  const sectionA = 80 + Math.floor(rng() * 30); // 80-110px
  const sectionB = 60 + Math.floor(rng() * 20); // 60-80px
  const sectionC = 48 + Math.floor(rng() * 16); // 48-64px

  return { offGrid, heropad, sectionA, sectionB, sectionC };
}

The Full Pipeline

Putting it all together:

sailop generate --seed "acme-corp-2026"

Output:

/* Generated by Sailop — seed: acme-corp-2026 */
:root {
  --f-display: 'Playfair Display', Georgia, serif;
  --f-body: 'DM Sans', system-ui, sans-serif;
  --f-mono: 'JetBrains Mono', monospace;

  --c-bg: hsl(42, 18%, 94%);
  --c-bg-raised: hsl(42, 14%, 97%);
  --c-fg: hsl(42, 20%, 9%);
  --c-fg-body: hsl(42, 14%, 26%);
  --c-accent: hsl(42, 72%, 44%);
  --c-secondary: hsl(222, 48%, 38%);

  --r-btn: 3px;
  --r-card: 6px;
  --r-container: 0px;

  --s-micro: 3px;
  --s-small: 13px;
  --s-medium: 21px;
}

Every value is different from the default. Every value is accessible. Every value is reproducible from the seed.

Infinity, Constrained

There are effectively infinite possible design systems. The constraint space (non-AI hues, WCAG contrast, serif pairing, off-grid spacing) is large enough that seed collisions producing visually similar output are statistically negligible.

This is the core philosophy: you don't need a designer for every project. You need a system that produces designed output every time. Procedural generation with the right constraints achieves this. See a real transformation in action in our Grade F to Grade A case study.

Try it: npm i -g sailop && sailop generate --seed "your-name-here". Every seed tells a different visual story. Find yours at sailop.com.

Try Sailop

Scan your frontend for AI patterns. Generate a unique design system. Ship code that looks intentional.

Free scannpm i -g sailop
Share this article
Share on X
Previous
The Typography Problem: Why Inter Is Killing Your Brand
Next
CI/CD for Design: Catching AI Slop Before It Ships
On this page
Why Procedural Generation?The Seed SystemColor Generation: HSL With Exclusion ZonesStep 1: Primary Hue SelectionStep 2: Saturation and LightnessStep 3: Background Hue-ShiftingStep 4: Complementary SecondaryStep 5: Neutral PaletteWCAG Contrast ValidationTypography GenerationSpacing GenerationThe Full PipelineInfinity, Constrained
Sailop 2026All articles