Learn Creative Coding (#142) - Mini-Project: Build a Creative Tool

in StemSocial3 hours ago

Learn Creative Coding (#142) - Mini-Project: Build a Creative Tool

cc-banner

Last episode I left you sitting in the driver's seat of a brand new engine, promising that the very next thing we'd do is stop learning techniques for a minute and actually build one real thing with everything we've gathered. Allez, promise kept - today's the mini-project. And I want to do it properly, the way you'd build an actual little project on a quiet evening, not the way you'd build a lesson. So no new API, no new syntax. Just us, taking the pile of habits from the last stretch - the framework shape from episode 131, the GUI controls from 133, the tool-for-others manners from 140 - and welding them into a single thing a stranger could pick up and enjoy :-).

Here's what we're making: a little palette generator. You give it a seed, it hands you back a set of colours that actually go together, you can nudge the settings, share what you made as a link, and drop it on any web page as a custom tag. Small on the outside, but under the hood it touches almost everything we've done this year - seeded randomness from episode 24, the colour thinking from episodes 7 and 28, the factory pattern, sensible defaults, teaching errors, URL sharing. I'm calling it hue-forge, because naming things is half the fun and I refuse to apologise for it. Let's build.

Start from the feeling, then work backwards

Before a single line, I always ask: what's the nicest possible thing the user does? For this tool, it's this - they type a word or a number, and out comes a palette that looks deliberate, like a designer chose it. Everything else is in service of that one moment. So I work backwards from the call I wish existed:

// this is the call I WISH existed. i haven't written the tool yet -
// i'm designing the front door first, before any of the plumbing.
const forge = createForge({ seed: "antwerp", harmony: "analogous" });
forge.palette();   // -> ["hsl(28 70% 55%)", "hsl(58 70% 55%)", ...]

That's the whole product, really. Design that call site until it reads like a sentence, and the rest is just honest work to make it true. See where this is going? We're doing the same thing episode 140 preached - the signature is the product - except now we get to actually feel it, because we're the first user.

The seed: words should work too

We learned seeded randomness back in episode 24, and I'm going to reuse the exact little generator from then - a tiny function that turns one number into an endless, repeatable stream of randomness. Same seed in, same numbers out, forever.

// the seeded RNG from episode 24 - mulberry32. one integer in,
// a repeatable stream of numbers between 0 and 1 out.
function seededRandom(seed) {
  let a = seed >>> 0;
  return function () {
    a |= 0; a = a + 0x6D2B79F5 | 0;
    let t = Math.imul(a ^ a >>> 15, 1 | a);
    t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
    return ((t ^ t >>> 14) >>> 0) / 4294967296;
  };
}

But look back at the call I wished for - I passed "antwerp", a word, not a number. That's on purpose, and it's a tiny kindness that makes a tool feel human. People don't want to remember seed: 483920. They want to type their cat's name. So I need a little bridge that turns any string into a stable number first. This is just a small hash - nothing cryptographic, we only need it to be consistent.

// turn ANY string into a stable integer, so users can seed with words.
// "antwerp" always maps to the same number, on any machine, forever.
function hashSeed(input) {
  if (typeof input === "number") return input;   // numbers pass straight through
  let h = 2166136261;                            // FNV-ish starting point
  for (let i = 0; i < input.length; i++) {
    h ^= input.charCodeAt(i);
    h = Math.imul(h, 16777619);                  // scramble as we go
  }
  return h >>> 0;                                // force it positive
}

Now "antwerp" and 42 are both valid seeds, and both are reproducible. That flexibility costs me eight lines and buys my users a much warmer first impression. Makes sense, right?

The actual colour thinking

Right, the heart of it. Why do some colours "go together" and others fight? Back in episodes 7 and 28 we leaned on this, and the honest short answer is: colours that sit in a deliberate relationship on the colour wheel read as intentional, and random ones read as noise. The wheel is just hue, 0 to 360 degrees. So instead of rolling three random colours and praying, I roll one base hue and then derive the others by walking known distances around the wheel.

// pick ONE base hue from the seed, then derive the rest by known angles.
// this is the whole secret to palettes that look chosen, not random.
const HARMONIES = {
  analogous:     [0, 30, 60, -30],     // neighbours on the wheel - calm, cohesive
  complementary: [0, 180, 30, 210],    // opposites - high contrast, punchy
  triadic:       [0, 120, 240],        // evenly spaced - balanced and lively
  monochrome:    [0, 0, 0, 0],         // one hue, we'll vary lightness instead
};

function hueSet(baseHue, harmony) {
  const offsets = HARMONIES[harmony] ?? HARMONIES.analogous;
  return offsets.map((d) => (baseHue + d + 360) % 360);   // wrap to stay in 0..360
}

That % 360 at the end is the important bit - hues wrap around, so 350 + 30 becomes 20, not 380. The wheel has no edges. With that in place, generating a full palette is just: pick a base hue from the RNG, spread it into a harmony, and give each colour a sensible saturation and lightness so they read as a family.

// build the palette: one seeded base hue -> a harmonious set of HSL colours.
function buildPalette(seed, harmony, sat, light) {
  const rng = seededRandom(hashSeed(seed));
  const baseHue = Math.floor(rng() * 360);        // the seed decides the whole mood
  const hues = hueSet(baseHue, harmony);

  return hues.map((h, i) => {
    // nudge lightness a touch per swatch so they're distinct but related
    const l = harmony === "monochrome" ? light + i * 12 : light;
    return `hsl(${Math.round(h)} ${sat}% ${Math.round(l)}%)`;
  });
}

I'm using HSL strings on purpose - they're human-readable (you can read hsl(28 70% 55%) and picture a warm orange), and they drop straight into CSS with zero conversion. That readability is a gift to whoever debugs this later, and that person is usually me three weeks from now :-).

Wrapping it in defaults and manners

We've got the engine. Now we make it polite, using the exact pattern from episode 140: one options object, a sensible default for every knob, and the user's choices spread on top so they only set what they care about.

// merge the user's options over our defaults - our manners, in one line.
// straight from episode 140. you'll write this in every tool you ever make.
function withDefaults(defaults, options = {}) {
  return { ...defaults, ...options };
}

const FORGE_DEFAULTS = {
  seed: "hello",
  harmony: "analogous",
  saturation: 70,
  lightness: 55,
  count: 4,
};

And before we trust any of it, we validate at the front door - with errors that teach instead of scold, exactly like last week's lesson. If someone passes a harmony we don't have, we don't crash three files deep with undefined is not a function. We tell them what they did and hand them the fix.

// validate up front and fail with a message that teaches, not one that scolds.
function validate(config) {
  if (!(config.harmony in HARMONIES)) {
    const options = Object.keys(HARMONIES).join(", ");
    throw new Error(
      `hue-forge: unknown harmony "${config.harmony}". ` +
      `Try one of: ${options}.`
    );
  }
  if (config.saturation < 0 || config.saturation > 100) {
    throw new Error(
      `hue-forge: "saturation" is a percentage (0-100), ` +
      `but you passed ${config.saturation}. Try { saturation: 70 }.`
    );
  }
}

Read that harmony error back - it even lists the valid options by reading the keys of our HARMONIES object. That means the day I add a fifth harmony, the error message updates itself. I didn't have to remember to keep it in sync, because it was never a separate list. Little touches like that are the difference between a tool that ages well and one that rots.

The factory: configure once, hand back a tidy object

Now the shape that ties it together - the factory from episode 131, pointed outward at other people like we discussed in 140. Configure once, get back a small object whose method names basically are the manual.

// the factory: the whole tool, behind three or four guessable methods.
// this is episode 131's shape, made friendly for strangers.
export function createForge(options = {}) {
  const config = withDefaults(FORGE_DEFAULTS, options);
  validate(config);

  return {
    // the current palette as an array of HSL strings
    palette() {
      return buildPalette(
        config.seed, config.harmony, config.saturation, config.lightness
      ).slice(0, config.count);
    },
    // re-roll with a new seed WITHOUT rebuilding the whole tool
    reseed(seed) { config.seed = seed; return this; },
    // switch the harmony rule on the fly
    setHarmony(harmony) { config.harmony = harmony; validate(config); return this; },
    // always let people read back the honest, complete settings
    get settings() { return { ...config }; },
  };
}

Notice reseed and setHarmony both return this - that lets people chain calls (forge.reseed("gent").setHarmony("triadic")) if they like, but doesn't force it. And settings hands back a copy, so a curious user can't accidentally reach in and corrupt our internal config by mutating what we returned. That's a small defensive habit that has saved me real debugging pain - never hand out your private state by reference.

Showing it: swatches on a canvas

A palette you can't see is just an array. Time to draw, and after everything we've done with the Canvas API since episode 2, this part is a gentle stroll. We take a context and a palette and paint even stripes across it.

// render a palette as a row of swatches into any 2D canvas context.
// nothing new here - just episode 2's canvas, doing honest work.
function renderSwatches(ctx, colors, width, height) {
  const w = width / colors.length;
  colors.forEach((color, i) => {
    ctx.fillStyle = color;
    ctx.fillRect(i * w, 0, Math.ceil(w), height);   // ceil avoids thin seams between swatches
  });
}

That Math.ceil on the width is a tiny trick worth pocketing - without it, fractional pixel widths leave hairline gaps between swatches where the page background peeks through. Rounding each swatch up a hair makes them overlap by less than a pixel and the seams vanish. It's the kind of thing nobody teaches you and everybody eventually discovers by squinting at their screen going "why is there a line there".

Controls, so people can play

A generator you can't steer gets boring in about ten seconds. Back in episode 133 we built proper GUI controls; here I'll keep it deliberately minimal - a seed box and a harmony picker - because a good tool starts simple and earns its complexity. We wire the inputs so that any change redraws immediately.

// wire up minimal controls: change a value, redraw at once. (episode 133 energy)
function attachControls(forge, ctx, canvas) {
  const redraw = () => renderSwatches(ctx, forge.palette(), canvas.width, canvas.height);

  document.querySelector("#seed").addEventListener("input", (e) => {
    forge.reseed(e.target.value || "hello");   // empty box falls back to a default
    redraw();
  });

  document.querySelector("#harmony").addEventListener("change", (e) => {
    forge.setHarmony(e.target.value);
    redraw();
  });

  redraw();   // paint once on load so the page is never blank
}

That last line matters more than it looks. A tool that opens to a blank canvas silently tells the user "you must do something before I'm any good." A tool that opens already showing a nice palette says "here, I made you something, now go change it." That's the welcoming-on-the-first-call idea from episode 140, made literal. Always paint once before the user touches anything.

Sharing without a server

Here's my favourite part, and it's almost free. A palette in this tool is entirely described by its settings - seed, harmony, a couple of numbers. That means the whole creation compresses to a handful of values, and a handful of values fits in a URL. So we let people share what they made with nothing but a link. No database, no accounts, no backend. The link is the save file.

// pack the settings into a URL so a creation can be shared as just a link.
// no server, no database - the link carries the whole recipe.
function forgeToURL(forge) {
  const params = new URLSearchParams(forge.settings);
  return `${location.origin}${location.pathname}?${params}`;
  // -> ".../?seed=antwerp&harmony=triadic&saturation=70&..."
}

// and rebuild from a link when the page loads, so shared URLs just work:
function forgeFromURL() {
  const params = new URLSearchParams(location.search);
  const options = Object.fromEntries(params);          // URL params -> options object
  if (options.saturation) options.saturation = +options.saturation;   // strings -> numbers
  if (options.lightness) options.lightness = +options.lightness;
  if (options.count) options.count = +options.count;
  return createForge(options);
}

Watch the little + conversions - URL values arrive as strings ("70", not 70), and if I fed those straight into the maths I'd get subtle bugs, because "70" + 12 is the string "7012", not 82. Turning them back into real numbers at the boundary is one of those unglamorous, five-second habits that saves an hour of "why is my lightness enormous". Boundaries between your tool and the outside world are exactly where types quietly go wrong.

Handing it to non-coders

Not everyone who'd enjoy this writes JavaScript. So, straight from episode 140's playbook, we wrap the whole thing in a custom element - our own HTML tag anyone can paste onto a page with no build step and no npm.

// wrap the tool in a custom element: a plain HTML tag anyone can drop on a page.
class HueForgeElement extends HTMLElement {
  connectedCallback() {
    const canvas = document.createElement("canvas");
    canvas.width = 400; canvas.height = 100;
    this.appendChild(canvas);

    // read options right off the HTML attributes, with friendly fallbacks
    const forge = createForge({
      seed: this.getAttribute("seed") ?? "hello",
      harmony: this.getAttribute("harmony") ?? "analogous",
    });
    renderSwatches(canvas.getContext("2d"), forge.palette(), 400, 100);
  }
}
customElements.define("hue-forge", HueForgeElement);

And now look at the entire experience for someone who's never opened a terminal in their life. This is all they write:

(html comment removed:  a non-coder pastes this and gets a live palette. that's the whole dream. )
<hue-forge seed="antwerp" harmony="triadic"></hue-forge>

One tag, two plain-English attributes, done. Every bit of our seeded, validated, harmony-aware cleverness sits quietly behind a tag that reads like it was always part of HTML. The first time a friend puts <hue-forge> on their site with their seed and gets a palette I never would've made - honestly, that never stops feeling a little bit magic.

Letting people take the colours with them

One last touch, because a palette you can look at but can't use is a tease. Real designers want the colours out - into their CSS, their sketch, their wherever. So we give them a one-click export as CSS custom properties, the format that drops straight into a stylesheet.

// export the palette as CSS variables - the format people actually paste into their styles.
function paletteToCSS(colors) {
  const lines = colors.map((c, i) => `  --swatch-${i + 1}: ${c};`);
  return `:root {\n${lines.join("\n")}\n}`;
  // -> ":root {\n  --swatch-1: hsl(28 70% 55%);\n  --swatch-2: ...\n}"
}

// wire it to a "copy" button so it lands on their clipboard, ready to paste
function attachExport(forge) {
  document.querySelector("#copy").addEventListener("click", () => {
    navigator.clipboard.writeText(paletteToCSS(forge.palette()));
  });
}

That's the whole loop closed: the tool makes something, lets you steer it, lets you share it as a link, lets you embed it as a tag, and lets you take the result away in a format you'll actually use. Notice how little of that was clever algorithm and how much was just... thinking about the person on the other side. That's the real lesson of this whole mini-project, and of episode 140 before it.

Where this is heading

Step back and look at what we built from one quiet evening's work. It's genuinly a tool now, not a sketch - it has a friendly front door, sensible defaults, errors that teach, a shareable link, an HTML tag, and an export button. And almost none of it was new knowledge. It was seeded randomness from episode 24, colour relationships from 7 and 28, the factory from 131, the controls from 133, and the manners from 140, all leaning on each other. That's what a mini-project is for - not to learn something new, but to feel how the separate pieces you've been collecting were secretly one toolkit the whole time.

But here's the thing that's been quietly nagging me the whole episode. We built hue-forge for others to use - and yet right now it lives on my machine, in my head, mine. What happens when you don't just let people use your tool, but let them crack it open, change it, add the harmony you forgot, fix the bug you never saw, and hand their improvements back to you and everyone else? That's a whole different way of making things - messier, more generous, and honestly more alive - and it's exactly where we're going next. So this week, your homework is lovely and small: take hue-forge (or your own version of it) and add one thing I left out. A fifth harmony rule. A randomise button. A dark background toggle. Whatever you wish it did. Because the moment you change a tool to fit your own hands, you've stopped being just a user of it - and that's the perfect frame of mind to walk into the next episode with :-).

Allez, wa weten we nu allemaal?

  • A mini-project isn't for learning something new - it's for feeling how the pieces you already have snap together into one thing. Today we welded episodes 24, 7, 28, 131, 133 and 140 into a single tool
  • Design the call you wish existed first - createForge({ seed: "antwerp" }) came before any plumbing. The signature is the product; make it read like a sentence, then do the honest work to make it true
  • Let people seed with words, not just numbers - a tiny string hash turns "antwerp" into a stable, reproducible number, and the tool feels human instead of clinical
  • Harmonious colour is one base hue plus known angles - roll ONE hue from the seed, then derive the rest by walking the wheel (analogous, complementary, triadic). Wrap with % 360 because the wheel has no edges
  • Wrap the engine in manners - one options object, a default for every knob (episode 140's withDefaults), and validation at the front door with errors that list the fix, not errors that scold
  • The factory hides everything behind guessable methods - palette(), reseed(), setHarmony(), and a settings getter that hands back a copy so nobody corrupts your private state
  • Open showing something nice - paint one palette before the user touches anything. A blank canvas says "do work first"; a filled one says "here, now play"
  • Share with the URL, embed with a tag, export as CSS - a creation that's just settings fits in a link (no server!), wraps into a <hue-forge> custom element for non-coders, and exports to CSS variables people actually paste. Mind the string-to-number conversions at every boundary

So that's a real tool, start to finish, built entirely out of things we already knew. That's the quiet joy of getting a few episodes deep into anything - the day you notice you're not learning bricks anymore, you're building houses. Go add your one missing feature to hue-forge this week, make it a little bit yours, and come back next time ready to think about what happens when you let the whole world do the same. Merci for reading, en tot de volgende keer :-).

Sallukes! Thanks for reading.

X

@femdev

Sort:  

Congratulations @femdev! You have completed the following achievement on the Hive blockchain And have been rewarded with New badge(s)

You have been a buzzy bee and published a post every day of the week.

You can view your badges on your board and compare yourself to others in the Ranking
If you no longer want to receive notifications, reply to this comment with the word STOP

Check out our last posts:

Feedback from the August Hive Power Up Day
Hive Power Up Month Challenge - July 2026 Winners List
Be ready for the August edition of the Hive Power Up Month!