From 22a058ae3a42b3b1a0ad74d58bf4ae4658f33f5e Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Sat, 23 May 2026 15:03:43 -0700 Subject: [PATCH 1/5] fix(app): use static SVG avatars for dense icons --- .../design-system/extension-mesh-avatar.tsx | 46 +----- .../design-system/paper-avatar-svg.ts | 156 ++++++++++++++++++ .../design-system/workspace-icon.tsx | 65 +------- 3 files changed, 165 insertions(+), 102 deletions(-) create mode 100644 apps/app/src/react-app/design-system/paper-avatar-svg.ts diff --git a/apps/app/src/react-app/design-system/extension-mesh-avatar.tsx b/apps/app/src/react-app/design-system/extension-mesh-avatar.tsx index 783c80bab7..d1e0b7b704 100644 --- a/apps/app/src/react-app/design-system/extension-mesh-avatar.tsx +++ b/apps/app/src/react-app/design-system/extension-mesh-avatar.tsx @@ -1,35 +1,11 @@ /** @jsxImportSource react */ -import { StaticMeshGradient } from "@paper-design/shaders-react"; +import { getPaperAvatarStyle } from "./paper-avatar-svg"; type ExtensionMeshAvatarProps = { name: string; className?: string; }; -const palettes = [ - ["#e0eaff", "#241d9a", "#f75092", "#9f50d3"], - ["#a8f976", "#5cf5d9", "#8261fa", "#14e1bc"], - ["#ffe29f", "#ffa99f", "#ff719a", "#6c5ce7"], - ["#b8fff9", "#85f4ff", "#8b5cf6", "#111827"], -] as const; - -function paletteForName(name: string) { - let hash = 0; - for (let index = 0; index < name.length; index += 1) { - hash = (hash * 31 + name.charCodeAt(index)) % palettes.length; - } - return palettes[hash]; -} - -function fallbackBackground(colors: readonly string[]) { - return [ - `radial-gradient(circle at 20% 20%, ${colors[0]}, transparent 38%)`, - `radial-gradient(circle at 80% 12%, ${colors[1]}, transparent 42%)`, - `radial-gradient(circle at 52% 90%, ${colors[2]}, transparent 46%)`, - `linear-gradient(135deg, ${colors[0]}, ${colors[3]})`, - ].join(", "); -} - export function extensionMeshAvatarText(name: string) { const words = name.trim().split(/\s+/).filter(Boolean); const letters = words.length >= 2 @@ -39,29 +15,11 @@ export function extensionMeshAvatarText(name: string) { } export function ExtensionMeshAvatar({ name, className }: ExtensionMeshAvatarProps) { - const colors = paletteForName(name); - return (
-
{extensionMeshAvatarText(name)}
diff --git a/apps/app/src/react-app/design-system/paper-avatar-svg.ts b/apps/app/src/react-app/design-system/paper-avatar-svg.ts new file mode 100644 index 0000000000..43a51a4d80 --- /dev/null +++ b/apps/app/src/react-app/design-system/paper-avatar-svg.ts @@ -0,0 +1,156 @@ +export type PaperAvatarVariant = "extension" | "workspace"; + +type Palette = readonly [string, string, string, string, string]; + +type PaperAvatarStyle = { + backgroundColor: string; + backgroundImage: string; + backgroundPosition: string; + backgroundSize: string; +}; + +const extensionPalettes: readonly Palette[] = [ + ["#e0eaff", "#241d9a", "#f75092", "#9f50d3", "#5cf5d9"], + ["#a8f976", "#5cf5d9", "#8261fa", "#14e1bc", "#111827"], + ["#ffe29f", "#ffa99f", "#ff719a", "#6c5ce7", "#35d8c0"], + ["#b8fff9", "#85f4ff", "#8b5cf6", "#111827", "#f75092"], + ["#fff2cc", "#ff6b35", "#004e89", "#1a659e", "#ffb703"], + ["#f5d0fe", "#7c3aed", "#06b6d4", "#f43f5e", "#facc15"], +]; + +const workspacePalettes: readonly Palette[] = [ + ["#7c3cff", "#00e5ff", "#ff2f92", "#ffb000", "#12ff8f"], + ["#1227ff", "#00ffd5", "#ff5c00", "#fff200", "#9d4edd"], + ["#ff006e", "#3a86ff", "#8338ec", "#ffbe0b", "#06ffa5"], + ["#00c2ff", "#001aff", "#ff4ecd", "#b8ff2c", "#ff7a00"], + ["#14f195", "#9945ff", "#00d1ff", "#ff2d55", "#ffd60a"], + ["#ff3d00", "#ffd500", "#00f5d4", "#7209b7", "#f72585"], + ["#39ff14", "#00bbf9", "#fee440", "#9b5de5", "#f15bb5"], + ["#4cc9f0", "#4361ee", "#f72585", "#b5179e", "#80ffdb"], +]; + +const styleCache = new Map(); + +export function getPaperAvatarStyle(seed: string, variant: PaperAvatarVariant): PaperAvatarStyle { + const cacheKey = `${variant}:${seed.trim() || variant}`; + const cached = styleCache.get(cacheKey); + if (cached) return cached; + + const palette = paletteForSeed(seed, variant); + const style = { + backgroundColor: palette[0], + backgroundImage: svgToCssUrl(generatePaperAvatarSvg(seed, variant, palette)), + backgroundPosition: "center", + backgroundSize: "cover", + }; + + styleCache.set(cacheKey, style); + return style; +} + +function paletteForSeed(seed: string, variant: PaperAvatarVariant): Palette { + const palettes = variant === "workspace" ? workspacePalettes : extensionPalettes; + return palettes[hashSeed(seed, `${variant}:palette`) % palettes.length]; +} + +function generatePaperAvatarSvg(seed: string, variant: PaperAvatarVariant, palette: Palette): string { + const random = createRandom(seed, variant); + const blobCount = variant === "workspace" ? 6 : 5; + const blobGradients: string[] = []; + const blobLayers: string[] = []; + const colorPop = variant === "workspace" ? 1 : 0.86; + const grainOpacity = variant === "workspace" ? 0.52 : 0.42; + + for (let index = 0; index < blobCount; index += 1) { + const color = palette[(index + 1) % palette.length]; + const cx = round(-10 + random() * 116, 1); + const cy = round(-10 + random() * 116, 1); + const radius = round(42 + random() * 44, 1); + const innerOpacity = round((0.66 + random() * 0.3) * colorPop, 2); + const middleOpacity = round((0.28 + random() * 0.24) * colorPop, 2); + + blobGradients.push( + `` + + `` + + `` + + `` + + ``, + ); + blobLayers.push(``); + } + + const grainRotation = Math.round(random() * 180); + const highlightPath = buildRibbonPath(random, 10, 46); + const shadowPath = buildRibbonPath(random, 34, 74); + const strokePath = buildStrokePath(random); + + return ( + `` + + `` + + `` + + `` + + `` + + `` + + `` + + blobGradients.join("") + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + blobLayers.join("") + + `` + + `` + + `` + + `` + + `` + + `` + ); +} + +function buildRibbonPath(random: () => number, minY: number, maxY: number): string { + const startY = round(minY + random() * (maxY - minY), 1); + const midY = round(minY + random() * (maxY - minY), 1); + const endY = round(minY + random() * (maxY - minY), 1); + const tailY = round(96 + random() * 20, 1); + + return `M -14 ${startY} C ${round(14 + random() * 18, 1)} ${round(startY - 18 + random() * 36, 1)} ${round(44 + random() * 18, 1)} ${midY} 110 ${endY} L 110 ${tailY} L -14 ${tailY} Z`; +} + +function buildStrokePath(random: () => number): string { + const startY = round(18 + random() * 58, 1); + const endY = round(18 + random() * 58, 1); + + return `M -10 ${startY} C ${round(18 + random() * 22, 1)} ${round(random() * 96, 1)} ${round(54 + random() * 18, 1)} ${round(random() * 96, 1)} 106 ${endY}`; +} + +function svgToCssUrl(svg: string): string { + return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`; +} + +function createRandom(seed: string, salt: string) { + let state = hashSeed(seed, salt) || 1; + + return () => { + state = (state * 1664525 + 1013904223) >>> 0; + return state / 4294967296; + }; +} + +function hashSeed(seed: string, salt: string): number { + const value = `${salt}:${seed.trim() || salt}`; + let hash = 5381; + + for (let index = 0; index < value.length; index += 1) { + hash = ((hash << 5) + hash + value.charCodeAt(index)) | 0; + } + + return hash >>> 0; +} + +function round(value: number, decimals: number): number { + const multiplier = 10 ** decimals; + return Math.round(value * multiplier) / multiplier; +} diff --git a/apps/app/src/react-app/design-system/workspace-icon.tsx b/apps/app/src/react-app/design-system/workspace-icon.tsx index c86e584c73..b92bc0125e 100644 --- a/apps/app/src/react-app/design-system/workspace-icon.tsx +++ b/apps/app/src/react-app/design-system/workspace-icon.tsx @@ -1,6 +1,5 @@ /** @jsxImportSource react */ -import { useMemo } from "react"; -import { PaperGrainGradient } from "@openwork/ui/react"; +import { getPaperAvatarStyle } from "./paper-avatar-svg"; export type WorkspaceIconProps = { /** Workspace name used to seed the gradient. Changes when renamed. */ @@ -10,64 +9,14 @@ export type WorkspaceIconProps = { }; /** - * Deeper, more professional palette families. Each uses complementary - * tones with enough contrast to read at 16px but avoids the neon/playful - * look of pure saturated colors. - */ -const palettes = [ - ["#7c8cf5", "#e8789c", "#e4a853", "#5cb8c4"], // soft indigo + muted rose + warm gold + soft teal - ["#9b8afb", "#5aab8e", "#d98a54", "#d07eb5"], // soft violet + sage + copper + dusty pink - ["#5a9fd4", "#d4a44c", "#cc7070", "#6aad7a"], // soft blue + warm gold + dusty red + sage green - ["#c27dd8", "#5a9e93", "#d4914e", "#7c8cf5"], // soft purple + muted teal + copper + soft indigo - ["#d47580", "#6b8fd4", "#8aad5a", "#d4a44c"], // dusty rose + soft blue + olive + warm gold - ["#5cb8c4", "#b572c4", "#d4a44c", "#5aab8e"], // soft teal + muted purple + warm gold + sage - ["#9b8afb", "#d4914e", "#5cb8c4", "#d47580"], // soft violet + copper + soft teal + dusty rose - ["#c47082", "#d4a44c", "#6aad7a", "#7c8cf5"], // muted rose + warm gold + sage green + soft indigo - ["#5a9e93", "#d47580", "#9b8afb", "#d4a44c"], // muted teal + dusty rose + soft violet + warm gold - ["#6b8fd4", "#c47070", "#5aab8e", "#d4914e"], // soft blue + muted red + sage + copper -]; - -/** Shapes that produce the most visible structure at tiny sizes. */ -const shapes = ["corners", "ripple", "sphere", "blob"] as const; - -/** Simple deterministic hash (DJB2). */ -function hashSeed(input: string): number { - const value = input.trim() || "workspace"; - let hash = 5381; - for (let i = 0; i < value.length; i++) { - hash = ((hash << 5) + hash + value.charCodeAt(i)) | 0; - } - return Math.abs(hash); -} - -/** - * Renders a small rounded circle with a deterministic Paper grain gradient - * seeded by the workspace name. Renaming the workspace changes the gradient. - * Uses deeper, more professional color palettes. + * Renders a small rounded circle with a deterministic static SVG background. + * Renaming the workspace changes the generated paper-like gradient. */ export function WorkspaceIcon({ seed, sizeClass = "size-4" }: WorkspaceIconProps) { - const config = useMemo(() => { - const hash = hashSeed(seed); - return { - colors: palettes[hash % palettes.length], - shape: shapes[(hash >> 4) % shapes.length], - frame: ((hash * 7) % 200000) + 10000, - }; - }, [seed]); - return ( -
- -
+
); } From dcd461ad8325bcd000f0de745536f2c9db65a412 Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Sat, 23 May 2026 17:45:56 -0700 Subject: [PATCH 2/5] fix(app): soften generated icon texture --- .../design-system/paper-avatar-svg.ts | 136 +++++++++++------- 1 file changed, 87 insertions(+), 49 deletions(-) diff --git a/apps/app/src/react-app/design-system/paper-avatar-svg.ts b/apps/app/src/react-app/design-system/paper-avatar-svg.ts index 43a51a4d80..99359ad31b 100644 --- a/apps/app/src/react-app/design-system/paper-avatar-svg.ts +++ b/apps/app/src/react-app/design-system/paper-avatar-svg.ts @@ -10,23 +10,23 @@ type PaperAvatarStyle = { }; const extensionPalettes: readonly Palette[] = [ - ["#e0eaff", "#241d9a", "#f75092", "#9f50d3", "#5cf5d9"], - ["#a8f976", "#5cf5d9", "#8261fa", "#14e1bc", "#111827"], - ["#ffe29f", "#ffa99f", "#ff719a", "#6c5ce7", "#35d8c0"], - ["#b8fff9", "#85f4ff", "#8b5cf6", "#111827", "#f75092"], - ["#fff2cc", "#ff6b35", "#004e89", "#1a659e", "#ffb703"], - ["#f5d0fe", "#7c3aed", "#06b6d4", "#f43f5e", "#facc15"], + ["#edf2ff", "#2f2aa0", "#f56aa0", "#9f62d1", "#5eead4"], + ["#eaffd5", "#138a72", "#7c5cff", "#43e6bd", "#203063"], + ["#fff1c7", "#ee7b58", "#f25f92", "#6754d9", "#33c7b7"], + ["#dcfffb", "#22b8e6", "#805ad5", "#1d1f33", "#f66fb3"], + ["#fff3dd", "#d96b2b", "#1b6d9a", "#31a7c9", "#f8c64e"], + ["#f8ddff", "#7f4ce3", "#15b6d6", "#e45175", "#f4d35e"], ]; const workspacePalettes: readonly Palette[] = [ - ["#7c3cff", "#00e5ff", "#ff2f92", "#ffb000", "#12ff8f"], - ["#1227ff", "#00ffd5", "#ff5c00", "#fff200", "#9d4edd"], - ["#ff006e", "#3a86ff", "#8338ec", "#ffbe0b", "#06ffa5"], - ["#00c2ff", "#001aff", "#ff4ecd", "#b8ff2c", "#ff7a00"], - ["#14f195", "#9945ff", "#00d1ff", "#ff2d55", "#ffd60a"], - ["#ff3d00", "#ffd500", "#00f5d4", "#7209b7", "#f72585"], - ["#39ff14", "#00bbf9", "#fee440", "#9b5de5", "#f15bb5"], - ["#4cc9f0", "#4361ee", "#f72585", "#b5179e", "#80ffdb"], + ["#6d35ff", "#00d5ff", "#ff4fa3", "#ffbd35", "#35f2a5"], + ["#2347ff", "#20e6c8", "#ff7a3d", "#f7e34a", "#9d61ff"], + ["#f72585", "#3a86ff", "#7b4dff", "#ffbe3d", "#2ee6ad"], + ["#00b7ff", "#2735d9", "#eb5ed3", "#b8f24a", "#ff8a35"], + ["#21d995", "#9157ff", "#1ec9f0", "#ff4d6d", "#ffd166"], + ["#f05a28", "#ffd23f", "#21d9c3", "#6d35ba", "#e94d91"], + ["#78e44d", "#1cb8e8", "#f8dc54", "#9561d9", "#e862b6"], + ["#4cc9f0", "#4965e8", "#ef4f91", "#a23ab8", "#86f0d3"], ]; const styleCache = new Map(); @@ -55,75 +55,113 @@ function paletteForSeed(seed: string, variant: PaperAvatarVariant): Palette { function generatePaperAvatarSvg(seed: string, variant: PaperAvatarVariant, palette: Palette): string { const random = createRandom(seed, variant); - const blobCount = variant === "workspace" ? 6 : 5; + const blobCount = variant === "workspace" ? 8 : 7; const blobGradients: string[] = []; const blobLayers: string[] = []; - const colorPop = variant === "workspace" ? 1 : 0.86; - const grainOpacity = variant === "workspace" ? 0.52 : 0.42; + const grainOpacity = variant === "workspace" ? 0.58 : 0.46; for (let index = 0; index < blobCount; index += 1) { - const color = palette[(index + 1) % palette.length]; - const cx = round(-10 + random() * 116, 1); - const cy = round(-10 + random() * 116, 1); - const radius = round(42 + random() * 44, 1); - const innerOpacity = round((0.66 + random() * 0.3) * colorPop, 2); - const middleOpacity = round((0.28 + random() * 0.24) * colorPop, 2); + const color = palette[(index + (variant === "workspace" ? 2 : 1)) % palette.length]; + const cx = round(-18 + random() * 132, 1); + const cy = round(-18 + random() * 132, 1); + const radius = round(46 + random() * 48, 1); + const innerOpacity = round(0.48 + random() * 0.22, 2); + const middleOpacity = round(0.2 + random() * 0.16, 2); blobGradients.push( `` + `` + - `` + + `` + `` + ``, ); blobLayers.push(``); } - const grainRotation = Math.round(random() * 180); - const highlightPath = buildRibbonPath(random, 10, 46); - const shadowPath = buildRibbonPath(random, 34, 74); - const strokePath = buildStrokePath(random); + const streaks = buildSoftStreaks(random, variant); + const fibers = buildPaperFibers(random, variant); + const grain = buildPaperGrain(random, variant); return ( `` + `` + `` + `` + - `` + + `` + `` + `` + + `` + + `` + + `` + + `` + blobGradients.join("") + - `` + - `` + - `` + - `` + - `` + `` + `` + blobLayers.join("") + - `` + - `` + - `` + - `` + - `` + + streaks + + `` + + `` + fibers + grain + `` + + `` + `` ); } -function buildRibbonPath(random: () => number, minY: number, maxY: number): string { - const startY = round(minY + random() * (maxY - minY), 1); - const midY = round(minY + random() * (maxY - minY), 1); - const endY = round(minY + random() * (maxY - minY), 1); - const tailY = round(96 + random() * 20, 1); +function buildSoftStreaks(random: () => number, variant: PaperAvatarVariant): string { + const count = variant === "workspace" ? 3 : 2; + const streaks: string[] = []; - return `M -14 ${startY} C ${round(14 + random() * 18, 1)} ${round(startY - 18 + random() * 36, 1)} ${round(44 + random() * 18, 1)} ${midY} 110 ${endY} L 110 ${tailY} L -14 ${tailY} Z`; + for (let index = 0; index < count; index += 1) { + const startY = round(14 + random() * 70, 1); + const controlY = round(8 + random() * 80, 1); + const endY = round(14 + random() * 70, 1); + const color = random() > 0.42 ? "#fff" : "#111827"; + const opacity = round(0.055 + random() * 0.07, 3); + const width = round(12 + random() * 18, 1); + + streaks.push( + ``, + ); + } + + return streaks.join(""); } -function buildStrokePath(random: () => number): string { - const startY = round(18 + random() * 58, 1); - const endY = round(18 + random() * 58, 1); +function buildPaperFibers(random: () => number, variant: PaperAvatarVariant): string { + const count = variant === "workspace" ? 18 : 14; + const fibers: string[] = []; + + for (let index = 0; index < count; index += 1) { + const startX = round(-8 + random() * 98, 1); + const startY = round(random() * 96, 1); + const length = round(10 + random() * 34, 1); + const drift = round(-7 + random() * 14, 1); + const color = random() > 0.5 ? "#fff" : "#111827"; + const opacity = round(0.035 + random() * 0.06, 3); + const width = round(0.22 + random() * 0.46, 2); + + fibers.push( + ``, + ); + } + + return fibers.join(""); +} + +function buildPaperGrain(random: () => number, variant: PaperAvatarVariant): string { + const count = variant === "workspace" ? 84 : 68; + const dots: string[] = []; + + for (let index = 0; index < count; index += 1) { + const color = random() > 0.47 ? "#fff" : "#111827"; + const opacity = round(0.035 + random() * 0.095, 3); + const radius = round(0.12 + random() * 0.32, 2); + + dots.push( + ``, + ); + } - return `M -10 ${startY} C ${round(18 + random() * 22, 1)} ${round(random() * 96, 1)} ${round(54 + random() * 18, 1)} ${round(random() * 96, 1)} 106 ${endY}`; + return dots.join(""); } function svgToCssUrl(svg: string): string { From ced614a0da3461239205821e5b4f624ed563aa02 Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Sat, 23 May 2026 17:50:18 -0700 Subject: [PATCH 3/5] fix(app): brighten static icon glows --- .../design-system/paper-avatar-svg.ts | 122 +++++++++++------- 1 file changed, 73 insertions(+), 49 deletions(-) diff --git a/apps/app/src/react-app/design-system/paper-avatar-svg.ts b/apps/app/src/react-app/design-system/paper-avatar-svg.ts index 99359ad31b..5d0c799209 100644 --- a/apps/app/src/react-app/design-system/paper-avatar-svg.ts +++ b/apps/app/src/react-app/design-system/paper-avatar-svg.ts @@ -10,23 +10,23 @@ type PaperAvatarStyle = { }; const extensionPalettes: readonly Palette[] = [ - ["#edf2ff", "#2f2aa0", "#f56aa0", "#9f62d1", "#5eead4"], - ["#eaffd5", "#138a72", "#7c5cff", "#43e6bd", "#203063"], - ["#fff1c7", "#ee7b58", "#f25f92", "#6754d9", "#33c7b7"], - ["#dcfffb", "#22b8e6", "#805ad5", "#1d1f33", "#f66fb3"], - ["#fff3dd", "#d96b2b", "#1b6d9a", "#31a7c9", "#f8c64e"], - ["#f8ddff", "#7f4ce3", "#15b6d6", "#e45175", "#f4d35e"], + ["#17115f", "#38e7ff", "#ff4fa3", "#9f62ff", "#fff06a"], + ["#043f3a", "#5cffb0", "#7c5cff", "#1ad8ff", "#ffca5f"], + ["#4f1c08", "#ffb44d", "#ff4f88", "#6754ff", "#3cffd0"], + ["#101447", "#31d4ff", "#9566ff", "#f66fb3", "#a8ff5e"], + ["#082f49", "#45d5ff", "#ff7a35", "#ffcf4d", "#42ffc6"], + ["#2e145f", "#c569ff", "#1ee8ff", "#ff5378", "#f8ec5f"], ]; const workspacePalettes: readonly Palette[] = [ - ["#6d35ff", "#00d5ff", "#ff4fa3", "#ffbd35", "#35f2a5"], - ["#2347ff", "#20e6c8", "#ff7a3d", "#f7e34a", "#9d61ff"], - ["#f72585", "#3a86ff", "#7b4dff", "#ffbe3d", "#2ee6ad"], - ["#00b7ff", "#2735d9", "#eb5ed3", "#b8f24a", "#ff8a35"], - ["#21d995", "#9157ff", "#1ec9f0", "#ff4d6d", "#ffd166"], - ["#f05a28", "#ffd23f", "#21d9c3", "#6d35ba", "#e94d91"], - ["#78e44d", "#1cb8e8", "#f8dc54", "#9561d9", "#e862b6"], - ["#4cc9f0", "#4965e8", "#ef4f91", "#a23ab8", "#86f0d3"], + ["#170057", "#00eaff", "#ff2fb2", "#7cff6b", "#ffd23f"], + ["#06139a", "#22fff0", "#ff6a2f", "#fff63d", "#b15cff"], + ["#4d0038", "#3a9cff", "#ff2f86", "#8f5cff", "#39ffbd"], + ["#001f5c", "#00c8ff", "#ff62dd", "#c2ff33", "#ff8a2f"], + ["#00382a", "#20ff9a", "#9d5cff", "#1ee8ff", "#ff4d6d"], + ["#5c1800", "#ffd23f", "#24ffe1", "#8a3dff", "#ff4fa3"], + ["#1b4d00", "#7cff4d", "#1ec9ff", "#ffe84d", "#f05cff"], + ["#082454", "#61d8ff", "#5f75ff", "#ff4d9d", "#94ffe0"], ]; const styleCache = new Map(); @@ -55,30 +55,32 @@ function paletteForSeed(seed: string, variant: PaperAvatarVariant): Palette { function generatePaperAvatarSvg(seed: string, variant: PaperAvatarVariant, palette: Palette): string { const random = createRandom(seed, variant); - const blobCount = variant === "workspace" ? 8 : 7; - const blobGradients: string[] = []; - const blobLayers: string[] = []; - const grainOpacity = variant === "workspace" ? 0.58 : 0.46; + const glowCount = variant === "workspace" ? 7 : 6; + const glowGradients: string[] = []; + const glowLayers: string[] = []; + const grainOpacity = variant === "workspace" ? 0.42 : 0.34; - for (let index = 0; index < blobCount; index += 1) { + for (let index = 0; index < glowCount; index += 1) { const color = palette[(index + (variant === "workspace" ? 2 : 1)) % palette.length]; - const cx = round(-18 + random() * 132, 1); - const cy = round(-18 + random() * 132, 1); - const radius = round(46 + random() * 48, 1); - const innerOpacity = round(0.48 + random() * 0.22, 2); - const middleOpacity = round(0.2 + random() * 0.16, 2); - - blobGradients.push( - `` + - `` + - `` + + const cx = round(-12 + random() * 120, 1); + const cy = round(-12 + random() * 120, 1); + const radius = round(34 + random() * 42, 1); + const coreOpacity = round(0.76 + random() * 0.18, 2); + const bloomOpacity = round(0.36 + random() * 0.18, 2); + + glowGradients.push( + `` + + `` + + `` + + `` + `` + ``, ); - blobLayers.push(``); + glowLayers.push(``); } - const streaks = buildSoftStreaks(random, variant); + const hotSpots = buildHotSpots(random, variant, palette); + const streaks = buildGlowStreaks(random, variant, palette); const fibers = buildPaperFibers(random, variant); const grain = buildPaperGrain(random, variant); @@ -87,26 +89,47 @@ function generatePaperAvatarSvg(seed: string, variant: PaperAvatarVariant, palet `` + `` + `` + - `` + - `` + + `` + + `` + `` + `` + - `` + - `` + + `` + + `` + `` + - blobGradients.join("") + + glowGradients.join("") + `` + `` + - blobLayers.join("") + + glowLayers.join("") + streaks + + hotSpots + `` + `` + fibers + grain + `` + - `` + + `` + `` ); } -function buildSoftStreaks(random: () => number, variant: PaperAvatarVariant): string { +function buildHotSpots(random: () => number, variant: PaperAvatarVariant, palette: Palette): string { + const count = variant === "workspace" ? 4 : 3; + const spots: string[] = []; + + for (let index = 0; index < count; index += 1) { + const color = palette[(index + 1 + Math.floor(random() * (palette.length - 1))) % palette.length]; + const cx = round(12 + random() * 72, 1); + const cy = round(12 + random() * 72, 1); + const radius = round(2.2 + random() * 5.8, 1); + + spots.push( + `` + + `` + + ``, + ); + } + + return spots.join(""); +} + +function buildGlowStreaks(random: () => number, variant: PaperAvatarVariant, palette: Palette): string { const count = variant === "workspace" ? 3 : 2; const streaks: string[] = []; @@ -114,12 +137,13 @@ function buildSoftStreaks(random: () => number, variant: PaperAvatarVariant): st const startY = round(14 + random() * 70, 1); const controlY = round(8 + random() * 80, 1); const endY = round(14 + random() * 70, 1); - const color = random() > 0.42 ? "#fff" : "#111827"; - const opacity = round(0.055 + random() * 0.07, 3); - const width = round(12 + random() * 18, 1); + const color = palette[(index + 1 + Math.floor(random() * 4)) % palette.length]; + const opacity = round(0.14 + random() * 0.12, 3); + const width = round(7 + random() * 12, 1); streaks.push( - ``, + `` + + ``, ); } @@ -127,7 +151,7 @@ function buildSoftStreaks(random: () => number, variant: PaperAvatarVariant): st } function buildPaperFibers(random: () => number, variant: PaperAvatarVariant): string { - const count = variant === "workspace" ? 18 : 14; + const count = variant === "workspace" ? 14 : 10; const fibers: string[] = []; for (let index = 0; index < count; index += 1) { @@ -135,8 +159,8 @@ function buildPaperFibers(random: () => number, variant: PaperAvatarVariant): st const startY = round(random() * 96, 1); const length = round(10 + random() * 34, 1); const drift = round(-7 + random() * 14, 1); - const color = random() > 0.5 ? "#fff" : "#111827"; - const opacity = round(0.035 + random() * 0.06, 3); + const color = random() > 0.2 ? "#fff" : "#020617"; + const opacity = color === "#fff" ? round(0.035 + random() * 0.05, 3) : round(0.02 + random() * 0.035, 3); const width = round(0.22 + random() * 0.46, 2); fibers.push( @@ -148,12 +172,12 @@ function buildPaperFibers(random: () => number, variant: PaperAvatarVariant): st } function buildPaperGrain(random: () => number, variant: PaperAvatarVariant): string { - const count = variant === "workspace" ? 84 : 68; + const count = variant === "workspace" ? 64 : 52; const dots: string[] = []; for (let index = 0; index < count; index += 1) { - const color = random() > 0.47 ? "#fff" : "#111827"; - const opacity = round(0.035 + random() * 0.095, 3); + const color = random() > 0.24 ? "#fff" : "#020617"; + const opacity = color === "#fff" ? round(0.04 + random() * 0.1, 3) : round(0.025 + random() * 0.04, 3); const radius = round(0.12 + random() * 0.32, 2); dots.push( From f92ebc0130f5dd7612d61e0045315c3763911365 Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Sat, 23 May 2026 17:58:41 -0700 Subject: [PATCH 4/5] fix(app): restyle static avatars with neon motifs --- .../design-system/paper-avatar-svg.ts | 298 +++++++++++------- 1 file changed, 177 insertions(+), 121 deletions(-) diff --git a/apps/app/src/react-app/design-system/paper-avatar-svg.ts b/apps/app/src/react-app/design-system/paper-avatar-svg.ts index 5d0c799209..b334a8298d 100644 --- a/apps/app/src/react-app/design-system/paper-avatar-svg.ts +++ b/apps/app/src/react-app/design-system/paper-avatar-svg.ts @@ -1,7 +1,5 @@ export type PaperAvatarVariant = "extension" | "workspace"; -type Palette = readonly [string, string, string, string, string]; - type PaperAvatarStyle = { backgroundColor: string; backgroundImage: string; @@ -9,25 +7,21 @@ type PaperAvatarStyle = { backgroundSize: string; }; -const extensionPalettes: readonly Palette[] = [ - ["#17115f", "#38e7ff", "#ff4fa3", "#9f62ff", "#fff06a"], - ["#043f3a", "#5cffb0", "#7c5cff", "#1ad8ff", "#ffca5f"], - ["#4f1c08", "#ffb44d", "#ff4f88", "#6754ff", "#3cffd0"], - ["#101447", "#31d4ff", "#9566ff", "#f66fb3", "#a8ff5e"], - ["#082f49", "#45d5ff", "#ff7a35", "#ffcf4d", "#42ffc6"], - ["#2e145f", "#c569ff", "#1ee8ff", "#ff5378", "#f8ec5f"], -]; - -const workspacePalettes: readonly Palette[] = [ - ["#170057", "#00eaff", "#ff2fb2", "#7cff6b", "#ffd23f"], - ["#06139a", "#22fff0", "#ff6a2f", "#fff63d", "#b15cff"], - ["#4d0038", "#3a9cff", "#ff2f86", "#8f5cff", "#39ffbd"], - ["#001f5c", "#00c8ff", "#ff62dd", "#c2ff33", "#ff8a2f"], - ["#00382a", "#20ff9a", "#9d5cff", "#1ee8ff", "#ff4d6d"], - ["#5c1800", "#ffd23f", "#24ffe1", "#8a3dff", "#ff4fa3"], - ["#1b4d00", "#7cff4d", "#1ec9ff", "#ffe84d", "#f05cff"], - ["#082454", "#61d8ff", "#5f75ff", "#ff4d9d", "#94ffe0"], -]; +type Palette = { + background: string; + base: string; + primary: string; + secondary: string; + tertiary: string; + accent: string; + highlight: string; + shadow: string; +}; + +type SvgLayerSet = { + defs: string; + layers: string; +}; const styleCache = new Map(); @@ -36,9 +30,9 @@ export function getPaperAvatarStyle(seed: string, variant: PaperAvatarVariant): const cached = styleCache.get(cacheKey); if (cached) return cached; - const palette = paletteForSeed(seed, variant); + const palette = createPalette(seed, variant); const style = { - backgroundColor: palette[0], + backgroundColor: palette.background, backgroundImage: svgToCssUrl(generatePaperAvatarSvg(seed, variant, palette)), backgroundPosition: "center", backgroundSize: "cover", @@ -48,144 +42,198 @@ export function getPaperAvatarStyle(seed: string, variant: PaperAvatarVariant): return style; } -function paletteForSeed(seed: string, variant: PaperAvatarVariant): Palette { - const palettes = variant === "workspace" ? workspacePalettes : extensionPalettes; - return palettes[hashSeed(seed, `${variant}:palette`) % palettes.length]; +function createPalette(seed: string, variant: PaperAvatarVariant): Palette { + const hue = hashSeed(seed, `${variant}:hue`) % 360; + const vivid = variant === "workspace"; + const saturation = vivid ? 98 : 92; + const glowLight = vivid ? 60 : 62; + + return { + background: hsl(hue + 224, 78, vivid ? 9 : 12), + base: hsl(hue + 252, 84, vivid ? 13 : 16), + primary: hsl(hue, saturation, glowLight), + secondary: hsl(hue + 76, saturation, vivid ? 57 : 60), + tertiary: hsl(hue + 154, vivid ? 94 : 88, vivid ? 55 : 58), + accent: hsl(hue + 292, vivid ? 96 : 90, vivid ? 64 : 66), + highlight: "#ffffff", + shadow: "#020617", + }; } function generatePaperAvatarSvg(seed: string, variant: PaperAvatarVariant, palette: Palette): string { - const random = createRandom(seed, variant); - const glowCount = variant === "workspace" ? 7 : 6; - const glowGradients: string[] = []; - const glowLayers: string[] = []; - const grainOpacity = variant === "workspace" ? 0.42 : 0.34; - - for (let index = 0; index < glowCount; index += 1) { - const color = palette[(index + (variant === "workspace" ? 2 : 1)) % palette.length]; - const cx = round(-12 + random() * 120, 1); - const cy = round(-12 + random() * 120, 1); - const radius = round(34 + random() * 42, 1); - const coreOpacity = round(0.76 + random() * 0.18, 2); - const bloomOpacity = round(0.36 + random() * 0.18, 2); - - glowGradients.push( - `` + - `` + - `` + - `` + - `` + - ``, - ); - glowLayers.push(``); - } - - const hotSpots = buildHotSpots(random, variant, palette); - const streaks = buildGlowStreaks(random, variant, palette); - const fibers = buildPaperFibers(random, variant); - const grain = buildPaperGrain(random, variant); + const random = createRandom(seed, `${variant}:svg`); + const glows = buildGlowLayers(random, variant, palette); + const ribbons = buildAuroraRibbons(random, variant, palette); + const grain = buildGrain(random, variant, palette); + const motifIndex = hashSeed(seed, `${variant}:motif`) % 3; + const motif = motifIndex === 0 + ? buildOrbitalMotif(random, variant, palette) + : motifIndex === 1 + ? buildPrismMotif(random, variant, palette) + : buildPixelMotif(random, variant, palette); return ( `` + `` + - `` + - `` + - `` + - `` + + `` + + `` + + `` + + `` + `` + - `` + - `` + - `` + + `` + + `` + + `` + + `` + `` + - glowGradients.join("") + + glows.defs + + ribbons.defs + `` + `` + - glowLayers.join("") + - streaks + - hotSpots + - `` + - `` + fibers + grain + `` + - `` + + `` + glows.layers + ribbons.layers + motif + `` + + `` + + grain + + `` + `` ); } -function buildHotSpots(random: () => number, variant: PaperAvatarVariant, palette: Palette): string { - const count = variant === "workspace" ? 4 : 3; - const spots: string[] = []; +function buildGlowLayers(random: () => number, variant: PaperAvatarVariant, palette: Palette): SvgLayerSet { + const colors = [palette.primary, palette.secondary, palette.tertiary, palette.accent]; + const count = variant === "workspace" ? 6 : 5; + const defs: string[] = []; + const layers: string[] = []; for (let index = 0; index < count; index += 1) { - const color = palette[(index + 1 + Math.floor(random() * (palette.length - 1))) % palette.length]; - const cx = round(12 + random() * 72, 1); - const cy = round(12 + random() * 72, 1); - const radius = round(2.2 + random() * 5.8, 1); - - spots.push( - `` + - `` + - ``, + const color = colors[index % colors.length]; + const cx = round(-8 + random() * 112, 1); + const cy = round(-8 + random() * 112, 1); + const radius = round(30 + random() * 38, 1); + const coreOpacity = round((variant === "workspace" ? 0.82 : 0.72) + random() * 0.14, 2); + const bloomOpacity = round((variant === "workspace" ? 0.32 : 0.26) + random() * 0.16, 2); + + defs.push( + `` + + `` + + `` + + `` + + `` + + ``, ); + + layers.push(``); } - return spots.join(""); + return { defs: defs.join(""), layers: layers.join("") }; } -function buildGlowStreaks(random: () => number, variant: PaperAvatarVariant, palette: Palette): string { +function buildAuroraRibbons(random: () => number, variant: PaperAvatarVariant, palette: Palette): SvgLayerSet { + const colors = [palette.primary, palette.secondary, palette.tertiary, palette.accent]; const count = variant === "workspace" ? 3 : 2; - const streaks: string[] = []; + const defs: string[] = []; + const layers: string[] = []; for (let index = 0; index < count; index += 1) { - const startY = round(14 + random() * 70, 1); - const controlY = round(8 + random() * 80, 1); - const endY = round(14 + random() * 70, 1); - const color = palette[(index + 1 + Math.floor(random() * 4)) % palette.length]; - const opacity = round(0.14 + random() * 0.12, 3); - const width = round(7 + random() * 12, 1); - - streaks.push( - `` + - ``, + const first = colors[(index + 1) % colors.length]; + const second = colors[(index + 2) % colors.length]; + const startY = round(10 + random() * 72, 1); + const endY = round(10 + random() * 72, 1); + const controlA = round(4 + random() * 88, 1); + const controlB = round(4 + random() * 88, 1); + const width = round((variant === "workspace" ? 10 : 8) + random() * 9, 1); + const opacity = round((variant === "workspace" ? 0.24 : 0.18) + random() * 0.13, 2); + const path = `M -18 ${startY} C 18 ${controlA} 54 ${controlB} 114 ${endY}`; + + defs.push( + `` + + `` + + `` + + `` + + ``, + ); + + layers.push( + `` + + ``, ); } - return streaks.join(""); + return { defs: defs.join(""), layers: layers.join("") }; } -function buildPaperFibers(random: () => number, variant: PaperAvatarVariant): string { - const count = variant === "workspace" ? 14 : 10; - const fibers: string[] = []; +function buildOrbitalMotif(random: () => number, variant: PaperAvatarVariant, palette: Palette): string { + const opacity = variant === "workspace" ? 0.36 : 0.28; + const rotation = Math.round(random() * 90) - 45; + const secondRotation = rotation + 58 + Math.round(random() * 24); - for (let index = 0; index < count; index += 1) { - const startX = round(-8 + random() * 98, 1); - const startY = round(random() * 96, 1); - const length = round(10 + random() * 34, 1); - const drift = round(-7 + random() * 14, 1); - const color = random() > 0.2 ? "#fff" : "#020617"; - const opacity = color === "#fff" ? round(0.035 + random() * 0.05, 3) : round(0.02 + random() * 0.035, 3); - const width = round(0.22 + random() * 0.46, 2); - - fibers.push( - ``, - ); + return ( + `` + + `` + + `` + ); +} + +function buildPrismMotif(random: () => number, variant: PaperAvatarVariant, palette: Palette): string { + const opacity = variant === "workspace" ? 0.3 : 0.22; + const shift = round(random() * 18 - 9, 1); + + return ( + `` + + `` + + `` + ); +} + +function buildPixelMotif(random: () => number, variant: PaperAvatarVariant, palette: Palette): string { + const grid = variant === "workspace" ? 5 : 4; + const cell = 8; + const start = 48 - (grid * cell) / 2; + const colors = [palette.highlight, palette.primary, palette.secondary, palette.tertiary, palette.accent]; + const pixels: string[] = []; + + for (let y = 0; y < grid; y += 1) { + for (let x = 0; x < Math.ceil(grid / 2); x += 1) { + if (random() < 0.5) continue; + + const color = colors[(x + y + Math.floor(random() * colors.length)) % colors.length]; + const size = round(2.4 + random() * 2.8, 1); + const opacity = round((variant === "workspace" ? 0.2 : 0.15) + random() * 0.22, 2); + const leftX = round(start + x * cell + (cell - size) / 2, 1); + const rightX = round(start + (grid - 1 - x) * cell + (cell - size) / 2, 1); + const topY = round(start + y * cell + (cell - size) / 2, 1); + + pixels.push(``); + if (rightX !== leftX) { + pixels.push(``); + } + } } - return fibers.join(""); + return pixels.join(""); } -function buildPaperGrain(random: () => number, variant: PaperAvatarVariant): string { - const count = variant === "workspace" ? 64 : 52; - const dots: string[] = []; +function buildGrain(random: () => number, variant: PaperAvatarVariant, palette: Palette): string { + const dotCount = variant === "workspace" ? 42 : 34; + const lineCount = variant === "workspace" ? 8 : 6; + const pieces: string[] = []; - for (let index = 0; index < count; index += 1) { - const color = random() > 0.24 ? "#fff" : "#020617"; - const opacity = color === "#fff" ? round(0.04 + random() * 0.1, 3) : round(0.025 + random() * 0.04, 3); + for (let index = 0; index < dotCount; index += 1) { + const color = random() > 0.34 ? palette.highlight : palette.shadow; + const opacity = color === palette.highlight ? round(0.035 + random() * 0.08, 3) : round(0.025 + random() * 0.045, 3); const radius = round(0.12 + random() * 0.32, 2); - dots.push( - ``, - ); + pieces.push(``); + } + + for (let index = 0; index < lineCount; index += 1) { + const startX = round(random() * 96, 1); + const startY = round(random() * 96, 1); + const endX = round(startX + random() * 18 - 9, 1); + const endY = round(startY + random() * 18 - 9, 1); + + pieces.push(``); } - return dots.join(""); + return `${pieces.join("")}`; } function svgToCssUrl(svg: string): string { @@ -212,6 +260,14 @@ function hashSeed(seed: string, salt: string): number { return hash >>> 0; } +function hsl(hue: number, saturation: number, lightness: number): string { + return `hsl(${normalizeHue(hue)}, ${saturation}%, ${lightness}%)`; +} + +function normalizeHue(hue: number): number { + return ((hue % 360) + 360) % 360; +} + function round(value: number, decimals: number): number { const multiplier = 10 ** decimals; return Math.round(value * multiplier) / multiplier; From 61a12e2eaadb4578499e68105c00df464e45a5ff Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Sat, 23 May 2026 19:00:19 -0700 Subject: [PATCH 5/5] fix(app): use generated WebP avatar presets --- .gitignore | 1 + apps/app/package.json | 9 +- apps/app/scripts/generate-avatar-presets.mjs | 279 ++++++++++++++++++ .../design-system/extension-mesh-avatar.tsx | 2 +- .../design-system/paper-avatar-presets.ts | 59 ++++ .../design-system/paper-avatar-svg.ts | 274 ----------------- .../design-system/workspace-icon.tsx | 2 +- pnpm-lock.yaml | 3 + 8 files changed, 351 insertions(+), 278 deletions(-) create mode 100644 apps/app/scripts/generate-avatar-presets.mjs create mode 100644 apps/app/src/react-app/design-system/paper-avatar-presets.ts delete mode 100644 apps/app/src/react-app/design-system/paper-avatar-svg.ts diff --git a/.gitignore b/.gitignore index 4225f91e20..c66b88db31 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ out/ dist/ packages/*/dist/ apps/*/dist/ +apps/app/public/avatar-presets/ ee/apps/*/dist/ ee/packages/*/dist/ ee/apps/inference/models-site/models/api.json diff --git a/apps/app/package.json b/apps/app/package.json index e3304b706b..aa7b20fc58 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -4,12 +4,16 @@ "version": "0.13.12", "type": "module", "scripts": { + "avatars:generate": "node scripts/generate-avatar-presets.mjs", + "predev": "pnpm avatars:generate", "dev": "OPENWORK_DEV_MODE=1 vite", + "predev:windows": "pnpm avatars:generate", "dev:windows": "vite", - "prebuild": "pnpm --dir ../../packages/ui build", + "prebuild": "pnpm --dir ../../packages/ui build && pnpm avatars:generate", "build": "vite build", + "predev:web": "pnpm avatars:generate", "dev:web": "OPENWORK_DEV_MODE=1 vite", - "prebuild:web": "pnpm --dir ../../packages/ui build", + "prebuild:web": "pnpm --dir ../../packages/ui build && pnpm avatars:generate", "build:web": "vite build", "preview": "vite preview", "pretypecheck": "pnpm --dir ../../packages/ui build", @@ -87,6 +91,7 @@ "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", "@vitejs/plugin-react": "^5.0.4", + "sharp": "^0.34.5", "tailwindcss": "^4.1.18", "typescript": "^5.6.3", "vite": "^6.0.1" diff --git a/apps/app/scripts/generate-avatar-presets.mjs b/apps/app/scripts/generate-avatar-presets.mjs new file mode 100644 index 0000000000..8b38788a3b --- /dev/null +++ b/apps/app/scripts/generate-avatar-presets.mjs @@ -0,0 +1,279 @@ +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import sharp from "sharp"; + +const scriptRoot = dirname(fileURLToPath(import.meta.url)); +const appRoot = resolve(scriptRoot, ".."); +const outputDir = resolve(appRoot, "public", "avatar-presets"); +const manifestPath = resolve(outputDir, "manifest.json"); +const presetCount = 512; +const size = 96; +const version = 1; +const concurrency = 16; + +if (await presetsAreCurrent()) { + console.log(`avatar-presets: ${presetCount} WebP presets already current`); + process.exit(0); +} + +await rm(outputDir, { recursive: true, force: true }); +await mkdir(outputDir, { recursive: true }); + +let nextIndex = 0; +await Promise.all( + Array.from({ length: concurrency }, async () => { + while (nextIndex < presetCount) { + const index = nextIndex; + nextIndex += 1; + await renderPreset(index); + } + }), +); + +await writeFile( + manifestPath, + `${JSON.stringify({ version, presetCount, size, format: "webp" }, null, 2)}\n`, +); + +console.log(`avatar-presets: generated ${presetCount} ${size}x${size} WebP presets`); + +async function presetsAreCurrent() { + if (!existsSync(manifestPath)) return false; + + try { + const manifest = JSON.parse(await readFile(manifestPath, "utf8")); + if (manifest.version !== version || manifest.presetCount !== presetCount || manifest.size !== size || manifest.format !== "webp") { + return false; + } + + for (let index = 0; index < presetCount; index += 1) { + if (!existsSync(presetPath(index))) return false; + } + + return true; + } catch { + return false; + } +} + +async function renderPreset(index) { + await sharp(Buffer.from(generateSvg(index))) + .webp({ quality: 78, effort: 4, smartSubsample: true }) + .toFile(presetPath(index)); +} + +function presetPath(index) { + return resolve(outputDir, `paper-avatar-${String(index).padStart(3, "0")}.webp`); +} + +function generateSvg(index) { + const random = createRandom(index); + const palette = createPalette(index, random); + const glowDefs = []; + const glowLayers = []; + const ribbonDefs = []; + const ribbonLayers = []; + + for (let layer = 0; layer < 8; layer += 1) { + const color = palette.glows[layer % palette.glows.length]; + const cx = round(-10 + random() * 116, 1); + const cy = round(-10 + random() * 116, 1); + const rx = round(24 + random() * 42, 1); + const ry = round(18 + random() * 36, 1); + const rotation = Math.round(random() * 180); + const opacity = round(0.42 + random() * 0.36, 2); + + glowDefs.push( + `` + + `` + + `` + + `` + + ``, + ); + glowLayers.push( + ``, + ); + } + + for (let layer = 0; layer < 3; layer += 1) { + const first = palette.glows[(layer + 1) % palette.glows.length]; + const second = palette.glows[(layer + 3) % palette.glows.length]; + const startY = round(12 + random() * 72, 1); + const endY = round(12 + random() * 72, 1); + const controlA = round(0 + random() * 96, 1); + const controlB = round(0 + random() * 96, 1); + const width = round(8 + random() * 14, 1); + const path = `M -18 ${startY} C 18 ${controlA} 60 ${controlB} 114 ${endY}`; + + ribbonDefs.push( + `` + + `` + + `` + + `` + + ``, + ); + ribbonLayers.push( + ``, + ); + } + + return ( + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + glowDefs.join("") + + ribbonDefs.join("") + + `` + + `` + + glowLayers.join("") + + ribbonLayers.join("") + + buildMotif(index, random, palette) + + `` + + buildGrain(random) + + `` + ); +} + +function createPalette(index, random) { + const hue = (index * 137 + Math.floor(random() * 46)) % 360; + + return { + baseA: hsl(hue + 232, 82, 10), + baseB: hsl(hue + 278, 86, 15), + baseC: hsl(hue + 214, 78, 6), + glows: [ + hsl(hue, 98, 62), + hsl(hue + 72, 98, 58), + hsl(hue + 148, 94, 56), + hsl(hue + 222, 96, 62), + hsl(hue + 296, 98, 66), + ], + }; +} + +function buildMotif(index, random, palette) { + switch (index % 4) { + case 0: + return buildOrbitMotif(random, palette); + case 1: + return buildPrismMotif(random, palette); + case 2: + return buildPixelMotif(random, palette); + default: + return buildArcMotif(random, palette); + } +} + +function buildOrbitMotif(random, palette) { + const rotation = Math.round(random() * 90) - 45; + const secondRotation = rotation + 64 + Math.round(random() * 28); + + return ( + `` + + `` + + `` + ); +} + +function buildPrismMotif(random, palette) { + const shift = round(random() * 18 - 9, 1); + + return ( + `` + + `` + + `` + ); +} + +function buildPixelMotif(random, palette) { + const grid = 5; + const cell = 8; + const start = 48 - (grid * cell) / 2; + const colors = ["#fff", ...palette.glows]; + const pixels = []; + + for (let y = 0; y < grid; y += 1) { + for (let x = 0; x < Math.ceil(grid / 2); x += 1) { + if (random() < 0.48) continue; + + const color = colors[(x + y + Math.floor(random() * colors.length)) % colors.length]; + const dotSize = round(2.4 + random() * 3, 1); + const opacity = round(0.18 + random() * 0.28, 2); + const leftX = round(start + x * cell + (cell - dotSize) / 2, 1); + const rightX = round(start + (grid - 1 - x) * cell + (cell - dotSize) / 2, 1); + const topY = round(start + y * cell + (cell - dotSize) / 2, 1); + const rx = round(dotSize * 0.36, 1); + + pixels.push(``); + if (rightX !== leftX) { + pixels.push(``); + } + } + } + + return pixels.join(""); +} + +function buildArcMotif(random, palette) { + const start = round(18 + random() * 20, 1); + const end = round(58 + random() * 20, 1); + + return ( + `` + + `` + ); +} + +function buildGrain(random) { + const pieces = []; + + for (let index = 0; index < 48; index += 1) { + const fill = random() > 0.32 ? "#fff" : "#020617"; + const opacity = fill === "#fff" ? round(0.035 + random() * 0.08, 3) : round(0.025 + random() * 0.04, 3); + pieces.push(``); + } + + for (let index = 0; index < 7; index += 1) { + const startX = round(random() * 96, 1); + const startY = round(random() * 96, 1); + pieces.push(``); + } + + return `${pieces.join("")}`; +} + +function createRandom(index) { + let state = (index * 747796405 + 2891336453) >>> 0; + + return () => { + state = (state * 1664525 + 1013904223) >>> 0; + return state / 4294967296; + }; +} + +function hsl(hue, saturation, lightness) { + return `hsl(${normalizeHue(hue)}, ${saturation}%, ${lightness}%)`; +} + +function normalizeHue(hue) { + return ((hue % 360) + 360) % 360; +} + +function round(value, decimals) { + const multiplier = 10 ** decimals; + return Math.round(value * multiplier) / multiplier; +} diff --git a/apps/app/src/react-app/design-system/extension-mesh-avatar.tsx b/apps/app/src/react-app/design-system/extension-mesh-avatar.tsx index d1e0b7b704..39c2eae18a 100644 --- a/apps/app/src/react-app/design-system/extension-mesh-avatar.tsx +++ b/apps/app/src/react-app/design-system/extension-mesh-avatar.tsx @@ -1,5 +1,5 @@ /** @jsxImportSource react */ -import { getPaperAvatarStyle } from "./paper-avatar-svg"; +import { getPaperAvatarStyle } from "./paper-avatar-presets"; type ExtensionMeshAvatarProps = { name: string; diff --git a/apps/app/src/react-app/design-system/paper-avatar-presets.ts b/apps/app/src/react-app/design-system/paper-avatar-presets.ts new file mode 100644 index 0000000000..9de9fbea64 --- /dev/null +++ b/apps/app/src/react-app/design-system/paper-avatar-presets.ts @@ -0,0 +1,59 @@ +export type PaperAvatarVariant = "extension" | "workspace"; + +type PaperAvatarStyle = { + backgroundColor: string; + backgroundImage: string; + backgroundPosition: string; + backgroundSize: string; +}; + +const avatarPresetCount = 512; +const styleCache = new Map(); + +export function getPaperAvatarStyle(seed: string, variant: PaperAvatarVariant): PaperAvatarStyle { + const normalizedSeed = seed.trim() || variant; + const cacheKey = `${variant}:${normalizedSeed}`; + const cached = styleCache.get(cacheKey); + if (cached) return cached; + + const presetIndex = hashSeed(normalizedSeed, `avatar-preset:${variant}`) % avatarPresetCount; + const hue = hashSeed(normalizedSeed, `avatar-fallback:${variant}`) % 360; + const style = { + backgroundColor: `hsl(${hue}, 90%, 14%)`, + backgroundImage: `${avatarPresetUrl(presetIndex)}, ${fallbackGradient(hue)}`, + backgroundPosition: "center", + backgroundSize: "cover", + }; + + styleCache.set(cacheKey, style); + return style; +} + +function avatarPresetUrl(index: number): string { + const base = import.meta.env.BASE_URL.endsWith("/") ? import.meta.env.BASE_URL : `${import.meta.env.BASE_URL}/`; + return `url("${base}avatar-presets/paper-avatar-${String(index).padStart(3, "0")}.webp")`; +} + +function fallbackGradient(hue: number): string { + return [ + `radial-gradient(circle at 28% 24%, hsl(${hue}, 98%, 62%), transparent 42%)`, + `radial-gradient(circle at 76% 34%, hsl(${normalizeHue(hue + 78)}, 96%, 58%), transparent 44%)`, + `radial-gradient(circle at 46% 82%, hsl(${normalizeHue(hue + 154)}, 92%, 56%), transparent 48%)`, + `linear-gradient(135deg, hsl(${normalizeHue(hue + 232)}, 82%, 12%), hsl(${normalizeHue(hue + 292)}, 84%, 18%))`, + ].join(", "); +} + +function hashSeed(seed: string, salt: string): number { + const value = `${salt}:${seed}`; + let hash = 5381; + + for (let index = 0; index < value.length; index += 1) { + hash = ((hash << 5) + hash + value.charCodeAt(index)) | 0; + } + + return hash >>> 0; +} + +function normalizeHue(hue: number): number { + return ((hue % 360) + 360) % 360; +} diff --git a/apps/app/src/react-app/design-system/paper-avatar-svg.ts b/apps/app/src/react-app/design-system/paper-avatar-svg.ts deleted file mode 100644 index b334a8298d..0000000000 --- a/apps/app/src/react-app/design-system/paper-avatar-svg.ts +++ /dev/null @@ -1,274 +0,0 @@ -export type PaperAvatarVariant = "extension" | "workspace"; - -type PaperAvatarStyle = { - backgroundColor: string; - backgroundImage: string; - backgroundPosition: string; - backgroundSize: string; -}; - -type Palette = { - background: string; - base: string; - primary: string; - secondary: string; - tertiary: string; - accent: string; - highlight: string; - shadow: string; -}; - -type SvgLayerSet = { - defs: string; - layers: string; -}; - -const styleCache = new Map(); - -export function getPaperAvatarStyle(seed: string, variant: PaperAvatarVariant): PaperAvatarStyle { - const cacheKey = `${variant}:${seed.trim() || variant}`; - const cached = styleCache.get(cacheKey); - if (cached) return cached; - - const palette = createPalette(seed, variant); - const style = { - backgroundColor: palette.background, - backgroundImage: svgToCssUrl(generatePaperAvatarSvg(seed, variant, palette)), - backgroundPosition: "center", - backgroundSize: "cover", - }; - - styleCache.set(cacheKey, style); - return style; -} - -function createPalette(seed: string, variant: PaperAvatarVariant): Palette { - const hue = hashSeed(seed, `${variant}:hue`) % 360; - const vivid = variant === "workspace"; - const saturation = vivid ? 98 : 92; - const glowLight = vivid ? 60 : 62; - - return { - background: hsl(hue + 224, 78, vivid ? 9 : 12), - base: hsl(hue + 252, 84, vivid ? 13 : 16), - primary: hsl(hue, saturation, glowLight), - secondary: hsl(hue + 76, saturation, vivid ? 57 : 60), - tertiary: hsl(hue + 154, vivid ? 94 : 88, vivid ? 55 : 58), - accent: hsl(hue + 292, vivid ? 96 : 90, vivid ? 64 : 66), - highlight: "#ffffff", - shadow: "#020617", - }; -} - -function generatePaperAvatarSvg(seed: string, variant: PaperAvatarVariant, palette: Palette): string { - const random = createRandom(seed, `${variant}:svg`); - const glows = buildGlowLayers(random, variant, palette); - const ribbons = buildAuroraRibbons(random, variant, palette); - const grain = buildGrain(random, variant, palette); - const motifIndex = hashSeed(seed, `${variant}:motif`) % 3; - const motif = motifIndex === 0 - ? buildOrbitalMotif(random, variant, palette) - : motifIndex === 1 - ? buildPrismMotif(random, variant, palette) - : buildPixelMotif(random, variant, palette); - - return ( - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - glows.defs + - ribbons.defs + - `` + - `` + - `` + glows.layers + ribbons.layers + motif + `` + - `` + - grain + - `` + - `` - ); -} - -function buildGlowLayers(random: () => number, variant: PaperAvatarVariant, palette: Palette): SvgLayerSet { - const colors = [palette.primary, palette.secondary, palette.tertiary, palette.accent]; - const count = variant === "workspace" ? 6 : 5; - const defs: string[] = []; - const layers: string[] = []; - - for (let index = 0; index < count; index += 1) { - const color = colors[index % colors.length]; - const cx = round(-8 + random() * 112, 1); - const cy = round(-8 + random() * 112, 1); - const radius = round(30 + random() * 38, 1); - const coreOpacity = round((variant === "workspace" ? 0.82 : 0.72) + random() * 0.14, 2); - const bloomOpacity = round((variant === "workspace" ? 0.32 : 0.26) + random() * 0.16, 2); - - defs.push( - `` + - `` + - `` + - `` + - `` + - ``, - ); - - layers.push(``); - } - - return { defs: defs.join(""), layers: layers.join("") }; -} - -function buildAuroraRibbons(random: () => number, variant: PaperAvatarVariant, palette: Palette): SvgLayerSet { - const colors = [palette.primary, palette.secondary, palette.tertiary, palette.accent]; - const count = variant === "workspace" ? 3 : 2; - const defs: string[] = []; - const layers: string[] = []; - - for (let index = 0; index < count; index += 1) { - const first = colors[(index + 1) % colors.length]; - const second = colors[(index + 2) % colors.length]; - const startY = round(10 + random() * 72, 1); - const endY = round(10 + random() * 72, 1); - const controlA = round(4 + random() * 88, 1); - const controlB = round(4 + random() * 88, 1); - const width = round((variant === "workspace" ? 10 : 8) + random() * 9, 1); - const opacity = round((variant === "workspace" ? 0.24 : 0.18) + random() * 0.13, 2); - const path = `M -18 ${startY} C 18 ${controlA} 54 ${controlB} 114 ${endY}`; - - defs.push( - `` + - `` + - `` + - `` + - ``, - ); - - layers.push( - `` + - ``, - ); - } - - return { defs: defs.join(""), layers: layers.join("") }; -} - -function buildOrbitalMotif(random: () => number, variant: PaperAvatarVariant, palette: Palette): string { - const opacity = variant === "workspace" ? 0.36 : 0.28; - const rotation = Math.round(random() * 90) - 45; - const secondRotation = rotation + 58 + Math.round(random() * 24); - - return ( - `` + - `` + - `` - ); -} - -function buildPrismMotif(random: () => number, variant: PaperAvatarVariant, palette: Palette): string { - const opacity = variant === "workspace" ? 0.3 : 0.22; - const shift = round(random() * 18 - 9, 1); - - return ( - `` + - `` + - `` - ); -} - -function buildPixelMotif(random: () => number, variant: PaperAvatarVariant, palette: Palette): string { - const grid = variant === "workspace" ? 5 : 4; - const cell = 8; - const start = 48 - (grid * cell) / 2; - const colors = [palette.highlight, palette.primary, palette.secondary, palette.tertiary, palette.accent]; - const pixels: string[] = []; - - for (let y = 0; y < grid; y += 1) { - for (let x = 0; x < Math.ceil(grid / 2); x += 1) { - if (random() < 0.5) continue; - - const color = colors[(x + y + Math.floor(random() * colors.length)) % colors.length]; - const size = round(2.4 + random() * 2.8, 1); - const opacity = round((variant === "workspace" ? 0.2 : 0.15) + random() * 0.22, 2); - const leftX = round(start + x * cell + (cell - size) / 2, 1); - const rightX = round(start + (grid - 1 - x) * cell + (cell - size) / 2, 1); - const topY = round(start + y * cell + (cell - size) / 2, 1); - - pixels.push(``); - if (rightX !== leftX) { - pixels.push(``); - } - } - } - - return pixels.join(""); -} - -function buildGrain(random: () => number, variant: PaperAvatarVariant, palette: Palette): string { - const dotCount = variant === "workspace" ? 42 : 34; - const lineCount = variant === "workspace" ? 8 : 6; - const pieces: string[] = []; - - for (let index = 0; index < dotCount; index += 1) { - const color = random() > 0.34 ? palette.highlight : palette.shadow; - const opacity = color === palette.highlight ? round(0.035 + random() * 0.08, 3) : round(0.025 + random() * 0.045, 3); - const radius = round(0.12 + random() * 0.32, 2); - - pieces.push(``); - } - - for (let index = 0; index < lineCount; index += 1) { - const startX = round(random() * 96, 1); - const startY = round(random() * 96, 1); - const endX = round(startX + random() * 18 - 9, 1); - const endY = round(startY + random() * 18 - 9, 1); - - pieces.push(``); - } - - return `${pieces.join("")}`; -} - -function svgToCssUrl(svg: string): string { - return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`; -} - -function createRandom(seed: string, salt: string) { - let state = hashSeed(seed, salt) || 1; - - return () => { - state = (state * 1664525 + 1013904223) >>> 0; - return state / 4294967296; - }; -} - -function hashSeed(seed: string, salt: string): number { - const value = `${salt}:${seed.trim() || salt}`; - let hash = 5381; - - for (let index = 0; index < value.length; index += 1) { - hash = ((hash << 5) + hash + value.charCodeAt(index)) | 0; - } - - return hash >>> 0; -} - -function hsl(hue: number, saturation: number, lightness: number): string { - return `hsl(${normalizeHue(hue)}, ${saturation}%, ${lightness}%)`; -} - -function normalizeHue(hue: number): number { - return ((hue % 360) + 360) % 360; -} - -function round(value: number, decimals: number): number { - const multiplier = 10 ** decimals; - return Math.round(value * multiplier) / multiplier; -} diff --git a/apps/app/src/react-app/design-system/workspace-icon.tsx b/apps/app/src/react-app/design-system/workspace-icon.tsx index b92bc0125e..f9dbdc5c9c 100644 --- a/apps/app/src/react-app/design-system/workspace-icon.tsx +++ b/apps/app/src/react-app/design-system/workspace-icon.tsx @@ -1,5 +1,5 @@ /** @jsxImportSource react */ -import { getPaperAvatarStyle } from "./paper-avatar-svg"; +import { getPaperAvatarStyle } from "./paper-avatar-presets"; export type WorkspaceIconProps = { /** Workspace name used to seed the gradient. Changes when renamed. */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2057a3d073..f6add387bd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -177,6 +177,9 @@ importers: '@vitejs/plugin-react': specifier: ^5.0.4 version: 5.2.0(vite@6.4.1(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) + sharp: + specifier: ^0.34.5 + version: 0.34.5 tailwindcss: specifier: ^4.1.18 version: 4.1.18