AI. Figma and colour scales and ramps. Creating web app prototype and plug in with Figma Make.

Published on

Collage of Figma Make Prompts arranged isometrically

 I decided to use Figma Make as my choice of AI prompt-to-code tool for the next the stage of creating a colour scale. Mostly because I already pay for Figma and didn’t see want to pay Lovable, Replit, v0, or Bolt to mock up a personal concept.

I gave Figma Make the full final specification from Chat GPT to, ‘design and prototype an interactive web app called “Brand Colour Scale Builder Experiment.” The goal is to help designers build balanced, perceptually uniform colour scales for brand palettes using OKLCH colour space with a subtle chroma taper.’

Chat GPT seemed to be  drifting further from my written design prompts and become the outputs were becoming less useful over time. The actual gradients it was calculating were correct and elegant but the interface with its interactions was getting ‘worse’ with each iteration.

My hope was that Figma Make would be ‘better’ as its a design tool, and also with a WYSIWYG interface it would be easier to review and amend.

It was easier to guide the design and layout in Figma Make. But I also added additional functionality around accessibility and contrast so that the user could see whether or not the colours in the scales met ‘AA’ standards.

I asked Figma Make what it thought the best data to display was. It recommended both white and black background contrast checking. The design already featured the lightness value so the final version includes a text summary of the accessibility values underneath. These get displayed within the final saved swatches.

Having created an app that could output the hex values as json for Figma to use as variables. I experimented with some existing plug ins that allowed you to import the values into Figma’s variable menu. Annoyingly most seemed to work on a freemium model so you had to pay for the uploading functionality.

In the end I asked Figma make if it could create a plug in and it did! Which was pretty amazing, and in dev mode you can install the plug in locally and run it just for yourself.

Download the plug in file from the code directory in Figma Make, and then using Figma’s desktop version you can install the plug in via Menu → Plugins → Development → Import plugin from manifest and navigate to the figma-plugin folder which you have downloaded and select manifest.json.

Figma Make created a stand alone file for me to copy.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Brand Colour Scale Builder Experiment</title>
  
  <!-- Tailwind CSS CDN -->
  <script src="https://cdn.tailwindcss.com"></script>
  
  <!-- React and ReactDOM from CDN -->
  <script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
  <script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
  
  <!-- Babel Standalone for JSX -->
  <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
  
  <style>
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
    }
    
    :root {
      --font-size: 16px;
      --background: #ffffff;
      --foreground: oklch(0.145 0 0);
      --card: #ffffff;
      --card-foreground: oklch(0.145 0 0);
      --primary: #030213;
      --primary-foreground: oklch(1 0 0);
      --secondary: oklch(0.95 0.0058 264.53);
      --secondary-foreground: #030213;
      --muted: #ececf0;
      --muted-foreground: #717182;
      --accent: #e9ebef;
      --accent-foreground: #030213;
      --destructive: #d4183d;
      --destructive-foreground: #ffffff;
      --border: rgba(0, 0, 0, 0.1);
      --input: transparent;
      --input-background: #f3f3f5;
      --ring: oklch(0.708 0 0);
      --font-weight-medium: 500;
      --font-weight-normal: 400;
      --radius: 0.625rem;
      --text-2xl: 1.5rem;
      --text-xl: 1.25rem;
      --text-lg: 1.125rem;
      --text-base: 1rem;
      --text-sm: 0.875rem;
      --text-xs: 0.75rem;
    }
    
    body {
      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
      background: #f6f6f6;
      color: var(--foreground);
      font-size: var(--font-size);
    }
    
    h1 {
      font-size: var(--text-2xl);
      font-weight: var(--font-weight-medium);
      line-height: 1.5;
      color: var(--foreground);
    }

    h2 {
      font-size: var(--text-xl);
      font-weight: var(--font-weight-medium);
      line-height: 1.5;
      color: var(--foreground);
    }

    p, label, button, input {
      font-size: var(--text-base);
      font-weight: var(--font-weight-normal);
      line-height: 1.5;
    }

    label {
      font-weight: var(--font-weight-medium);
      font-size: var(--text-base);
    }

    button {
      font-weight: var(--font-weight-medium);
      font-size: var(--text-sm);
    }
    
    .toast-container {
      position: fixed;
      bottom: 20px;
      right: 20px;
      z-index: 1000;
    }
    
    .toast {
      background: white;
      padding: 12px 20px;
      border-radius: 8px;
      box-shadow: 0 4px 12px rgba(0,0,0,0.15);
      margin-top: 8px;
      animation: slideIn 0.3s ease-out;
    }
    
    @keyframes slideIn {
      from {
        transform: translateX(400px);
        opacity: 0;
      }
      to {
        transform: translateX(0);
        opacity: 1;
      }
    }
    
    /* Button styles matching ShadCN */
    .btn {
      display: inline-flex;
      align-items: center;
      justify-content: center;
      white-space: nowrap;
      border-radius: 0.375rem;
      font-size: var(--text-sm);
      font-weight: var(--font-weight-medium);
      transition: all 0.2s;
      cursor: pointer;
      outline: none;
      height: 36px;
      padding: 8px 16px;
      gap: 0.5rem;
    }
    
    .btn:disabled {
      pointer-events: none;
      opacity: 0.5;
      cursor: not-allowed;
    }
    
    .btn-primary {
      background: var(--primary);
      color: var(--primary-foreground);
      border: none;
    }
    
    .btn-primary:hover:not(:disabled) {
      opacity: 0.9;
    }
    
    .btn-secondary {
      background: var(--secondary);
      color: var(--secondary-foreground);
      border: none;
    }
    
    .btn-secondary:hover:not(:disabled) {
      opacity: 0.8;
    }
    
    .btn-outline {
      background: var(--background);
      color: var(--foreground);
      border: 1px solid var(--border);
    }
    
    .btn-outline:hover:not(:disabled) {
      background: var(--accent);
      color: var(--accent-foreground);
    }
    
    .btn-ghost {
      background: transparent;
      color: var(--foreground);
      border: none;
    }
    
    .btn-ghost:hover:not(:disabled) {
      background: var(--accent);
      color: var(--accent-foreground);
    }
    
    .btn-sm {
      height: 32px;
      padding: 4px 12px;
    }
    
    .btn-icon-sm {
      height: 24px;
      width: 24px;
      padding: 0;
    }
    
    .btn-arrow {
      height: 20px;
      width: 24px;
      padding: 0;
      font-size: 12px;
    }
    
    .input {
      width: 100%;
      padding: 8px 12px;
      border-radius: 0.375rem;
      border: 1px solid var(--border);
      background: var(--input-background);
      font-size: var(--text-base);
      transition: all 0.2s;
    }
    
    .input:focus {
      outline: none;
      border-color: var(--ring);
      box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.05);
    }
  </style>
</head>
<body>
  <div id="root"></div>
  
  <script type="text/babel">
    const { useState, useContext, useEffect } = React;
    
    // ===== COLOR CONVERSION LIBRARY (OKLCH) =====
    // Simplified OKLCH conversion functions
    
    function hexToRgb(hex) {
      hex = hex.replace('#', '');
      return {
        r: parseInt(hex.substring(0, 2), 16) / 255,
        g: parseInt(hex.substring(2, 4), 16) / 255,
        b: parseInt(hex.substring(4, 6), 16) / 255
      };
    }
    
    function rgbToHex(r, g, b) {
      const toHex = (n) => {
        const clamped = Math.max(0, Math.min(255, Math.round(n * 255)));
        return clamped.toString(16).padStart(2, '0');
      };
      return '#' + toHex(r) + toHex(g) + toHex(b);
    }
    
    function rgbToLinear(c) {
      return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
    }
    
    function linearToRgb(c) {
      return c <= 0.0031308 ? c * 12.92 : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
    }
    
    function linearRgbToOklab(r, g, b) {
      const l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
      const m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
      const s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
      
      const l_ = Math.cbrt(l);
      const m_ = Math.cbrt(m);
      const s_ = Math.cbrt(s);
      
      return {
        L: 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_,
        a: 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_,
        b: 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_
      };
    }
    
    function oklabToLinearRgb(L, a, b) {
      const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
      const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
      const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
      
      const l = l_ * l_ * l_;
      const m = m_ * m_ * m_;
      const s = s_ * s_ * s_;
      
      return {
        r: +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
        g: -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
        b: -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s
      };
    }
    
    function oklabToOklch(L, a, b) {
      const c = Math.sqrt(a * a + b * b);
      let h = Math.atan2(b, a) * 180 / Math.PI;
      if (h < 0) h += 360;
      
      return { l: L, c: c, h: h };
    }
    
    function oklchToOklab(l, c, h) {
      const hRad = h * Math.PI / 180;
      return {
        L: l,
        a: c * Math.cos(hRad),
        b: c * Math.sin(hRad)
      };
    }
    
    function isValidHex(hex) {
      return /^#[0-9A-F]{6}$/i.test(hex);
    }
    
    function hexToOklch(hex) {
      if (!isValidHex(hex)) return null;
      
      const rgb = hexToRgb(hex);
      
      const lr = rgbToLinear(rgb.r);
      const lg = rgbToLinear(rgb.g);
      const lb = rgbToLinear(rgb.b);
      
      const oklab = linearRgbToOklab(lr, lg, lb);
      const oklch = oklabToOklch(oklab.L, oklab.a, oklab.b);
      
      return {
        mode: 'oklch',
        l: oklch.l,
        c: oklch.c,
        h: oklch.h
      };
    }
    
    function oklchToHex(l, c, h) {
      const oklab = oklchToOklab(l, c, h);
      const linear = oklabToLinearRgb(oklab.L, oklab.a, oklab.b);
      
      const r = linearToRgb(linear.r);
      const g = linearToRgb(linear.g);
      const b = linearToRgb(linear.b);
      
      return rgbToHex(r, g, b).toUpperCase();
    }
    
    // ===== ACCESSIBILITY FUNCTIONS =====
    
    function getRelativeLuminance(hex) {
      const rgb = hexToRgb(hex);
      
      const rsRGB = rgbToLinear(rgb.r);
      const gsRGB = rgbToLinear(rgb.g);
      const bsRGB = rgbToLinear(rgb.b);
      
      return 0.2126 * rsRGB + 0.7152 * gsRGB + 0.0722 * bsRGB;
    }
    
    function getContrastRatio(hex1, hex2) {
      const l1 = getRelativeLuminance(hex1);
      const l2 = getRelativeLuminance(hex2);
      
      const lighter = Math.max(l1, l2);
      const darker = Math.min(l1, l2);
      
      return (lighter + 0.05) / (darker + 0.05);
    }
    
    function meetsAA(ratio, largeText = false) {
      return largeText ? ratio >= 3.0 : ratio >= 4.5;
    }
    
    function getAccessibilityInfo(hex) {
      const contrastWhite = getContrastRatio(hex, '#FFFFFF');
      const contrastBlack = getContrastRatio(hex, '#000000');
      
      return {
        contrastWhite,
        contrastBlack,
        meetsAAWhite: meetsAA(contrastWhite),
        meetsAABlack: meetsAA(contrastBlack)
      };
    }
    
    function getBestTextColor(hex) {
      const contrastWhite = getContrastRatio(hex, '#FFFFFF');
      const contrastBlack = getContrastRatio(hex, '#000000');
      
      return contrastWhite > contrastBlack ? '#FFFFFF' : '#000000';
    }
    
    // ===== END COLOR CONVERSION LIBRARY =====
    
    // Toast system
    const ToastContext = React.createContext();
    
    function ToastProvider({ children }) {
      const [toasts, setToasts] = useState([]);
      
      const addToast = (message, type = 'info') => {
        const id = Date.now();
        setToasts(prev => [...prev, { id, message, type }]);
        setTimeout(() => {
          setToasts(prev => prev.filter(t => t.id !== id));
        }, 3000);
      };
      
      const toast = {
        success: (msg) => addToast(msg, 'success'),
        error: (msg) => addToast(msg, 'error'),
        info: (msg) => addToast(msg, 'info')
      };
      
      return (
        <ToastContext.Provider value={toast}>
          {children}
          <div className="toast-container">
            {toasts.map(t => (
              <div key={t.id} className="toast">
                {t.message}
              </div>
            ))}
          </div>
        </ToastContext.Provider>
      );
    }
    
    function useToast() {
      return React.useContext(ToastContext);
    }
    
    // Color utilities
    const SCALE_POSITIONS = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950];
    
    function scaleToIndex(position) {
      return SCALE_POSITIONS.indexOf(position);
    }
    
    function indexToScale(index) {
      return SCALE_POSITIONS[index];
    }
    
    function generateLightnessSteps(anchorL, anchorIndex) {
      const steps = 11;
      const lightnessValues = new Array(steps);
      const maxL = 0.95;
      const minL = 0.24;
      const totalRange = maxL - minL;
      const stepSize = totalRange / (steps - 1);
      
      for (let i = 0; i < steps; i++) {
        lightnessValues[i] = maxL - (i * stepSize);
      }
      
      const currentAnchorL = lightnessValues[anchorIndex];
      const offset = anchorL - currentAnchorL;
      
      for (let i = 0; i < steps; i++) {
        lightnessValues[i] = Math.max(0.24, Math.min(0.95, lightnessValues[i] + offset));
      }
      
      return lightnessValues;
    }
    
    function applyChromaTaper(baseChroma, index) {
      const steps = 11;
      const middle = (steps - 1) / 2;
      const distanceFromMiddle = Math.abs(index - middle) / middle;
      const taper = 1 - Math.pow(distanceFromMiddle, 1.5) * 0.4;
      return baseChroma * taper;
    }
    
    function isAchromatic(oklch, threshold = 0.001) {
      return oklch.c !== undefined && oklch.c < threshold;
    }
    
    function generateAchromaticScale(anchorHex, anchorPosition, name) {
      const anchorColor = hexToOklch(anchorHex);
      
      if (!anchorColor) return null;
      
      const anchorIndex = scaleToIndex(anchorPosition);
      const lightnessSteps = generateLightnessSteps(anchorColor.l, anchorIndex);
      
      const colors = lightnessSteps.map((l, index) => {
        if (index === anchorIndex) {
          return {
            step: indexToScale(index),
            hex: anchorHex.toUpperCase(),
            oklch: anchorColor
          };
        }
        
        // For greys: keep chroma at 0, hue at 0 (doesn't matter for achromatic)
        const hex = oklchToHex(l, 0, 0);
        
        return {
          step: indexToScale(index),
          hex: hex,
          oklch: { mode: 'oklch', l: l, c: 0, h: 0 }
        };
      });
      
      return {
        name,
        anchorHex,
        anchorPosition,
        colors
      };
    }
    
    function generateColorScale(anchorHex, anchorPosition, name = 'palette') {
      const anchorColor = hexToOklch(anchorHex);
      
      if (!anchorColor) return null;
      
      // Check if the color is achromatic (grey) and route to appropriate generator
      if (isAchromatic(anchorColor)) {
        return generateAchromaticScale(anchorHex, anchorPosition, name);
      }
      
      const anchorIndex = scaleToIndex(anchorPosition);
      const lightnessSteps = generateLightnessSteps(anchorColor.l, anchorIndex);
      
      const colors = lightnessSteps.map((l, index) => {
        if (index === anchorIndex) {
          return {
            step: indexToScale(index),
            hex: anchorHex.toUpperCase(),
            oklch: anchorColor
          };
        }
        
        const c = applyChromaTaper(anchorColor.c, index);
        const h = anchorColor.h;
        
        const hex = oklchToHex(l, c, h);
        
        return {
          step: indexToScale(index),
          hex: hex,
          oklch: { mode: 'oklch', l: l, c: c, h: h }
        };
      });
      
      return {
        name,
        anchorHex,
        anchorPosition,
        colors
      };
    }
    
    function calculateInitialPosition(hex) {
      const color = hexToOklch(hex);
      
      if (!color) return 500;
      
      const lightness = color.l;
      
      const positions = [
        { l: 0.95, pos: 50 },
        { l: 0.90, pos: 100 },
        { l: 0.80, pos: 200 },
        { l: 0.70, pos: 300 },
        { l: 0.60, pos: 400 },
        { l: 0.50, pos: 500 },
        { l: 0.40, pos: 600 },
        { l: 0.33, pos: 700 },
        { l: 0.29, pos: 800 },
        { l: 0.26, pos: 900 },
        { l: 0.24, pos: 950 }
      ];
      
      let closestPos = 500;
      let minDiff = Infinity;
      
      for (const { l, pos } of positions) {
        const diff = Math.abs(lightness - l);
        if (diff < minDiff) {
          minDiff = diff;
          closestPos = pos;
        }
      }
      
      return closestPos;
    }
    
    // Icon components
    function ChevronUpIcon() {
      return (
        <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
          <path d="m18 15-6-6-6 6"/>
        </svg>
      );
    }
    
    function ChevronDownIcon() {
      return (
        <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
          <path d="m6 9 6 6 6-6"/>
        </svg>
      );
    }
    
    function XIcon() {
      return (
        <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
          <path d="M18 6 6 18"/>
          <path d="m6 6 12 12"/>
        </svg>
      );
    }
    
    function CheckIcon() {
      return (
        <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
          <path d="M20 6 9 17l-5-5"/>
        </svg>
      );
    }
    
    // Components
    function InputControls({ 
      onGenerate, 
      onSave, 
      onReset, 
      onExport, 
      onClearSaved,
      hasCurrentScale,
      hasSavedPalettes,
      currentScaleInfo
    }) {
      const [hex, setHex] = useState('#004774');
      const [name, setName] = useState('primary');
      const [error, setError] = useState('');
      const toast = useToast();
      
      const handleGenerate = () => {
        const trimmedHex = hex.trim();
        
        if (!isValidHex(trimmedHex)) {
          setError('Please enter a valid hex color (e.g. #004774)');
          return;
        }
        
        if (!name.trim()) {
          setError('Please enter a color name');
          return;
        }
        
        setError('');
        onGenerate(trimmedHex, name.trim());
      };
      
      const handleNameChange = (value) => {
        const formatted = value.toLowerCase().replace(/\s+/g, '-');
        setName(formatted);
        setError('');
      };
      
      return (
        <div style={{
          background: 'white',
          borderRadius: '0.75rem',
          padding: '1.5rem',
          boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
          border: '1px solid var(--border)'
        }}>
          <div style={{display: 'flex', flexDirection: 'column', gap: '1rem'}}>
            <div style={{display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '1rem'}}>
              <div style={{display: 'flex', flexDirection: 'column', gap: '0.5rem'}}>
                <label htmlFor="hex-input">Anchor Colour (Hex)</label>
                <input
                  id="hex-input"
                  type="text"
                  value={hex}
                  onChange={(e) => { setHex(e.target.value); setError(''); }}
                  placeholder="#004774"
                  className="input"
                  style={{fontFamily: 'monospace'}}
                />
              </div>
              
              <div style={{display: 'flex', flexDirection: 'column', gap: '0.5rem'}}>
                <label htmlFor="name-input">Colour Name</label>
                <input
                  id="name-input"
                  type="text"
                  value={name}
                  onChange={(e) => handleNameChange(e.target.value)}
                  placeholder="primary"
                  className="input"
                />
              </div>
            </div>

            {error && (
              <p style={{fontSize: 'var(--text-sm)', color: 'var(--destructive)'}}>{error}</p>
            )}
            
            {currentScaleInfo && (
              <div style={{
                padding: '0.75rem',
                background: 'var(--accent)',
                borderRadius: '0.375rem',
                display: 'flex',
                justifyContent: 'space-between',
                alignItems: 'center'
              }}>
                <div>
                  <p style={{fontSize: 'var(--text-sm)', color: 'rgba(26, 26, 26, 0.6)'}}>
                    Current Anchor Position
                  </p>
                  <p style={{fontSize: 'var(--text-base)', fontWeight: 'var(--font-weight-medium)'}}>
                    {currentScaleInfo.position} • L: {currentScaleInfo.lightness.toFixed(0)}%
                  </p>
                </div>
              </div>
            )}
            
            <div style={{display: 'flex', gap: '0.5rem', flexWrap: 'wrap'}}>
              <button onClick={handleGenerate} className="btn btn-primary">
                Generate
              </button>
              
              <button 
                onClick={onSave} 
                className="btn btn-secondary"
                disabled={!hasCurrentScale}
              >
                Save Palette
              </button>
              
              <button 
                onClick={onReset} 
                className="btn btn-outline"
                disabled={!hasCurrentScale}
              >
                Reset
              </button>
              
              <button 
                onClick={onExport} 
                className="btn btn-outline"
                disabled={!hasSavedPalettes}
              >
                Export JSON
              </button>
              
              <button 
                onClick={onClearSaved} 
                className="btn btn-outline"
                disabled={!hasSavedPalettes}
              >
                Clear Saved Palettes
              </button>
            </div>
          </div>
        </div>
      );
    }
    
    function ColorSwatch({ color, isAnchor, onMoveUp, onMoveDown, canMoveUp, canMoveDown }) {
      const textColor = getBestTextColor(color.hex);
      const a11y = getAccessibilityInfo(color.hex);
      
      return (
        <div style={{display: 'flex', alignItems: 'center', gap: '0.5rem'}}>
          {isAnchor && (
            <div style={{display: 'flex', flexDirection: 'column', gap: '0.125rem'}}>
              <button
                onClick={onMoveUp}
                disabled={!canMoveUp}
                className="btn btn-outline btn-arrow"
                title="Move anchor up (lighter position)"
              >
                <ChevronUpIcon />
              </button>
              <button
                onClick={onMoveDown}
                disabled={!canMoveDown}
                className="btn btn-outline btn-arrow"
                title="Move anchor down (darker position)"
              >
                <ChevronDownIcon />
              </button>
            </div>
          )}
          
          {!isAnchor && <div style={{width: '24px'}} />}
          
          <div style={{display: 'flex', alignItems: 'center', gap: '0.75rem'}}>
            <div
              style={{
                width: '80px',
                height: '80px',
                borderRadius: '0.5rem',
                background: color.hex,
                display: 'flex',
                alignItems: 'center',
                justifyContent: 'center',
                position: 'relative',
                border: isAnchor ? '3px solid #000000' : '1px solid rgba(0,0,0,0.1)',
                boxShadow: isAnchor ? '0 0 0 1px rgba(255,255,255,0.5) inset' : 'none'
              }}
            >
              <span style={{
                fontSize: 'var(--text-sm)',
                color: textColor,
                textShadow: '0 1px 2px rgba(0,0,0,0.1)'
              }}>
                {color.step}
              </span>
            </div>
            <div style={{display: 'flex', flexDirection: 'column', gap: '0.125rem'}}>
              <code style={{fontSize: 'var(--text-sm)', color: 'rgba(26, 26, 26, 0.8)'}}>
                {color.hex}
              </code>
              <code style={{fontSize: 'var(--text-xs)', color: 'rgba(26, 26, 26, 0.5)'}}>
                L: {(color.oklch.l * 100).toFixed(0)}%
              </code>
              <code style={{fontSize: 'var(--text-xs)', color: 'rgba(26, 26, 26, 0.5)', display: 'flex', alignItems: 'center', gap: '0.25rem'}}>
                <span>AA: on white: {a11y.contrastWhite.toFixed(1)}:1</span>
                {a11y.meetsAAWhite ? (
                  <span style={{color: '#22c55e', display: 'inline-flex', alignItems: 'center'}}>
                    <CheckIcon />
                  </span>
                ) : (
                  <span style={{color: '#ef4444', display: 'inline-flex', alignItems: 'center'}}>
                    <XIcon />
                  </span>
                )}
                <span style={{marginLeft: '0.25rem'}}>on black: {a11y.contrastBlack.toFixed(1)}:1</span>
                {a11y.meetsAABlack ? (
                  <span style={{color: '#22c55e', display: 'inline-flex', alignItems: 'center'}}>
                    <CheckIcon />
                  </span>
                ) : (
                  <span style={{color: '#ef4444', display: 'inline-flex', alignItems: 'center'}}>
                    <XIcon />
                  </span>
                )}
              </code>
            </div>
          </div>
        </div>
      );
    }
    
    function GeneratedScale({ scale, onMoveAnchor }) {
      const anchorIndex = scaleToIndex(scale.anchorPosition);
      const canMoveUp = scale.anchorPosition > 50;
      const canMoveDown = scale.anchorPosition < 950;
      
      return (
        <div style={{
          background: 'white',
          borderRadius: '0.75rem',
          padding: '1.5rem',
          boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
          border: '1px solid var(--border)'
        }}>
          <div style={{marginBottom: '1rem'}}>
            <h2>Generated Scale</h2>
          </div>
          
          <div style={{display: 'flex', flexDirection: 'column', gap: '0.75rem'}}>
            {scale.colors.map((color, index) => (
              <ColorSwatch
                key={color.step}
                color={color}
                isAnchor={index === anchorIndex}
                onMoveUp={() => onMoveAnchor('up')}
                onMoveDown={() => onMoveAnchor('down')}
                canMoveUp={index === anchorIndex && canMoveUp}
                canMoveDown={index === anchorIndex && canMoveDown}
              />
            ))}
          </div>
          
          <div style={{
            marginTop: '1rem',
            paddingTop: '1rem',
            borderTop: '1px solid var(--border)'
          }}>
            <p style={{fontSize: 'var(--text-sm)', color: 'rgba(26, 26, 26, 0.6)'}}>
              Anchor at {scale.anchorPosition} • {scale.name}
            </p>
          </div>
        </div>
      );
    }
    
    function SavedPalettes({ palettes, onRemove }) {
      if (palettes.length === 0) {
        return (
          <div style={{
            background: 'white',
            borderRadius: '0.75rem',
            padding: '1.5rem',
            boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
            border: '1px solid var(--border)'
          }}>
            <h2 style={{marginBottom: '1rem'}}>Saved Palettes</h2>
            <p style={{
              fontSize: 'var(--text-sm)',
              color: 'rgba(26, 26, 26, 0.5)',
              textAlign: 'center',
              padding: '2rem 0'
            }}>
              No saved palettes yet. Generate and save a palette to see it here.
            </p>
          </div>
        );
      }
      
      return (
        <div style={{
          background: 'white',
          borderRadius: '0.75rem',
          padding: '1.5rem',
          boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
          border: '1px solid var(--border)'
        }}>
          <h2 style={{marginBottom: '1rem'}}>Saved Palettes</h2>
          
          <div style={{
            display: 'flex',
            gap: '1.5rem',
            overflowX: 'auto',
            paddingBottom: '0.5rem'
          }}>
            {palettes.map((palette, paletteIndex) => (
              <div key={paletteIndex} style={{flexShrink: 0}}>
                <div style={{display: 'flex', flexDirection: 'column', gap: '0.75rem', marginBottom: '0.75rem'}}>
                  {palette.colors.map((color) => {
                    const textColor = getBestTextColor(color.hex);
                    const isAnchor = color.step === palette.anchorPosition;
                    const a11y = getAccessibilityInfo(color.hex);
                    
                    const bestContrast = Math.max(a11y.contrastWhite, a11y.contrastBlack);
                    const meetsAA = textColor === '#FFFFFF' ? a11y.meetsAAWhite : a11y.meetsAABlack;
                    
                    return (
                      <div key={color.step}>
                        <div
                          style={{
                            width: '80px',
                            height: '80px',
                            borderRadius: '0.5rem',
                            background: color.hex,
                            display: 'flex',
                            flexDirection: 'column',
                            alignItems: 'center',
                            justifyContent: 'center',
                            position: 'relative',
                            gap: '0.125rem',
                            padding: '0 0.25rem',
                            border: isAnchor ? '3px solid #000000' : '1px solid rgba(0,0,0,0.1)',
                            boxShadow: isAnchor ? '0 0 0 1px rgba(255,255,255,0.5) inset' : 'none'
                          }}
                        >
                          <span style={{
                            fontSize: 'var(--text-sm)',
                            color: textColor,
                            textShadow: '0 1px 2px rgba(0,0,0,0.1)'
                          }}>
                            {color.step}
                          </span>
                          <code style={{
                            fontSize: 'var(--text-xs)',
                            color: textColor,
                            textShadow: '0 1px 2px rgba(0,0,0,0.1)'
                          }}>
                            {color.hex}
                          </code>
                          <div style={{
                            fontSize: 'var(--text-xs)',
                            color: textColor,
                            textShadow: '0 1px 2px rgba(0,0,0,0.1)',
                            display: 'flex',
                            alignItems: 'center',
                            gap: '0.25rem'
                          }}>
                            <span>{bestContrast.toFixed(1)}:1</span>
                            {meetsAA ? (
                              <span style={{color: textColor, display: 'inline-flex', alignItems: 'center'}}>
                                <CheckIcon />
                              </span>
                            ) : (
                              <span style={{color: textColor, display: 'inline-flex', alignItems: 'center'}}>
                                <XIcon />
                              </span>
                            )}
                          </div>
                        </div>
                      </div>
                    );
                  })}
                </div>
                
                <div style={{
                  display: 'flex',
                  alignItems: 'center',
                  justifyContent: 'space-between',
                  gap: '0.5rem',
                  marginTop: '1rem',
                  paddingTop: '1rem',
                  borderTop: '1px solid var(--border)'
                }}>
                  <p style={{
                    fontSize: 'var(--text-sm)',
                    color: 'rgba(26, 26, 26, 0.7)',
                    overflow: 'hidden',
                    textOverflow: 'ellipsis',
                    whiteSpace: 'nowrap'
                  }}>
                    {palette.name}
                  </p>
                  <button
                    onClick={() => onRemove(paletteIndex)}
                    className="btn btn-ghost btn-icon-sm"
                    style={{flexShrink: 0}}
                  >
                    <XIcon />
                  </button>
                </div>
              </div>
            ))}
          </div>
        </div>
      );
    }
    
    function ExportPanel({ palettes, onClose }) {
      const toast = useToast();
      
      const generateJSON = () => {
        const output = {};
        
        palettes.forEach((palette) => {
          const colors = {};
          palette.colors.forEach((color) => {
            colors[color.step] = color.hex;
          });
          output[palette.name] = colors;
        });
        
        return JSON.stringify(output, null, 2);
      };
      
      const handleCopy = () => {
        const json = generateJSON();
        navigator.clipboard.writeText(json);
        toast.success('JSON copied to clipboard!');
      };
      
      const handleDownload = () => {
        const json = generateJSON();
        const blob = new Blob([json], { type: 'application/json' });
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url;
        a.download = 'color-scales.json';
        a.click();
        URL.revokeObjectURL(url);
        toast.success('JSON file downloaded!');
      };
      
      return (
        <div style={{
          background: 'white',
          borderRadius: '0.75rem',
          padding: '1.5rem',
          boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
          border: '1px solid var(--border)'
        }}>
          <div style={{
            display: 'flex',
            justifyContent: 'space-between',
            alignItems: 'center',
            marginBottom: '1rem'
          }}>
            <h2>Export JSON for Figma</h2>
            <button onClick={onClose} className="btn btn-ghost btn-icon-sm">
              <XIcon />
            </button>
          </div>
          
          <p style={{
            fontSize: 'var(--text-sm)',
            color: 'rgba(26, 26, 26, 0.6)',
            marginBottom: '1rem'
          }}>
            Export your saved palettes as JSON for use in Figma's variable system.
          </p>
          
          <div style={{
            background: 'var(--input-background)',
            padding: '1rem',
            borderRadius: '0.375rem',
            marginBottom: '1rem',
            maxHeight: '300px',
            overflowY: 'auto'
          }}>
            <pre style={{
              fontSize: 'var(--text-xs)',
              fontFamily: 'monospace',
              margin: 0,
              whiteSpace: 'pre-wrap',
              wordBreak: 'break-all'
            }}>
              {generateJSON()}
            </pre>
          </div>
          
          <div style={{display: 'flex', gap: '0.5rem'}}>
            <button onClick={handleCopy} className="btn btn-primary">
              Copy to Clipboard
            </button>
            <button onClick={handleDownload} className="btn btn-secondary">
              Download JSON
            </button>
          </div>
        </div>
      );
    }
    
    function App() {
      const [currentScale, setCurrentScale] = useState(null);
      const [savedPalettes, setSavedPalettes] = useState([]);
      const [showExport, setShowExport] = useState(false);
      const toast = useToast();
      
      const handleGenerate = (hex, name) => {
        const initialPosition = calculateInitialPosition(hex);
        const scale = generateColorScale(hex, initialPosition, name);
        
        if (!scale) {
          toast.error('Failed to generate color scale. Please check your hex value.');
          return;
        }
        
        setCurrentScale(scale);
        toast.success('Color scale generated!');
      };
      
      const handleSave = () => {
        if (!currentScale) return;
        
        const existingIndex = savedPalettes.findIndex(p => p.name === currentScale.name);
        
        if (existingIndex >= 0) {
          const updated = [...savedPalettes];
          updated[existingIndex] = currentScale;
          setSavedPalettes(updated);
          toast.success(`Palette "${currentScale.name}" updated!`);
        } else {
          setSavedPalettes([...savedPalettes, currentScale]);
          toast.success(`Palette "${currentScale.name}" saved!`);
        }
      };
      
      const handleReset = () => {
        setCurrentScale(null);
        toast.info('Reset to default');
      };
      
      const handleClearSaved = () => {
        setSavedPalettes([]);
        toast.info('All saved palettes cleared');
      };
      
      const handleMoveAnchor = (direction) => {
        if (!currentScale) return;
        
        const currentIndex = scaleToIndex(currentScale.anchorPosition);
        const newIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
        
        if (newIndex < 0 || newIndex > 10) return;
        
        const newPosition = indexToScale(newIndex);
        const newScale = generateColorScale(
          currentScale.anchorHex,
          newPosition,
          currentScale.name
        );
        
        if (newScale) {
          setCurrentScale(newScale);
        }
      };
      
      const handleRemovePalette = (index) => {
        const removed = savedPalettes[index];
        setSavedPalettes(savedPalettes.filter((_, i) => i !== index));
        toast.info(`Removed "${removed.name}"`);
      };
      
      const currentScaleInfo = currentScale ? {
        lightness: currentScale.colors.find(c => c.step === currentScale.anchorPosition)?.oklch.l * 100,
        position: currentScale.anchorPosition
      } : undefined;
      
      return (
        <div style={{minHeight: '100vh', background: '#f6f6f6', padding: '1.5rem'}}>
          <div style={{maxWidth: '1280px', margin: '0 auto'}}>
            <div style={{marginBottom: '2rem'}}>
              <h1 style={{marginBottom: '0.5rem'}}>Brand Colour Scale Builder Experiment</h1>
              <p style={{color: 'rgba(26, 26, 26, 0.6)'}}>
                Build perceptually uniform colour scales using OKLCH colour space with subtle chroma taper
              </p>
            </div>
            
            <div style={{display: 'flex', flexDirection: 'column', gap: '1.5rem'}}>
              <InputControls
                onGenerate={handleGenerate}
                onSave={handleSave}
                onReset={handleReset}
                onExport={() => setShowExport(!showExport)}
                onClearSaved={handleClearSaved}
                hasCurrentScale={currentScale !== null}
                hasSavedPalettes={savedPalettes.length > 0}
                currentScaleInfo={currentScaleInfo}
              />
              
              <div style={{
                display: 'grid',
                gridTemplateColumns: '1fr',
                gap: '1.5rem'
              }}>
                <div style={{
                  display: 'grid',
                  gridTemplateColumns: 'repeat(auto-fit, minmax(400px, 1fr))',
                  gap: '1.5rem'
                }}>
                  <div>
                    {currentScale ? (
                      <GeneratedScale
                        scale={currentScale}
                        onMoveAnchor={handleMoveAnchor}
                      />
                    ) : (
                      <div style={{
                        background: 'white',
                        borderRadius: '0.75rem',
                        padding: '1.5rem',
                        boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
                        border: '1px solid var(--border)'
                      }}>
                        <h2 style={{marginBottom: '1rem'}}>Generated Scale</h2>
                        <p style={{
                          fontSize: 'var(--text-sm)',
                          color: 'rgba(26, 26, 26, 0.5)',
                          textAlign: 'center',
                          padding: '3rem 0'
                        }}>
                          Enter a hex colour and click "Generate" to create your scale
                        </p>
                      </div>
                    )}
                  </div>
                  
                  <div>
                    <SavedPalettes
                      palettes={savedPalettes}
                      onRemove={handleRemovePalette}
                    />
                  </div>
                </div>
              </div>
              
              {showExport && savedPalettes.length > 0 && (
                <ExportPanel
                  palettes={savedPalettes}
                  onClose={() => setShowExport(false)}
                />
              )}
            </div>
          </div>
        </div>
      );
    }
    
    const root = ReactDOM.createRoot(document.getElementById('root'));
    root.render(
      <ToastProvider>
        <App />
      </ToastProvider>
    );
  </script>




</body>
</html>

Which looked like this

Final design of app using Figma Make.

It was easier to style with Figma’s WYSIWYG interface. Also being able to spin out a standalone plug in for importing the json variables was a real help.

As I am not a coder it is hard to judge the maths of how the gradients are being calculated.

The point of the tool was also to be able to move the ‘anchor’ colour up and down the scale to prevent this as well, and both the Chat GPT version and the Figma Make allowed for that.

If you compare the two scales I think the Figma Make is a bit better. I wonder if there is an issue with the way the app’s scale seems to be mostly adjusting the ‘L’ lightness value. I did prompt Figma Make not to go to dark in its calculations.


‘Perfect idea! Limiting the darkest value to L 24 will ensure your darker shades remain distinguishable and usable. Let me update the lightness generation to cap the minimum at L 24 instead of L 5:

Perfect! I’ve updated the color generation to limit the darkest value to L 24 instead of L 5. Now your darkest shade (950) will have a minimum lightness of 24%, preventing those indistinguishable near-black values. This gives you more usable darker shades that actually show the color’s hue, rather than appearing as pure black. The lightness range now spans from L 95 (lightest) to L 24 (darkest), providing better practical utility for UI design.”

But the colours still get greyed out and murker at the darker end of the scale.

I wonder if the ‘C’ chroma which represents the color intensity, maybe should increase in the darker shades as well. The yellow goes to a brown quickly when maybe you’d want it to lean into more of a cadnium orange.

Colour scales for Chat GPT and Figma Make compared
A movie still, colour tinted with blues and pinks from James Whale’s Frankenstein (1931) the Doctor confronts the monster in his castle.

I am still haunted by the Frankenstein problem of having created a working tool I’m not really sure how the code actually works.

But I learnt all about about OKHCL colour and application.

Also both the Chat GPT and Figma Make created toasts and error messaging feedback without any prompting, which was pretty mind blowing. A sort of magical UX error and alert generation.