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 (
+ ``
+ );
+}
+
+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 783c80bab7..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,35 +1,11 @@
/** @jsxImportSource react */
-import { StaticMeshGradient } from "@paper-design/shaders-react";
+import { getPaperAvatarStyle } from "./paper-avatar-presets";
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-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/workspace-icon.tsx b/apps/app/src/react-app/design-system/workspace-icon.tsx
index c86e584c73..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,6 +1,5 @@
/** @jsxImportSource react */
-import { useMemo } from "react";
-import { PaperGrainGradient } from "@openwork/ui/react";
+import { getPaperAvatarStyle } from "./paper-avatar-presets";
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 (
-
+
);
}
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