Procedural Design Systems: How Sailop Generates Infinite Palettes
Sailop hashes a seed string into a deterministic RNG, then generates a full design system in HSL with a hard exclusion zone over the blue-indigo-violet AI band. Same seed, same palette, every value WCAG-checked and off the Tailwind defaults.
Type sailop generate --seed "acme-corp-2026" and you get back --c-accent: hsl(42, 72%, 44%) — a warm amber, not blue-600. Change one character of the seed and the whole palette shifts, but it stays accessible, stays off the AI band, and regenerates identically tomorrow. No color picker, no list of presets. Here's how the generation works under the hood.
Why Procedural Generation?
The traditional approach to design systems is curation. A designer picks colors, fonts, spacing values by hand. This produces great results and does not scale. You cannot hand-curate a system for every new project, and curation is exactly what every AI tool skips — which is why they all converge on the same #3b82f6 and Inter.
Sailop generates with constraints instead. Given a seed string, it produces a complete design system that is:
- Unique: different seeds produce visually distinct systems
- Reproducible: the same seed always produces the same system
- Accessible: WCAG contrast ratios are enforced, not hoped for
- Off the AI defaults: every generated value lands outside the ranges v0, Bolt, and Lovable reach for
It is the same idea as procedural terrain in games: deterministic randomness inside hard constraints. This is the technical foundation behind the complete guide to anti-AI design.
The Seed System
Every 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 RNG (a variant of SFC32). That RNG drives every decision in the pipeline. Same seed, same sequence of "random" numbers, same output — down to the last hex.
// 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)The order matters: the first rng() call picks the hue, the next pair sets saturation and lightness, and so on. Reorder the pipeline and the same seed produces a different palette — so the call order is part of the contract.
Color Generation: HSL With Exclusion Zones
Colors are generated in HSL, never RGB. HSL lets you reason about color relationships — "rotate the hue 180 degrees," "drop saturation 10 points" — and enforce constraints you could never express cleanly in #rrggbb.
Step 1: Primary Hue Selection
The RNG picks a hue between 0 and 360, with a hole cut out of it:
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 (270 degrees), then skip the band
const allowed = 360 - AI_BAND_SIZE; // 270 degrees available
const raw = Math.floor(rng() * allowed);
return raw < AI_BAND_START ? raw : raw + AI_BAND_SIZE;
}That 200-290 window is exactly where Tailwind's blue-500 (217), indigo-500 (239), and violet-500 (258) live. Excluding it is non-negotiable: it is the single loudest tell of a generated site. For why every model gravitates there, see why every AI-generated website looks the same and the blue-purple gradient AI signature.
Step 2: Saturation and Lightness
With the hue chosen, saturation and lightness are pulled from usable ranges:
function generateAccentColor(hue: number, rng: () => number) {
const saturation = 45 + rng() * 35; // 45-80% (vivid, not neon)
const lightness = 35 + rng() * 20; // 35-55% (readable on a light bg)
return { h: hue, s: saturation, l: lightness };
}The 35-55% lightness ceiling is what keeps the accent dark enough to pass contrast as a text or button color. A blue-400 at 60%+ lightness fails on white — and ships anyway in most generated UIs.
Step 3: Background Hue-Shifting
The background is never pure white (#fff). It is always tinted toward the primary hue:
function generateBackground(primaryHue: number, rng: () => number) {
const bgLightness = 93 + rng() * 4; // 93-97% (very light, not white)
const bgSaturation = 8 + rng() * 12; // 8-20% (subtle tint)
return { h: primaryHue, s: bgSaturation, l: bgLightness };
}
// Seed "my-project-2026" → HSL(28, 12%, 95%) = #f5f0ebThat single tint is why a Sailop background feels like paper instead of a screenshot. #ffffff everywhere is its own AI tell — see the zinc-950 dark hero default for the dark-mode equivalent.
Step 4: Complementary Secondary
The secondary color uses color theory. Sailop generates either a complementary (180 degrees) 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);
}Worked through: primary hue 42 (amber), complementary strategy, (42 + 180) % 360 = 222 — a deep blue. The AI band is excluded for the *primary* hue, not the secondary, so blue can still appear as a deliberate accent against an amber primary. That is the difference between blue-by-choice and blue-by-default.
Step 5: Neutral Palette
Neutrals (text, borders, muted elements) descend from the primary hue with falling saturation, so even the greys carry a trace of the brand color:
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
};
}Pure #000 text on #fff — the default of every generated site — never appears.
WCAG Contrast Validation
Every color pair is checked against WCAG 2.1 before it ships:
function validateContrast(fg: HSL, bg: HSL): boolean {
const ratio = getContrastRatio(hslToRgb(fg), hslToRgb(bg));
return ratio >= 4.5; // AA 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 the accent fails, darken and saturate it
while (!validateContrast(palette.accent, palette.bg)) {
palette.accent.l -= 2;
palette.accent.s += 1;
}
return palette;
}The loop guarantees no palette ever ships inaccessible text: at least 4.5:1 for body text, 3:1 for large text. Because validation runs *after* generation, the constraint can correct a roll the RNG got wrong rather than throwing it away.
Typography Generation
Font pairing is procedural too. Sailop keeps a list of fonts that are not the AI defaults — no Inter, no Geist — 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' },
// ...
];
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' },
// ...
];The RNG picks a display font and a body font, weighting cross-category pairings (serif display over sans body). For why the default font is a liability, 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' }, // mono is fixed
};
}Spacing Generation
Spacing values deliberately break the 4px grid that every Tailwind UI snaps to:
function generateSpacing(rng: () => number) {
// Three "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, 15)
20 + Math.floor(rng() * 6), // 20-25px (includes 21, 23)
];
// 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 };
}A 13px or 21px gap reads as hand-tuned because no gap-* utility produces it — gap-3 is 12, gap-4 is 16. Spacing that lands between the rungs of the scale is one of the cheapest ways to break the generated look.
The Full Pipeline
Putting it 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;
}Note the --r-btn: 3px — not the rounded-lg (8px) or rounded-2xl (16px) every generated card defaults to. Amber primary, deep-blue secondary, paper background, serif display, off-grid spacing. Reproducible from six words.
Infinity, Constrained
The constraint space — 270 degrees of allowed hue, validated contrast, cross-category font pairing, off-grid spacing — is large enough that two different seeds producing visually similar systems is statistically negligible. Effectively infinite output, all of it on-spec.
That is the whole argument: you do not need a designer for every project. You need a system that returns *designed* output every time, with the bad regions of the space fenced off. Constraints, not templates — see a full before/after in the Grade F to Grade A case study, and the broader case in the future of AI-assisted design.
Try it: npx sailop install && sailop generate --seed "your-name-here". Every seed tells a different visual story. Find yours at sailop.com.
SHIP CODE THAT LOOKS INTENTIONAL
Scan your frontend for AI patterns. Generate a unique design system. Stop shipping the same blue gradient as everyone else.