[worker] simplify image-derived piece palettes #205
No files matched your search
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@luzzle/web.worker": patch
|
||||
---
|
||||
|
||||
Use Sharp's palette quantizer instead of Colorlip to extract image swatches. Build piece palettes from those swatches, favoring colorful backgrounds and choosing black or white text with measured contrast against the solid background. Share the same straightforward pipeline between Preview and Publish.
|
||||
@@ -80,6 +80,18 @@ Values must match the chosen field types; a page count is a number, not text.
|
||||
Invalid values log a warning and use that role's default field values for the
|
||||
piece. Unknown names/keys and malformed selectors fail config validation.
|
||||
|
||||
## Image-derived colors
|
||||
|
||||
The first registered image resolved from `fields.media` supplies a shared palette
|
||||
for piece banners, icons and OG images. Colorful images favor representative
|
||||
colored backgrounds; monochrome images retain neutral tones. Black or white text
|
||||
is chosen for contrast against the solid palette background. Image layers under
|
||||
translucent template surfaces can still change the final rendered contrast.
|
||||
|
||||
Generate a fresh Preview to inspect the colors, then use the piece's Publish
|
||||
action to regenerate its public outputs. Existing published palettes and OG
|
||||
images do not change automatically.
|
||||
|
||||
## Apply your choices
|
||||
|
||||
Restart web and worker after config changes, then refresh. To regenerate OG
|
||||
|
||||
@@ -48,7 +48,6 @@
|
||||
"@luzzle/web.templates": "workspace:*",
|
||||
"@luzzle/web.theme": "workspace:*",
|
||||
"@takumi-rs/core": "1.5.0",
|
||||
"colorlip": "^1.0.0",
|
||||
"devlop": "1.1.0",
|
||||
"hastscript": "9.0.1",
|
||||
"kysely": "0.28.17",
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { describe, test, expect } from 'vitest'
|
||||
import sharp from 'sharp'
|
||||
import { getPalette } from './colorlip.js'
|
||||
|
||||
describe('transforms/palette/colorlip', () => {
|
||||
test('extracts palette from image buffer using colorlip', async () => {
|
||||
// Create a simple 10x10 image buffer dynamically using sharp
|
||||
// Fill it with red background and a small blue area for accent
|
||||
const image = await sharp({
|
||||
create: {
|
||||
width: 10,
|
||||
height: 10,
|
||||
channels: 3,
|
||||
background: { r: 255, g: 0, b: 0 },
|
||||
},
|
||||
})
|
||||
.composite([
|
||||
{
|
||||
input: Buffer.from([0, 0, 255]), // blue pixel
|
||||
raw: { width: 1, height: 1, channels: 3 },
|
||||
top: 0,
|
||||
left: 0,
|
||||
},
|
||||
])
|
||||
.png()
|
||||
.toBuffer()
|
||||
|
||||
const palette = await getPalette(image)
|
||||
|
||||
// Assert on legacy shape properties
|
||||
expect(palette.background).toBeDefined()
|
||||
expect(palette.background).toMatch(/^#[0-9a-fA-F]{6}$/)
|
||||
expect(palette.accent).toBeDefined()
|
||||
expect(palette.accent).toMatch(/^#[0-9a-fA-F]{6}$/)
|
||||
expect(palette.muted).toBeDefined()
|
||||
expect(palette.muted).toMatch(/^#[0-9a-fA-F]{6}$/)
|
||||
|
||||
// Contrast text recommendations should be present and valid
|
||||
expect(palette.bodyText).toBeDefined()
|
||||
expect(['#ffffff', '#000000']).toContain(palette.bodyText)
|
||||
expect(palette.titleText).toBeDefined()
|
||||
expect(['#ffffff', '#000000']).toContain(palette.titleText)
|
||||
|
||||
// Extended properties
|
||||
expect(palette.dominant).toBeDefined()
|
||||
expect(palette.swatches).toBeInstanceOf(Array)
|
||||
expect(palette.swatches?.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
@@ -1,55 +0,0 @@
|
||||
import { getPalette as getRawPalette } from 'colorlip/sharp'
|
||||
import { softenBackground, getBestTextColor, rgbToHex } from './utils.js'
|
||||
|
||||
export interface Palette {
|
||||
background?: string
|
||||
bodyText?: string
|
||||
titleText?: string
|
||||
accent?: string
|
||||
muted?: string
|
||||
dominant?: string
|
||||
swatches?: string[]
|
||||
}
|
||||
|
||||
export async function getPalette(image: Buffer): Promise<Palette> {
|
||||
const rawPalette = await getRawPalette(image)
|
||||
const dominant = rawPalette.dominant
|
||||
const accent = rawPalette.accent
|
||||
const swatches = rawPalette.swatches || []
|
||||
|
||||
// Determine raw background color (choose a prominent swatch that isn't dominant or accent to prevent image bleeding)
|
||||
const nonPrimarySwatches = swatches.filter(
|
||||
(s) => s.hex !== dominant?.hex && s.hex !== accent?.hex
|
||||
)
|
||||
const bg = nonPrimarySwatches[0] || swatches.find((s) => s.hex !== dominant?.hex) || dominant
|
||||
|
||||
let background: string | undefined
|
||||
let bodyText: string | undefined
|
||||
let titleText: string | undefined
|
||||
|
||||
if (bg) {
|
||||
// Soften background saturation/lightness to avoid eyes strain
|
||||
const softenedBg = softenBackground(bg.r, bg.g, bg.b)
|
||||
background = rgbToHex(softenedBg.r, softenedBg.g, softenedBg.b)
|
||||
|
||||
// Determine mathematically optimal text contrast
|
||||
const textColor = getBestTextColor(softenedBg.r, softenedBg.g, softenedBg.b)
|
||||
bodyText = textColor
|
||||
titleText = textColor
|
||||
}
|
||||
|
||||
// Determine muted color by finding the swatch with the lowest saturation
|
||||
// that isn't the dominant or accent color
|
||||
const candidates = nonPrimarySwatches.length > 0 ? nonPrimarySwatches : swatches
|
||||
const mutedSwatch = candidates.slice().sort((a, b) => a.saturation - b.saturation)[0]
|
||||
|
||||
return {
|
||||
background,
|
||||
bodyText,
|
||||
titleText,
|
||||
accent: accent?.hex || swatches[1]?.hex,
|
||||
muted: mutedSwatch?.hex,
|
||||
dominant: dominant?.hex,
|
||||
swatches: swatches.map((s) => s.hex),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { expect, test } from 'vitest'
|
||||
import sharp from 'sharp'
|
||||
import { extractSwatches } from './extract.js'
|
||||
import { createPalette } from './palette.js'
|
||||
import { getContrastRatio, getRelativeLuminance, rgbToHsl } from './utils.js'
|
||||
|
||||
test('builds a colorful, readable palette from a cover with prominent neutral tones', async () => {
|
||||
const image = await sharp(
|
||||
Buffer.from(`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="150">
|
||||
<rect width="120" height="150" fill="#DCD6CC" />
|
||||
<rect width="120" height="25" y="60" fill="#22211E" />
|
||||
<rect width="120" height="20" y="85" fill="#78746E" />
|
||||
<rect width="120" height="15" y="105" fill="#ABAAA6" />
|
||||
<rect width="120" height="10" y="120" fill="#B8594C" />
|
||||
<rect width="120" height="20" y="130" fill="#648298" />
|
||||
</svg>
|
||||
`)
|
||||
)
|
||||
.png()
|
||||
.toBuffer()
|
||||
|
||||
const swatches = await extractSwatches(image)
|
||||
const palette = createPalette(swatches)
|
||||
|
||||
const blue = swatches.find((color) => color.b > color.r + 20)!
|
||||
const red = swatches.find((color) => color.r > color.b + 30)!
|
||||
expect(blue.population).toBeGreaterThan(red.population)
|
||||
expect(palette.background).toBe(blue.hex)
|
||||
const [r, g, b] = palette.background!.match(/[0-9a-f]{2}/gi)!.map((c) => parseInt(c, 16))
|
||||
expect(rgbToHsl(r, g, b).s).toBeGreaterThan(0.15)
|
||||
expect(['#000000', '#ffffff']).toContain(palette.bodyText)
|
||||
const textLuminance = palette.bodyText === '#ffffff' ? 1 : 0
|
||||
expect(getContrastRatio(getRelativeLuminance(r, g, b), textLuminance)).toBeGreaterThanOrEqual(4.5)
|
||||
})
|
||||
|
||||
test('does not include invisible pixels in the extracted swatches', async () => {
|
||||
const white = [255, 255, 255, 255]
|
||||
const transparent = [0, 0, 0, 0]
|
||||
const image = await sharp(
|
||||
Buffer.from([...white, ...transparent, ...transparent, ...transparent]),
|
||||
{
|
||||
raw: { width: 2, height: 2, channels: 4 },
|
||||
}
|
||||
)
|
||||
.png()
|
||||
.toBuffer()
|
||||
|
||||
const swatches = await extractSwatches(image)
|
||||
|
||||
expect(swatches.map((color) => color.hex)).toEqual(['#ffffff'])
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import sharp from 'sharp'
|
||||
import { rgbToHex, type Rgb } from './utils.js'
|
||||
|
||||
export interface Swatch extends Rgb {
|
||||
hex: string
|
||||
population: number
|
||||
}
|
||||
|
||||
export async function extractSwatches(image: Buffer): Promise<Swatch[]> {
|
||||
const quantized = await sharp(image)
|
||||
.resize({ width: 150, height: 150, fit: 'inside', withoutEnlargement: true })
|
||||
.png({ palette: true, colours: 16, dither: 0 })
|
||||
.toBuffer()
|
||||
const { data } = await sharp(quantized)
|
||||
.toColourspace('srgb')
|
||||
.ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true })
|
||||
|
||||
const swatches = new Map<number, Swatch>()
|
||||
for (let offset = 0; offset < data.length; offset += 4) {
|
||||
const r = data[offset]
|
||||
const g = data[offset + 1]
|
||||
const b = data[offset + 2]
|
||||
const alpha = data[offset + 3] / 255
|
||||
if (alpha === 0) continue
|
||||
|
||||
const key = (r << 16) | (g << 8) | b
|
||||
const swatch = swatches.get(key)
|
||||
if (swatch) swatch.population += alpha
|
||||
else swatches.set(key, { r, g, b, hex: rgbToHex(r, g, b), population: alpha })
|
||||
}
|
||||
return [...swatches.values()]
|
||||
}
|
||||
@@ -1,176 +1,99 @@
|
||||
import { describe, test, expect, vi, afterEach } from 'vitest'
|
||||
import { run } from './index.js'
|
||||
import { getPalette } from './colorlip.js'
|
||||
import { afterEach, expect, test, vi } from 'vitest'
|
||||
import type { Config } from '@luzzle/web.config'
|
||||
import type { WebPieces } from '../../services/db.js'
|
||||
import type { Pieces } from '@luzzle/core'
|
||||
import type { WebPieces } from '../../services/db.js'
|
||||
import { makeLogger } from '../../../test/logger.js'
|
||||
import { extractSwatches } from './extract.js'
|
||||
import { run } from './index.js'
|
||||
|
||||
vi.mock('./colorlip.js', () => ({
|
||||
getPalette: vi.fn(),
|
||||
}))
|
||||
vi.mock('./extract.js', () => ({ extractSwatches: vi.fn() }))
|
||||
|
||||
const makeWebPiece = (overrides?: Partial<WebPieces>): WebPieces => ({
|
||||
id: '1',
|
||||
afterEach(() => vi.resetAllMocks())
|
||||
|
||||
const webPiece = {
|
||||
type: 'books',
|
||||
date_updated: 100,
|
||||
date_added: 50,
|
||||
json_metadata: JSON.stringify({ cover: 'cover.jpg' }),
|
||||
file_path: 'book.md',
|
||||
key: 'key',
|
||||
slug: 'my-book',
|
||||
title: 'My Book',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const makeConfig = (overrides?: Partial<Config>): Config =>
|
||||
({
|
||||
url: { app: 'http://localhost' },
|
||||
pieces: [{ type: 'books', fields: { media: ['cover'] } }],
|
||||
...overrides,
|
||||
}) as unknown as Config
|
||||
|
||||
const makePieces = (buffer: Buffer = Buffer.from('img')) =>
|
||||
json_metadata: JSON.stringify({ poster: 'missing-key', cover: 'cover-key' }),
|
||||
} as WebPieces
|
||||
const assetMap = new Map([['cover-key', 'assets/cover.jpg']])
|
||||
const config = (media = ['poster', 'cover']) =>
|
||||
({ pieces: [{ type: 'books', fields: { media } }] }) as unknown as Config
|
||||
const pieces = (buffer = Buffer.from('image')) =>
|
||||
({ getPieceAsset: vi.fn().mockResolvedValue(buffer) }) as unknown as Pieces
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
test('uses the first resolved media image and stores its palette as embedded JSON', async () => {
|
||||
const image = Buffer.from('image-bytes')
|
||||
const archive = pieces(image)
|
||||
vi.mocked(extractSwatches).mockResolvedValue([
|
||||
{ r: 184, g: 89, b: 76, hex: '#b8594c', population: 1 },
|
||||
])
|
||||
|
||||
const assetMap = new Map([['cover.jpg', 'assets/cover.jpg']])
|
||||
|
||||
describe('transforms/palette', () => {
|
||||
test('reads asset from pieces and returns embedded palette JSON', async () => {
|
||||
const buffer = Buffer.from('image-bytes')
|
||||
const pieces = makePieces(buffer)
|
||||
const palette = { accent: '#ff0000', background: '#ffffff' }
|
||||
vi.mocked(getPalette).mockResolvedValue(palette)
|
||||
|
||||
const records = await run({
|
||||
webPiece: makeWebPiece(),
|
||||
config: makeConfig(),
|
||||
outDir: '/out',
|
||||
pieces,
|
||||
assetKeyToPath: assetMap,
|
||||
logger: makeLogger(),
|
||||
})
|
||||
|
||||
expect(pieces.getPieceAsset).toHaveBeenCalledWith('assets/cover.jpg')
|
||||
expect(getPalette).toHaveBeenCalledWith(buffer)
|
||||
expect(records).toEqual([
|
||||
expect.objectContaining({
|
||||
transformation: 'palette',
|
||||
content: JSON.stringify(palette),
|
||||
mime_type: 'application/json',
|
||||
is_embedded: 1,
|
||||
}),
|
||||
])
|
||||
const records = await run({
|
||||
webPiece,
|
||||
config: config(),
|
||||
outDir: '/out',
|
||||
pieces: archive,
|
||||
assetKeyToPath: assetMap,
|
||||
logger: makeLogger(),
|
||||
})
|
||||
|
||||
test('returns empty when piece type has no media fields', async () => {
|
||||
const pieces = makePieces()
|
||||
const configNoMedia = makeConfig({ pieces: [{ type: 'books', fields: {} }] } as unknown as Partial<Config>)
|
||||
|
||||
const records = await run({
|
||||
webPiece: makeWebPiece(),
|
||||
config: configNoMedia,
|
||||
outDir: '/out',
|
||||
pieces,
|
||||
assetKeyToPath: assetMap,
|
||||
logger: makeLogger(),
|
||||
})
|
||||
|
||||
expect(pieces.getPieceAsset).not.toHaveBeenCalled()
|
||||
expect(getPalette).not.toHaveBeenCalled()
|
||||
expect(records).toEqual([])
|
||||
})
|
||||
|
||||
test('returns empty when piece type not found in config', async () => {
|
||||
const pieces = makePieces()
|
||||
const configOther = makeConfig({ pieces: [{ type: 'other', fields: { media: ['cover'] } }] } as unknown as Partial<Config>)
|
||||
|
||||
const records = await run({
|
||||
webPiece: makeWebPiece(),
|
||||
config: configOther,
|
||||
outDir: '/out',
|
||||
pieces,
|
||||
assetKeyToPath: assetMap,
|
||||
logger: makeLogger(),
|
||||
})
|
||||
|
||||
expect(pieces.getPieceAsset).not.toHaveBeenCalled()
|
||||
expect(records).toEqual([])
|
||||
})
|
||||
|
||||
test('returns empty when piece has no media values in frontmatter', async () => {
|
||||
const pieces = makePieces()
|
||||
const webPiece = makeWebPiece({ json_metadata: '{}' })
|
||||
|
||||
const records = await run({
|
||||
webPiece,
|
||||
config: makeConfig(),
|
||||
outDir: '/out',
|
||||
pieces,
|
||||
assetKeyToPath: assetMap,
|
||||
logger: makeLogger(),
|
||||
})
|
||||
|
||||
expect(pieces.getPieceAsset).not.toHaveBeenCalled()
|
||||
expect(records).toEqual([])
|
||||
})
|
||||
|
||||
test('returns empty when media values do not resolve to assets', async () => {
|
||||
const pieces = makePieces()
|
||||
const webPiece = makeWebPiece({ json_metadata: JSON.stringify({ cover: 'unknown.jpg' }) })
|
||||
|
||||
const records = await run({
|
||||
webPiece,
|
||||
config: makeConfig(),
|
||||
outDir: '/out',
|
||||
pieces,
|
||||
assetKeyToPath: assetMap,
|
||||
logger: makeLogger(),
|
||||
})
|
||||
|
||||
expect(pieces.getPieceAsset).not.toHaveBeenCalled()
|
||||
expect(records).toEqual([])
|
||||
})
|
||||
|
||||
test('falls through to second media field when first has no resolved asset', async () => {
|
||||
const pieces = makePieces()
|
||||
const palette = { accent: '#000' }
|
||||
vi.mocked(getPalette).mockResolvedValue(palette)
|
||||
|
||||
const config = makeConfig({
|
||||
pieces: [{ type: 'books', fields: { media: ['poster', 'cover'] } }],
|
||||
} as unknown as Partial<Config>)
|
||||
|
||||
const records = await run({
|
||||
webPiece: makeWebPiece(),
|
||||
config,
|
||||
outDir: '/out',
|
||||
pieces,
|
||||
assetKeyToPath: assetMap,
|
||||
logger: makeLogger(),
|
||||
})
|
||||
|
||||
expect(pieces.getPieceAsset).toHaveBeenCalledWith('assets/cover.jpg')
|
||||
expect(records).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('throws when getPalette throws', async () => {
|
||||
const pieces = makePieces()
|
||||
vi.mocked(getPalette).mockRejectedValue(new Error('palette failed'))
|
||||
|
||||
await expect(
|
||||
run({
|
||||
webPiece: makeWebPiece(),
|
||||
config: makeConfig(),
|
||||
outDir: '/out',
|
||||
pieces,
|
||||
assetKeyToPath: assetMap,
|
||||
logger: makeLogger(),
|
||||
})
|
||||
).rejects.toThrow('palette failed')
|
||||
expect(archive.getPieceAsset).toHaveBeenCalledWith('assets/cover.jpg')
|
||||
expect(extractSwatches).toHaveBeenCalledWith(image)
|
||||
expect(records).toEqual([
|
||||
expect.objectContaining({
|
||||
transformation: 'palette',
|
||||
mime_type: 'application/json',
|
||||
is_embedded: 1,
|
||||
}),
|
||||
])
|
||||
expect(JSON.parse(records[0].content!)).toMatchObject({
|
||||
background: '#b8594c',
|
||||
swatches: ['#b8594c'],
|
||||
})
|
||||
})
|
||||
|
||||
test('does not read an image for a piece without configured media', async () => {
|
||||
const archive = pieces()
|
||||
|
||||
const records = await run({
|
||||
webPiece,
|
||||
config: config([]),
|
||||
outDir: '/out',
|
||||
pieces: archive,
|
||||
assetKeyToPath: assetMap,
|
||||
logger: makeLogger(),
|
||||
})
|
||||
|
||||
expect(records).toEqual([])
|
||||
expect(archive.getPieceAsset).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('does not read an image when no media references resolve to registered assets', async () => {
|
||||
const archive = pieces()
|
||||
|
||||
const records = await run({
|
||||
webPiece,
|
||||
config: config(),
|
||||
outDir: '/out',
|
||||
pieces: archive,
|
||||
assetKeyToPath: new Map(),
|
||||
logger: makeLogger(),
|
||||
})
|
||||
|
||||
expect(records).toEqual([])
|
||||
expect(archive.getPieceAsset).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('propagates extraction failures to the workflow', async () => {
|
||||
vi.mocked(extractSwatches).mockRejectedValue(new Error('invalid image'))
|
||||
|
||||
await expect(
|
||||
run({
|
||||
webPiece,
|
||||
config: config(),
|
||||
outDir: '/out',
|
||||
pieces: pieces(),
|
||||
assetKeyToPath: assetMap,
|
||||
logger: makeLogger(),
|
||||
})
|
||||
).rejects.toThrow('invalid image')
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getFrontmatterValues } from '@luzzle/core'
|
||||
import { getPalette } from './colorlip.js'
|
||||
import { extractSwatches } from './extract.js'
|
||||
import { createPalette } from './palette.js'
|
||||
import type { TransformInput, AssetRecord } from '../utils/types.js'
|
||||
|
||||
export async function run({
|
||||
@@ -29,7 +30,8 @@ export async function run({
|
||||
if (!assetPath) return []
|
||||
|
||||
const buffer = await pieces.getPieceAsset(assetPath)
|
||||
const palette = await getPalette(buffer)
|
||||
const swatches = await extractSwatches(buffer)
|
||||
const palette = createPalette(swatches)
|
||||
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { expect, test } from 'vitest'
|
||||
import type { Swatch } from './extract.js'
|
||||
import { createPalette } from './palette.js'
|
||||
|
||||
function swatch(hex: string, population: number): Swatch {
|
||||
const [r, g, b] = hex.match(/[0-9a-f]{2}/gi)!.map((channel) => parseInt(channel, 16))
|
||||
return { r, g, b, hex, population }
|
||||
}
|
||||
|
||||
test('uses the most prominent colorful swatch for the background and image colors for other roles', () => {
|
||||
const cream = swatch('#dcd6cc', 600)
|
||||
const blue = swatch('#648298', 250)
|
||||
const red = swatch('#b8594c', 150)
|
||||
|
||||
const palette = createPalette([red, cream, blue])
|
||||
|
||||
expect(palette.background).toBe(blue.hex)
|
||||
expect(palette.accent).toBe(red.hex)
|
||||
expect(palette.muted).toBe(cream.hex)
|
||||
expect(palette.dominant).toBe(cream.hex)
|
||||
})
|
||||
|
||||
test('uses the dominant tone when the image is monochrome', () => {
|
||||
const gray = swatch('#787878', 7)
|
||||
|
||||
const palette = createPalette([swatch('#282828', 2), gray])
|
||||
|
||||
expect(palette.background).toBe(gray.hex)
|
||||
})
|
||||
|
||||
test('chooses accessible text against the softened background rather than the source swatch', () => {
|
||||
const palette = createPalette([swatch('#009b00', 1)])
|
||||
|
||||
expect(palette.background).toBe('#1b801b')
|
||||
expect(palette.bodyText).toBe('#ffffff')
|
||||
expect(palette.titleText).toBe(palette.bodyText)
|
||||
})
|
||||
|
||||
test('leaves presentation colors unset when no swatches are available', () => {
|
||||
expect(createPalette([])).toEqual({ swatches: [] })
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Swatch } from './extract.js'
|
||||
import { colorfulness, getBestTextColor, rgbToHex, softenBackground } from './utils.js'
|
||||
|
||||
export interface Palette {
|
||||
background?: string
|
||||
bodyText?: string
|
||||
titleText?: string
|
||||
accent?: string
|
||||
muted?: string
|
||||
dominant?: string
|
||||
swatches: string[]
|
||||
}
|
||||
|
||||
const MIN_BACKGROUND_COLORFULNESS = 0.1
|
||||
|
||||
export function createPalette(swatches: readonly Swatch[]): Palette {
|
||||
const colors = [...swatches].sort((a, b) => b.population - a.population)
|
||||
const dominant = colors[0]
|
||||
if (!dominant) return { swatches: [] }
|
||||
|
||||
const selected =
|
||||
colors.find((color) => colorfulness(color) >= MIN_BACKGROUND_COLORFULNESS) ?? dominant
|
||||
const accent =
|
||||
colors
|
||||
.filter((color) => color !== selected)
|
||||
.sort((a, b) => colorfulness(b) - colorfulness(a))[0] ?? selected
|
||||
const muted = [...colors].sort((a, b) => colorfulness(a) - colorfulness(b))[0]
|
||||
const background = softenBackground(selected.r, selected.g, selected.b)
|
||||
const text = getBestTextColor(background.r, background.g, background.b)
|
||||
|
||||
return {
|
||||
background: rgbToHex(background.r, background.g, background.b),
|
||||
bodyText: text,
|
||||
titleText: text,
|
||||
accent: accent.hex,
|
||||
muted: muted.hex,
|
||||
dominant: dominant.hex,
|
||||
swatches: colors.map((color) => color.hex),
|
||||
}
|
||||
}
|
||||
@@ -1,82 +1,15 @@
|
||||
import { describe, test, expect } from 'vitest'
|
||||
import {
|
||||
rgbToHsl,
|
||||
hslToRgb,
|
||||
rgbToHex,
|
||||
getRelativeLuminance,
|
||||
getContrastRatio,
|
||||
getBestTextColor,
|
||||
softenBackground
|
||||
} from './utils.js'
|
||||
import { expect, test } from 'vitest'
|
||||
import { getRelativeLuminance, getContrastRatio, getBestTextColor } from './utils.js'
|
||||
|
||||
describe('transforms/palette/utils', () => {
|
||||
test('rgbToHsl and hslToRgb color space conversions', () => {
|
||||
// Pure Red
|
||||
const hslRed = rgbToHsl(255, 0, 0)
|
||||
expect(hslRed.h).toBeCloseTo(0, 5)
|
||||
expect(hslRed.s).toBeCloseTo(1, 5)
|
||||
expect(hslRed.l).toBeCloseTo(0.5, 5)
|
||||
test.each([
|
||||
{ rgb: [20, 20, 20], text: '#ffffff' },
|
||||
{ rgb: [240, 240, 240], text: '#000000' },
|
||||
{ rgb: [119, 119, 119], text: '#000000' },
|
||||
])('chooses higher-contrast text for $rgb', ({ rgb: [r, g, b], text }) => {
|
||||
const selected = getBestTextColor(r, g, b)
|
||||
|
||||
const rgbRed = hslToRgb(hslRed.h, hslRed.s, hslRed.l)
|
||||
expect(rgbRed.r).toBe(255)
|
||||
expect(rgbRed.g).toBe(0)
|
||||
expect(rgbRed.b).toBe(0)
|
||||
|
||||
// White
|
||||
const hslWhite = rgbToHsl(255, 255, 255)
|
||||
expect(hslWhite.l).toBeCloseTo(1.0, 5)
|
||||
|
||||
const rgbWhite = hslToRgb(hslWhite.h, hslWhite.s, hslWhite.l)
|
||||
expect(rgbWhite.r).toBe(255)
|
||||
expect(rgbWhite.g).toBe(255)
|
||||
expect(rgbWhite.b).toBe(255)
|
||||
})
|
||||
|
||||
test('rgbToHex conversion', () => {
|
||||
expect(rgbToHex(255, 0, 0)).toBe('#ff0000')
|
||||
expect(rgbToHex(0, 255, 0)).toBe('#00ff00')
|
||||
expect(rgbToHex(0, 0, 255)).toBe('#0000ff')
|
||||
expect(rgbToHex(255, 255, 255)).toBe('#ffffff')
|
||||
})
|
||||
|
||||
test('getRelativeLuminance', () => {
|
||||
expect(getRelativeLuminance(0, 0, 0)).toBe(0.0)
|
||||
expect(getRelativeLuminance(255, 255, 255)).toBe(1.0)
|
||||
})
|
||||
|
||||
test('getContrastRatio', () => {
|
||||
expect(getContrastRatio(1.0, 0.0)).toBe(21) // White vs Black
|
||||
expect(getContrastRatio(1.0, 1.0)).toBe(1.0) // Same color
|
||||
})
|
||||
|
||||
test('getBestTextColor chooses highest contrast color', () => {
|
||||
// Dark background should choose white text
|
||||
expect(getBestTextColor(20, 20, 20)).toBe('#ffffff')
|
||||
|
||||
// Light background should choose black text
|
||||
expect(getBestTextColor(240, 240, 240)).toBe('#000000')
|
||||
|
||||
// Borderline dark/colored backgrounds that previously got black text should now get white text
|
||||
expect(getBestTextColor(63, 132, 63)).toBe('#ffffff') // #3f843f (dark green)
|
||||
expect(getBestTextColor(73, 126, 152)).toBe('#ffffff') // #497e98 (medium slate blue)
|
||||
})
|
||||
|
||||
test('softenBackground caps saturation and clamps lightness', () => {
|
||||
// Pure bright red (100% Saturation, 50% Lightness)
|
||||
const softenedRed = softenBackground(255, 0, 0)
|
||||
const hslSoftened = rgbToHsl(softenedRed.r, softenedRed.g, softenedRed.b)
|
||||
|
||||
// Saturation should be capped at 35%
|
||||
expect(hslSoftened.s).toBeCloseTo(0.35, 1)
|
||||
|
||||
// Lightness of blinding white (100%) should be capped at 90%
|
||||
const softenedWhite = softenBackground(255, 255, 255)
|
||||
const hslSoftenedWhite = rgbToHsl(softenedWhite.r, softenedWhite.g, softenedWhite.b)
|
||||
expect(hslSoftenedWhite.l).toBeLessThanOrEqual(0.91)
|
||||
|
||||
// Lightness of pitch black (0%) should be clamped to at least 12%
|
||||
const softenedBlack = softenBackground(0, 0, 0)
|
||||
const hslSoftenedBlack = rgbToHsl(softenedBlack.r, softenedBlack.g, softenedBlack.b)
|
||||
expect(hslSoftenedBlack.l).toBeGreaterThanOrEqual(0.11)
|
||||
})
|
||||
expect(selected).toBe(text)
|
||||
expect(
|
||||
getContrastRatio(getRelativeLuminance(r, g, b), selected === '#ffffff' ? 1 : 0)
|
||||
).toBeGreaterThanOrEqual(4.5)
|
||||
})
|
||||
@@ -1,9 +1,15 @@
|
||||
/**
|
||||
* Convert RGB color space to HSL.
|
||||
* @param r Red channel (0-255)
|
||||
* @param g Green channel (0-255)
|
||||
* @param b Blue channel (0-255)
|
||||
*/
|
||||
export interface Rgb {
|
||||
r: number
|
||||
g: number
|
||||
b: number
|
||||
}
|
||||
|
||||
/** RGB chroma: zero for neutral tones, one for fully vivid colors. */
|
||||
export function colorfulness({ r, g, b }: Rgb): number {
|
||||
return (Math.max(r, g, b) - Math.min(r, g, b)) / 255
|
||||
}
|
||||
|
||||
/** RGB channels are 0–255; HSL components are normalized to 0–1. */
|
||||
export function rgbToHsl(r: number, g: number, b: number) {
|
||||
r /= 255
|
||||
g /= 255
|
||||
@@ -33,12 +39,6 @@ export function rgbToHsl(r: number, g: number, b: number) {
|
||||
return { h, s, l }
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert HSL color space back to RGB.
|
||||
* @param h Hue (0-1)
|
||||
* @param s Saturation (0-1)
|
||||
* @param l Lightness (0-1)
|
||||
*/
|
||||
export function hslToRgb(h: number, s: number, l: number) {
|
||||
let r, g, b
|
||||
if (s === 0) {
|
||||
@@ -61,55 +61,42 @@ export function hslToRgb(h: number, s: number, l: number) {
|
||||
return {
|
||||
r: Math.round(r * 255),
|
||||
g: Math.round(g * 255),
|
||||
b: Math.round(b * 255)
|
||||
b: Math.round(b * 255),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert RGB channels to a hexadecimal color string.
|
||||
*/
|
||||
export function rgbToHex(r: number, g: number, b: number): string {
|
||||
const toHex = (c: number) => c.toString(16).padStart(2, '0')
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate WCAG 2.0 relative luminance for an RGB color.
|
||||
*/
|
||||
/** WCAG relative luminance of an sRGB color. */
|
||||
export function getRelativeLuminance(r: number, g: number, b: number): number {
|
||||
const a = [r, g, b].map((v) => {
|
||||
v /= 255
|
||||
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4)
|
||||
return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4)
|
||||
})
|
||||
return a[0] * 0.2126 + a[1] * 0.7152 + a[2] * 0.0722
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate contrast ratio between two relative luminances.
|
||||
*/
|
||||
export function getContrastRatio(l1: number, l2: number): number {
|
||||
const brightest = Math.max(l1, l2)
|
||||
const darkest = Math.min(l1, l2)
|
||||
return (brightest + 0.05) / (darkest + 0.05)
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the best readable text color (#ffffff or #000000) for a given background.
|
||||
*/
|
||||
export function getBestTextColor(r: number, g: number, b: number): string {
|
||||
const yiq = (r * 299 + g * 587 + b * 114) / 1000
|
||||
return yiq >= 128 ? '#000000' : '#ffffff'
|
||||
const luminance = getRelativeLuminance(r, g, b)
|
||||
const blackContrast = getContrastRatio(luminance, 0)
|
||||
const whiteContrast = getContrastRatio(luminance, 1)
|
||||
return blackContrast >= whiteContrast ? '#000000' : '#ffffff'
|
||||
}
|
||||
|
||||
/**
|
||||
* Refine a background color by softening saturation and bounding lightness.
|
||||
*/
|
||||
export function softenBackground(r: number, g: number, b: number) {
|
||||
const { h, s, l } = rgbToHsl(r, g, b)
|
||||
// Cap saturation at 35% for eye-friendly, pastel shades
|
||||
const softenedS = Math.min(s, 0.35)
|
||||
// Clamp lightness between 12% and 90% to avoid pure white/black strain
|
||||
const softenedL = Math.max(0.12, Math.min(l, 0.90))
|
||||
// Preserve the image's color without using fully saturated UI backgrounds.
|
||||
const softenedS = Math.min(s, 0.65)
|
||||
const softenedL = Math.max(0.12, Math.min(l, 0.9))
|
||||
|
||||
return hslToRgb(h, softenedS, softenedL)
|
||||
}
|
||||
Generated
-15
@@ -798,9 +798,6 @@ importers:
|
||||
'@takumi-rs/core':
|
||||
specifier: 1.5.0
|
||||
version: 1.5.0
|
||||
colorlip:
|
||||
specifier: ^1.0.0
|
||||
version: 1.0.0(sharp@0.33.5)
|
||||
devlop:
|
||||
specifier: 1.1.0
|
||||
version: 1.1.0
|
||||
@@ -2502,14 +2499,6 @@ packages:
|
||||
colorette@2.0.20:
|
||||
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
|
||||
|
||||
colorlip@1.0.0:
|
||||
resolution: {integrity: sha512-mr3ghFyAdp3s4JGE108orDguvVsbQ7ukIjJs6z9YR9SDOx73YRjuToVV9uaeY89KSd2LVk5G7CF2T564zQFJhQ==}
|
||||
peerDependencies:
|
||||
sharp: '>=0.33.0'
|
||||
peerDependenciesMeta:
|
||||
sharp:
|
||||
optional: true
|
||||
|
||||
comma-separated-tokens@2.0.3:
|
||||
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
|
||||
|
||||
@@ -6027,10 +6016,6 @@ snapshots:
|
||||
|
||||
colorette@2.0.20: {}
|
||||
|
||||
colorlip@1.0.0(sharp@0.33.5):
|
||||
optionalDependencies:
|
||||
sharp: 0.33.5
|
||||
|
||||
comma-separated-tokens@2.0.3: {}
|
||||
|
||||
commander@8.3.0: {}
|
||||
|
||||
Reference in new issue
Block a user