Skip to main content
    All posts

    The optimiser that made Shapeshifter worse

    Two changes made palettes measurably better and the product measurably worse. Notes from rewriting a colour engine, including the bug where best-of-24 was secretly best-of-2.

    Heyitskenton
    6 min read
    The Shapeshifter palette generator showing a generated brand colour palette

    Shapeshifter generates brand colour palettes. You describe a brand — the industry, the feeling, who it is for — and it returns a palette with roles assigned: primary, background, surface, accent, neutral.

    The first version did that badly in a way that took a while to see. This is about the rewrite, and specifically about two changes that made the output measurably better while making the product measurably worse.

    Regenerate was a button that did nothing

    The original engine had no random seed at all. Not a fixed seed — none. Palette generation was a pure function of the brand inputs, so pressing Regenerate with the same selections returned a byte-identical palette. The button worked. It ran. It produced the same five colours it had produced a moment earlier.

    The obvious fix is to add randomness. That fix is wrong, and the reason it is wrong became the design constraint for everything after it.

    Every palette has a share URL, and a share URL is a promise: send it to someone and they see what you saw. Randomness breaks that promise. So the engine needed to be deterministic in (inputs, seed), and genuinely different across seeds — both halves, simultaneously. The test file says this more sharply than I can:

    // The two tests that are the actual specification.
    //
    // They must pass SIMULTANEOUSLY. Diversity alone is satisfied by pure noise,
    // which is the same failure as the current engine, inverted. Fidelity alone is
    // satisfied by the constant function, which is the current engine.
    

    Pure noise passes a diversity test. A constant function passes a fidelity test. Only holding both at once describes the thing you actually want.

    Best-of-24 was secretly best-of-2

    With seeding in place, the natural next move is best-of-N: generate 24 candidates from derived seeds, score them, keep the best. Seeds are derived rather than random so the whole search stays reproducible from the one seed that ends up in the URL.

    deriveSeed was written as seed ^ CONSTANT.

    XOR is an involution. f(f(x)) === x. So the "chain" of derived seeds was not a chain — it was A, B, A, B, A, B forever. Twenty-four candidates were two candidates, twenty-three of them duplicates.

    The test that catches it has to assert something stronger:

    // deriveSeed drives best-of-N candidate search and constraint retries, so it
    // has to keep producing NEW seeds. An involution such as `seed ^ CONSTANT`
    // satisfies "different and reproducible" while oscillating between two values,
    // which silently turns best-of-24 into best-of-2.
    it('generates a long non-repeating chain, not an involution', () => {
      let seed = 12345;
      const seen = new Set<number>([seed]);
      for (let i = 0; i < 1000; i++) {
        seed = deriveSeed(seed);
        seen.add(seed);
      }
      expect(seen.size).toBe(1001);
    });

    A thousand derivations, a thousand and one distinct values. seed ^ CONSTANT returns 2.

    And then the fix made things worse

    With a real mixing step in place, best-of-24 finally did what it said. Palettes got better. Regenerate got worse.

    Measured: 12 candidates turned 40 presses of Regenerate into 6 distinct palettes.

    This is obvious in hindsight and invisible in advance. An optimiser converges on the same optimum from any starting point — that is what makes it an optimiser. Scoring 12 candidates and keeping the best means 12 different seeds all walk toward the same well-scored palette. The better the scoring function, the worse the variety.

    A second mechanism was doing the same thing independently. When accessibility constraints cannot be satisfied, the pipeline reseeds and tries again. Different starting seeds converge on the same compliant palette, for the same reason. Gating that behaviour alone took Regenerate from 17–29 distinct palettes per 40 presses to 40 out of 40, on every descriptor tested.

    The fix is counterintuitive enough that it needed a comment to survive review:

    // A SINGLE candidate here, deliberately. Best-of-N converges on the same
    // optimum from different starting seeds — measured, 12 candidates turned
    // 40 presses into 6 distinct palettes. Regenerate is a request for
    // something different, not for the best possible thing, so it takes the
    // seed's own palette.

    Generate uses 24 candidates, because the first click wants the best answer. Regenerate uses one, because that click is a request for a different answer. Two buttons, two goals, and an optimiser that belongs on exactly one of them.

    Measuring the thing you actually ship

    One more, because it is the kind of bug that passes every test you would think to write.

    Contrast was being computed in continuous OKLCH — the space the engine works in, where lightness is a float. But nobody receives a float. They receive a hex triple, and rounding to 8 bits per channel moves the contrast ratio by up to about 0.02.

    So a palette could satisfy its own 3:1 check and ship at 2.99:1.

    /**
     * Luminance of the swatch AS DELIVERED, i.e. after 8-bit quantisation.
     *
     * Measuring in continuous OKLCH would let a palette pass its own contrast check
     * and then ship at 2.99:1, because rounding to a hex triple moves the ratio by
     * up to ~0.02. The report has to be true of the colors the user actually gets.
     */

    There are now two luminance functions on purpose. The solver's inner loop uses the continuous one, because a bisection needs precision. The verdict uses the quantised one, because the verdict has to be true of the colours in the export.

    The tool that failed its own test

    Shapeshifter has an Accessibility Score Card. It measures the contrast of the palette it just generated and grades it against WCAG.

    Its own --muted-foreground shipped at 4.17:1 — below the 4.5:1 floor for normal text. Measured with the project's own contrast function. On the most-used text colour on the site.

    It gets worse, and more familiar. The prefers-reduced-motion block in the stylesheet only sets animation-duration and transition-duration, so it never reached any of the 104 framer-motion elements on the site — because framer animates in JavaScript, not CSS. Someone who had asked their operating system to stop moving things still got the wizard's slide transitions and a 120-degree rotate-in on scroll. Which is precisely the vestibular trigger that setting exists to prevent.

    Neither of those is a clever bug. They are the ordinary kind, and the reason they survived is that the people most likely to notice are the least likely to be in the room.


    The engine is 17 modules and about 2,900 lines, with 150 tests sitting next to it — several of them 500-case fuzz passes over seeded fixtures. That ratio is not diligence for its own sake. It is that every bug above passed the tests I would have written first.

    Shapeshifter is free to try — no signup. Every palette has a share URL, and that URL is still a promise.

    Heyitskenton

    Builds and maintains everything Socialocca ships — the apps, the sites, and the systems behind them.

    Share

    Keep reading