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 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