feat(ui): add a gallery of procedural project icon styles to the components lab (#58058)

This commit is contained in:
Kit Langton
2026-10-22 00:53:02 -07:00
committed by GitHub
Unverified
parent 7a2644f26d
commit a898dfa2ca
4 changed files with 1972 additions and 5 deletions
@@ -23,7 +23,7 @@ const DEFAULTS = {
}
// Paper first, then four inks, as in the playground's art palettes. The mesh uses the four inks.
const PALETTES = [
export const PALETTES = [
["#f4efe6", "#1d3557", "#e63946", "#f1a208", "#2a9d8f"],
["#efe8d8", "#264653", "#e76f51", "#f4a261", "#e9c46a"],
["#f6f1eb", "#3d405b", "#e07a5f", "#81b29a", "#f2cc8f"],
@@ -140,7 +140,7 @@ export function drawProjectIcon(ctx: CanvasRenderingContext2D, px: number, icon:
}
// The playground's rounded tile: corner radius 22% of the size, antialiased over about one pixel.
function tileMask(u: number, v: number, px: number) {
export function tileMask(u: number, v: number, px: number) {
const pixel = 1 / px
const dx = Math.abs(u * 2 - 1) - 0.56
const dy = Math.abs(v * 2 - 1) - 0.56
@@ -161,7 +161,7 @@ function hash(x: number, y: number) {
return fract(f(px * py))
}
function valueNoise(x: number, y: number) {
export function valueNoise(x: number, y: number) {
const ix = Math.floor(x)
const iy = Math.floor(y)
const fx = x - ix
@@ -196,7 +196,7 @@ function normalize(x: number, y: number, z: number) {
return [x / length, y / length, z / length]
}
function cyrb53(input: string) {
export function cyrb53(input: string) {
let h1 = 0xdeadbeef
let h2 = 0x41c6ce57
@@ -212,7 +212,7 @@ function cyrb53(input: string) {
return (h1 ^ h2) >>> 0
}
function mulberry32(seed: number) {
export function mulberry32(seed: number) {
let a = seed
return () => {
@@ -0,0 +1,16 @@
import { describe, expect, test } from "bun:test"
import { PROJECT_ICON_STYLES } from "./procedural"
describe("procedural project icon styles", () => {
test.each(PROJECT_ICON_STYLES.map((style) => [style.id, style] as const))(
"%s resolves the same finite params from the same key",
(id, style) => {
const icon = style.generate("harbor-api")
expect(style.generate("harbor-api")).toEqual(icon)
expect(style.generate("basalt")).not.toEqual(icon)
expect(icon.style).toBe(id)
expect(Object.values(icon.params).every(Number.isFinite)).toBe(true)
},
)
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,372 @@
import { createEffect, For, on, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { PROJECT_ICON_STYLES, projectIconStyle, type ProjectIconStyle } from "./procedural"
import "./project-avatar.css"
const docs = `### Overview
Candidate procedural styles for the generated default project avatar, next to James's mesh. Each style resolves its
params once from a key, like the mesh, and draws on a 2D canvas clipped to the avatar's rounded tile.
### Gallery
- One row per style; columns are fictional project names at 32, 20 and 16 px, then a large preview.
- Click any icon to show it large at the top. Hover an icon to play its animation (off with reduced motion).
### Controls
- \`style\`: one style or all. \`name\`: a seed of your own, shown in the first column.
- \`size\`: the large preview. \`surface\`: dark or light ground. \`animate\`: hover animation.
`
const NAMES = ["atlas", "quill", "harbor-api", "nimbus", "copperleaf", "tern", "lumen-ui", "basalt"]
const SIZES = [32, 20, 16]
const SURFACES = {
dark: {
background: "#141414",
text: "#ededed",
muted: "#8a8a8a",
hairline: "rgba(255, 255, 255, 0.08)",
ring: "rgba(255, 255, 255, 0.85)",
},
light: {
background: "#fafafa",
text: "#171717",
muted: "#737373",
hairline: "rgba(0, 0, 0, 0.08)",
ring: "rgba(0, 0, 0, 0.8)",
},
}
type Args = { style: string; name: string; size: number; surface: string; animate: boolean }
export default {
title: "UI/ProjectIconStyles",
id: "ui-project-icon-styles",
parameters: {
layout: "fullscreen",
docs: {
description: {
component: docs,
},
},
},
argTypes: {
style: {
control: "select",
options: ["all", ...PROJECT_ICON_STYLES.map((style) => style.id)],
},
size: {
control: { type: "range", min: 64, max: 256, step: 8 },
},
surface: {
control: "select",
options: ["dark", "light"],
},
},
args: {
style: "all",
name: "",
size: 128,
surface: "dark",
animate: true,
},
}
export const Gallery = {
render: (args: Args) => <GalleryView args={args} />,
}
export const ContactSheet = {
render: (args: Args) => <ContactSheetView args={args} />,
}
function GalleryView(props: { args: Args }) {
const surface = () => (props.args.surface === "light" ? SURFACES.light : SURFACES.dark)
const names = () => (props.args.name ? [props.args.name, ...NAMES.slice(1)] : NAMES)
const styles = () =>
props.args.style === "all"
? PROJECT_ICON_STYLES
: PROJECT_ICON_STYLES.filter((style) => style.id === props.args.style)
const [selected, setSelected] = createStore({ style: "mesh", name: NAMES[0] })
const spotlight = () => projectIconStyle(selected.style)
createEffect(
on(
() => props.args.name,
(name) => name && setSelected("name", name),
{ defer: true },
),
)
createEffect(
on(
() => props.args.style,
(style) => style !== "all" && setSelected("style", style),
{ defer: true },
),
)
const isSelected = (style: ProjectIconStyle, name: string) => selected.style === style.id && selected.name === name
// Fixed columns: a label, one 32 + 20 + 16 px trio per name, then the large preview.
const row = () => ({
display: "grid",
"grid-template-columns": `200px repeat(${names().length}, 80px) 48px`,
"column-gap": "24px",
"align-items": "center",
})
const tile = (style: ProjectIconStyle, name: string, size: number) => (
<Tile
style={style}
name={name}
size={size}
animate={props.args.animate}
selected={isSelected(style, name)}
ring={surface().ring}
onSelect={() => setSelected({ style: style.id, name })}
/>
)
return (
<div
style={{
"min-height": "100%",
padding: "0 24px 24px",
background: surface().background,
color: surface().text,
"font-size": "13px",
"line-height": "16px",
}}
>
<div style={{ position: "sticky", top: "0", "z-index": 1, background: surface().background }}>
<div style={{ display: "flex", gap: "24px", "align-items": "center", padding: "24px 0" }}>
<Icon style={spotlight()} name={selected.name} size={props.args.size} animate={props.args.animate} />
<div style={{ display: "flex", "flex-direction": "column", gap: "4px" }}>
<div style={{ "font-weight": 500 }}>
{spotlight().name} · {selected.name}
</div>
<div style={{ color: surface().muted, "font-size": "12px" }}>{spotlight().label}</div>
<div style={{ display: "flex", gap: "12px", "align-items": "flex-end", "padding-top": "12px" }}>
<For each={SIZES}>
{(size) => <Icon style={spotlight()} name={selected.name} size={size} animate={props.args.animate} />}
</For>
</div>
</div>
</div>
<div style={{ ...row(), color: surface().muted, "font-size": "12px", "padding-bottom": "8px" }}>
<div />
<For each={names()}>
{(name) => (
<div style={{ "white-space": "nowrap", overflow: "hidden", "text-overflow": "ellipsis" }}>{name}</div>
)}
</For>
</div>
</div>
<For each={styles()}>
{(style) => (
<div style={{ ...row(), padding: "12px 0", "border-top": `1px solid ${surface().hairline}` }}>
<button
type="button"
onClick={() => setSelected("style", style.id)}
style={{ ...reset, padding: "0", "text-align": "start" }}
>
<div style={{ "font-weight": 500 }}>{style.name}</div>
<div style={{ color: surface().muted, "font-size": "12px", "padding-top": "2px" }}>{style.label}</div>
</button>
<For each={names()}>
{(name) => (
<div style={{ display: "flex", gap: "6px", "align-items": "flex-end" }}>
<For each={SIZES}>{(size) => tile(style, name, size)}</For>
</div>
)}
</For>
{tile(style, selected.name, 48)}
</div>
)}
</For>
</div>
)
}
// Every style × six names at 32, 20 and 16 px, in two columns: the proof still for review.
function ContactSheetView(props: { args: Args }) {
const surface = () => (props.args.surface === "light" ? SURFACES.light : SURFACES.dark)
const names = NAMES.slice(0, 6)
const half = Math.ceil(PROJECT_ICON_STYLES.length / 2)
const columns = [PROJECT_ICON_STYLES.slice(0, half), PROJECT_ICON_STYLES.slice(half)]
return (
<div
data-slot="contact-sheet"
style={{
display: "inline-flex",
gap: "48px",
padding: "24px",
background: surface().background,
color: surface().text,
"font-size": "12px",
"line-height": "16px",
}}
>
<For each={columns}>
{(column) => (
<div style={{ display: "flex", "flex-direction": "column", gap: "10px" }}>
<For each={column}>
{(style) => (
<div style={{ display: "flex", "align-items": "flex-end", gap: "20px" }}>
<div style={{ width: "88px", "align-self": "center", "white-space": "nowrap" }}>{style.name}</div>
<For each={SIZES}>
{(size) => (
<div style={{ display: "flex", gap: "8px", "align-items": "flex-end" }}>
<For each={names}>
{(name) => <Icon style={style} name={name} size={size} animate={props.args.animate} />}
</For>
</div>
)}
</For>
</div>
)}
</For>
</div>
)}
</For>
</div>
)
}
const reset = {
background: "none",
border: "0",
margin: "0",
color: "inherit",
font: "inherit",
cursor: "pointer",
}
function Tile(props: {
style: ProjectIconStyle
name: string
size: number
animate: boolean
selected: boolean
ring: string
onSelect: () => void
}) {
return (
<button
type="button"
title={`${props.style.name} · ${props.name}`}
onClick={props.onSelect}
style={{
...reset,
padding: "0",
display: "block",
"border-radius": `${Math.max(4, props.size * 0.22 + 2)}px`,
outline: props.selected ? `1.5px solid ${props.ring}` : "none",
"outline-offset": "2px",
}}
>
<Icon style={props.style} name={props.name} size={props.size} animate={props.animate} />
</button>
)
}
// The avatar's own markup and styles, so each style is clipped exactly as ProjectAvatar clips the mesh.
function Icon(props: { style: ProjectIconStyle; name: string; size: number; animate: boolean }) {
let canvas!: HTMLCanvasElement
onMount(() => {
const icon = () => props.style.generate(props.name)
let frame = 0
let time = 0
let hovering = false
let last = 0
const paint = (seconds: number) => {
const px = Math.max(1, Math.round(props.size * (window.devicePixelRatio || 1)))
if (canvas.width !== px) canvas.width = canvas.height = px
const ctx = canvas.getContext("2d")
if (!ctx) return
if (seconds > 0) return props.style.draw(ctx, px, icon(), seconds)
ctx.clearRect(0, 0, px, px)
ctx.drawImage(still(props.style, props.name, px), 0, 0)
}
// As in the app's avatars: play while hovered, then ease back to the static frame and stop.
const tick = (now: number) => {
const seconds = Math.min((now - last) / 1000, 0.1)
last = now
time = hovering ? time + seconds : time * Math.pow(0.002, seconds)
if (!hovering && time < 0.01) {
time = 0
frame = 0
paint(0)
return
}
paint(time)
frame = requestAnimationFrame(tick)
}
const enter = () => {
if (!props.animate || !props.style.animated || matchMedia("(prefers-reduced-motion: reduce)").matches) return
hovering = true
if (frame) return
last = performance.now()
frame = requestAnimationFrame(tick)
}
const leave = () => (hovering = false)
const target = canvas.closest("button") ?? canvas
target.addEventListener("pointerenter", enter)
target.addEventListener("pointerleave", leave)
onCleanup(() => {
cancelAnimationFrame(frame)
target.removeEventListener("pointerenter", enter)
target.removeEventListener("pointerleave", leave)
})
createEffect(() => {
props.style
props.name
props.size
if (!frame) paint(0)
})
})
return (
<div data-component="project-avatar-v2" style={{ width: `${props.size}px`, height: `${props.size}px` }}>
<div data-slot="project-avatar-surface" data-generated="">
<canvas ref={canvas} data-slot="project-avatar-generated" aria-hidden="true" />
</div>
</div>
)
}
const stills = new Map<string, HTMLCanvasElement>()
function still(style: ProjectIconStyle, name: string, px: number) {
const key = `${style.id}|${name}|${px}`
const cached = stills.get(key)
if (cached) return cached
if (stills.size > 2048) stills.clear()
const canvas = document.createElement("canvas")
canvas.width = canvas.height = px
const ctx = canvas.getContext("2d")
if (ctx) style.draw(ctx, px, style.generate(name), 0)
stills.set(key, canvas)
return canvas
}