// Rasterizes a workspace icon (emoji or Tabler glyph, over an optional gradient
// background) into a PNG Blob, ready to PUT to `/api/workspaces/:id/icon` — the
// server normalizes it to a 128×128 WebP. Keeping everything in the existing
// image pipeline means the sidebar and header keep rendering icons as a plain
// `
`, with no schema change for emoji/glyph modes.
// A gradient background: a CSS-style angle (0deg = up, clockwise) and 2–3 hex
// stops, ordered light → dark. Rendered identically by the DOM preview
// (`gradientCss`) and the canvas rasterizer (`paintGradient`), both of which
// finish with a soft radial sheen for depth.
export type IconGradient = {
angle: number
stops: string[]
}
// Hand-tuned presets shown as swatches. `randomGradient()` below generates
// endless extras in the same family.
export const GRADIENT_PRESETS: { id: string; gradient: IconGradient }[] = [
{ id: 'sunrise', gradient: { angle: 140, stops: ['#FFE29F', '#FFA99F', '#FF719A'] } },
{ id: 'ocean', gradient: { angle: 135, stops: ['#67E8F9', '#3B82F6', '#4F46E5'] } },
{ id: 'meadow', gradient: { angle: 135, stops: ['#A7F3D0', '#34D399', '#059669'] } },
{ id: 'dusk', gradient: { angle: 120, stops: ['#C471F5', '#FA71CD'] } },
{ id: 'midnight', gradient: { angle: 135, stops: ['#30CFD0', '#330867'] } }
]
// OKLCH → sRGB hex. Generating in OKLCH keeps random gradients perceptually
// even — equal lightness/chroma reads equally vivid at any hue, which is what
// makes the shuffle output feel curated rather than arbitrary.
function oklchToHex(l: number, c: number, h: number): string {
const hr = (h * Math.PI) / 180
const a = c * Math.cos(hr)
const b = c * Math.sin(hr)
const l_ = (l + 0.3963377774 * a + 0.2158037573 * b) ** 3
const m_ = (l - 0.1055613458 * a - 0.0638541728 * b) ** 3
const s_ = (l - 0.0894841775 * a - 1.291485548 * b) ** 3
const channels = [
4.0767416621 * l_ - 3.3077115913 * m_ + 0.2309699292 * s_,
-1.2684380046 * l_ + 2.6097574011 * m_ - 0.3413193965 * s_,
-0.0041960863 * l_ - 0.7034186147 * m_ + 1.707614701 * s_
]
return `#${channels
.map(x => {
const clamped = Math.min(1, Math.max(0, x))
const srgb = clamped <= 0.0031308 ? 12.92 * clamped : 1.055 * clamped ** (1 / 2.4) - 0.055
return Math.round(srgb * 255)
.toString(16)
.padStart(2, '0')
})
.join('')}`
}
// Generate a fresh gradient: random hue, a 35–90° hue swing toward the dark
// end, light→dark diagonal. A third mid-stop drops in ~40% of the time for
// richer blends.
export function randomGradient(): IconGradient {
const hue = Math.random() * 360
const swing = (35 + Math.random() * 55) * (Math.random() < 0.5 ? -1 : 1)
const angle = Math.round(115 + Math.random() * 55)
const light = oklchToHex(0.83 + Math.random() * 0.06, 0.09 + Math.random() * 0.06, hue)
const dark = oklchToHex(0.5 + Math.random() * 0.14, 0.17 + Math.random() * 0.07, hue + swing)
if (Math.random() < 0.4) {
const mid = oklchToHex(0.7 + Math.random() * 0.05, 0.15, hue + swing / 2)
return { angle, stops: [light, mid, dark] }
}
return { angle, stops: [light, dark] }
}
// Sheen shared by the DOM preview and the canvas: a soft white radial glow off
// the top-left, like light hitting a rounded app icon.
const SHEEN_CSS =
'radial-gradient(120% 100% at 28% 12%, rgba(255,255,255,0.32), rgba(255,255,255,0) 58%)'
// CSS `background` value for previewing a gradient in the DOM. Colors are
// runtime-generated (shuffle), so this goes through a style attribute — a
// static Tailwind class can't express it.
export function gradientCss(g: IconGradient): string {
return `${SHEEN_CSS}, linear-gradient(${g.angle}deg, ${g.stops.join(', ')})`
}
// Rendered at 2× the server's target so the downscale to 128 stays crisp.
const SIZE = 256
function paintGradient(ctx: CanvasRenderingContext2D, g: IconGradient) {
// CSS angle semantics: 0deg points up, rotating clockwise. The gradient line
// runs through the center, long enough to cover the square's projection.
const rad = (g.angle * Math.PI) / 180
const dx = Math.sin(rad)
const dy = -Math.cos(rad)
const half = (SIZE * (Math.abs(dx) + Math.abs(dy))) / 2
const c = SIZE / 2
const lin = ctx.createLinearGradient(c - dx * half, c - dy * half, c + dx * half, c + dy * half)
const last = g.stops.length - 1
g.stops.forEach((stop, i) => lin.addColorStop(last === 0 ? 0 : i / last, stop))
ctx.fillStyle = lin
ctx.fillRect(0, 0, SIZE, SIZE)
const sheen = ctx.createRadialGradient(
SIZE * 0.28,
SIZE * 0.12,
0,
SIZE * 0.28,
SIZE * 0.12,
SIZE * 1.1
)
sheen.addColorStop(0, 'rgba(255,255,255,0.32)')
sheen.addColorStop(0.58, 'rgba(255,255,255,0)')
ctx.fillStyle = sheen
ctx.fillRect(0, 0, SIZE, SIZE)
}
function toBlob(canvas: HTMLCanvasElement): Promise {
return new Promise((resolve, reject) => {
canvas.toBlob(
blob => (blob ? resolve(blob) : reject(new Error('Canvas is empty'))),
'image/png'
)
})
}
function newCanvas(): [HTMLCanvasElement, CanvasRenderingContext2D] {
const canvas = document.createElement('canvas')
canvas.width = SIZE
canvas.height = SIZE
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('2D canvas unavailable')
return [canvas, ctx]
}
// Emoji fill ratios: full-bleed on a transparent background (the emoji IS the
// icon), padded when sitting on a gradient tile.
const EMOJI_BARE = 0.92
const EMOJI_ON_GRADIENT = 0.68
// Rasterize a single emoji centered over the chosen background (null = none).
export async function renderEmojiIcon(emoji: string, bg: IconGradient | null): Promise {
const [canvas, ctx] = newCanvas()
if (bg) paintGradient(ctx, bg)
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
const scale = bg ? EMOJI_ON_GRADIENT : EMOJI_BARE
ctx.font = `${Math.round(SIZE * scale)}px "Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",sans-serif`
// Nudge down slightly — emoji glyphs sit high of the geometric center.
ctx.fillText(emoji, SIZE / 2, SIZE / 2 + SIZE * 0.04)
return toBlob(canvas)
}
function loadImage(src: string): Promise {
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => resolve(img)
img.onerror = () => reject(new Error('Failed to load SVG'))
img.src = src
})
}
// Glyph fill ratios, matching the emoji treatment.
const GLYPH_BARE = 0.78
const GLYPH_ON_GRADIENT = 0.6
// Rasterize a Tabler glyph (passed as serialized SVG markup) centered over the
// chosen background. The glyph is recolored white on a gradient, near-black when
// the background is transparent, so it reads in either case.
export async function renderGlyphIcon(svg: string, bg: IconGradient | null): Promise {
const [canvas, ctx] = newCanvas()
if (bg) paintGradient(ctx, bg)
const color = bg ? '#ffffff' : '#18181b'
const colored = svg.replace(/currentColor/g, color)
const img = await loadImage(`data:image/svg+xml;utf8,${encodeURIComponent(colored)}`)
const glyph = SIZE * (bg ? GLYPH_ON_GRADIENT : GLYPH_BARE)
const offset = (SIZE - glyph) / 2
ctx.drawImage(img, offset, offset, glyph, glyph)
return toBlob(canvas)
}