mcpbeat Sign in

Artwork Palette Theming Agent Skill

Drive a screen's colours from its artwork — which extracted swatch to use for an accent versus for a large page background, luminance-adaptive darkening so overlaid text stays readable on any image, the hex parsing helper this needs, and what to do when extraction returns nothing or there is no artwork at all. Use when a page background comes out lurid or unreadably light, when it flashes or changes while scrolling a list, or when a screen renders transparent or invisible instead of tinted.

2k tokens
context cost
the whole folder, loaded on every use
1
files
instructions only
0
copies elsewhere
how many repositories repackaged it
100
stars on the repo
on the repository, not the skill itself

Install

one command, takes just this skill from the repository
npx skills add https://github.com/maxrave-dev/kotlin-footguns --skill artwork-palette-theming

The instruction itself

4 sections, as written by the author

Artwork palette theming

Palette extraction gives a handful of swatches per image (vibrant, muted, dominant, and light/dark

variants). Choosing between them decides whether a screen looks designed or broken, and the right

choice differs between a small accent and the page behind the text.

Accent versus background

An accent — a section tint, a chip, a stripe, a shadow colour — wants a *vivid* swatch, and a

priority walk so something is always returned:

// adapted: the real code nests these. `const val NONE = 0` — the "absent" default every getter takes
var argb = palette.getDarkVibrantColor(NONE)
if (argb == NONE) argb = palette.getDarkMutedColor(NONE)
if (argb == NONE) argb = palette.getVibrantColor(NONE)
if (argb == NONE) argb = palette.getMutedColor(NONE)
if (argb == NONE) argb = palette.getLightVibrantColor(NONE)
if (argb == NONE) argb = palette.getLightMutedColor(NONE)

A page background wants the opposite: the *dominant* swatch, which is the image's overall tone by

pixel area. A vivid swatch can be a tiny saturated patch — hair, lipstick, a logo — and using it

turns a whole page that colour, which is where "why is this portrait's page brick red" comes from.

// adapted
fun Palette.toImmersiveBackground(): Color {
    val rgb = getDominantColor(NONE).takeIf { it != NONE }
        ?: getMutedColor(NONE).takeIf { it != NONE }
        ?: getVibrantColor(NONE).takeIf { it != NONE }
        ?: return Color.Black
    val base = Color(rgb)
    val luminance = 0.299f * base.red + 0.587f * base.green + 0.114f * base.blue
    return lerp(base, Color.Black, 0.35f + 0.45f * luminance)
}

The darkening is what makes it work on *any* image rather than on the images you tested. Read the two

constants as a floor and a slope: darken every background at least this much, and a light one this

much more again, so light artwork cannot produce a page white text disappears on. Tune them against

your two extremes — the lightest and darkest cover you can find — no single constant satisfies both.

Traps

The "no swatch" sentinel is whatever you passed in, and it is a real colour. Absence has no

representation of its own — getDominantColor(NONE) hands your own NONE straight back — so "no

swatch" and "the colour 0x00000000" arrive as the same 32 bits. Zero is the right choice *because*

swatches are always opaque (androidx palette's quantizer builds them through Color.rgb, which ORs

0xFF000000), so it cannot collide with a real swatch, whereas the tempting "safer" opaque black

collides with every genuinely black one. What zero does not escape is the trap below: it still draws.

That same sentinel handed to a colour constructor is transparent, not black. Color(argb: Int)

packs the whole 32-bit value as ARGB and supplies no alpha of its own, so Color(0) has alpha 0 and

draws nothing. Return an explicit Color.Black on the everything-failed path — the one nobody tests.

Extraction returning nothing is normal, not an error. Near-monochrome art, flat art and very

small images produce no swatch for a given profile, for a meaningful slice of any library — which is

the whole reason the walk above exists. Do not log it as a failure.

No artwork means the palette never even runs. With no image the success callback that feeds the

extractor never fires, so the palette stays null forever. Deriving the fallback from the same

generator that drew the placeholder keeps the two in agreement — deterministic-title-placeholder-painter:

// adapted
val background = palette?.toImmersiveBackground() ?: run {
    val titleColors = gradientFromTitle(title)
    val base = if (titleColors.size >= 2) lerp(titleColors[0], titleColors[1], 0.5f)
               else titleColors.firstOrNull() ?: Color.Black
    lerp(base, Color.Black, 0.3f)
}

Regenerate on the image's identity, not on the bitmap. In a lazy list the header scrolls out, is

recycled, re-mounts and fires its callback again — re-running extraction and flashing the page to a

slightly different colour. Keep the key you already have and skip when it is unchanged:

// adapted
var paletteGeneratedFor by remember { mutableStateOf<String?>(null) }
LaunchedEffect(bitmap) {
    val bm = bitmap
    if (bm != null && paletteGeneratedFor != currentImageKey) {
        paletteState.generate(bm)
        paletteGeneratedFor = currentImageKey
    }
}

Clamp to 0f..1f, not to 255. Compose colour components are floats, so min(red * factor, 255f)

looks like a clamp and never fires — a leftover from integer colour APIs. Use coerceIn(0f, 1f).

Parsing hex needs the alpha byte added, and needs to survive garbage. Colours arrive from remote

metadata and user settings, so parse to a nullable — and six digits must be OR-ed with an opaque

alpha or you get an invisible colour, while eight already carry one:

// adapted
fun String.hexToColorOrNull(): Color? = runCatching {
    val clean = removePrefix("#")
    val argb = when (clean.length) {
        6 -> 0xFF000000L or clean.toLong(16)
        8 -> clean.toLong(16)
        else -> return null
    }
    Color(argb)
}.getOrNull()

Verifying it

  • Every extraction call site, to check each keys its regeneration on the image identity:
   grep -rn --include='*.kt' -A6 "\.generate(" . | grep -v '/build/' | grep -E "generate\(|GeneratedFor|LaunchedEffect"
  • Zero sentinels around colour APIs. Deliberately unanchored, so it catches a literal Color(0)

and any …Color(0) getter given a zero default — every hit is a place where "no colour" and

"transparent black" are the same value:

   grep -rn --include='*.kt' -E "Color\(0x?0+\)|Color\(0\)" . | grep -v '/build/'
  • Clamps against integer colour ranges in float colour code:
   grep -rn --include='*.kt' -E "(min|coerceAtMost|coerceIn)\([^)]*25[56]f" . | grep -v '/build/'
  • By eye, against the two extremes rather than an average cover: a near-white artwork and a

near-black one. Overlay the smallest body text you ship; readable on both means the floor and

slope are right.

How to use it

Copy the folder

Take maxrave-dev/artwork-palette-theming from the repository into ~/.claude/skills for personal use, or into .claude/skills inside a project.

Check the name does not clash

The agent identifies a skill by the name field in its header. Two skills with the same name cannot sit side by side — one of them will be ignored.