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.
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.
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?
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 :-).
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.
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.
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".
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.
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.
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:
<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.
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.
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 :-).
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% 360 because the wheel has no edgeswithDefaults), and validation at the front door with errors that list the fix, not errors that scoldpalette(), reseed(), setHarmony(), and a settings getter that hands back a copy so nobody corrupts your private state<hue-forge> custom element for non-coders, and exports to CSS variables people actually paste. Mind the string-to-number conversions at every boundarySo 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