From 557057364858d9b9408159c0292b0d0c31787d0f Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 30 Sep 2026 16:06:28 -0700 Subject: [PATCH 1/3] feat(workspaces): generated glyph icons for workspaces without a logo --- .../invitation-workspace-access.tsx | 8 +- .../workspaces-section/workspace-list.tsx | 11 +- .../add-resource-dropdown.tsx | 5 +- .../command-items/command-items.tsx | 6 +- .../search-groups/search-groups.tsx | 1 + .../view-invitations-modal.tsx | 5 +- .../workspace-header/workspace-header.tsx | 14 +- .../identity-tile/identity-tile.tsx | 124 ++++++++++-- apps/sim/lib/workspaces/glyph.test.ts | 70 +++++++ apps/sim/lib/workspaces/glyph.ts | 179 ++++++++++++++++++ apps/sim/lib/workspaces/initials.ts | 8 - 11 files changed, 375 insertions(+), 56 deletions(-) create mode 100644 apps/sim/lib/workspaces/glyph.test.ts create mode 100644 apps/sim/lib/workspaces/glyph.ts delete mode 100644 apps/sim/lib/workspaces/initials.ts diff --git a/apps/sim/app/invite/components/invitation-workspace-access.tsx b/apps/sim/app/invite/components/invitation-workspace-access.tsx index b9bd00c9b05..86f8b43406b 100644 --- a/apps/sim/app/invite/components/invitation-workspace-access.tsx +++ b/apps/sim/app/invite/components/invitation-workspace-access.tsx @@ -1,7 +1,6 @@ import { ChipTag } from '@sim/emcn' import { IdentityTile } from '@/components/identity-tile/identity-tile' import type { InvitationDetails } from '@/lib/api/contracts/invitations' -import { getWorkspaceInitial } from '@/lib/workspaces/initials' import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' interface InvitationWorkspaceAccessProps { @@ -15,12 +14,7 @@ export function InvitationWorkspaceAccess({ grants }: InvitationWorkspaceAccessP
  • - } + icon={} iconVariant='custom' title={grant.workspaceName || 'Unnamed workspace'} badge={{grant.permission} access} diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspace-list.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspace-list.tsx index f371d69d4a8..2ea68bf303f 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspace-list.tsx +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspace-list.tsx @@ -16,7 +16,6 @@ import { getErrorMessage } from '@sim/utils/errors' import { IdentityTile } from '@/components/identity-tile/identity-tile' import { SettingsGuardedLink } from '@/components/settings/settings-guarded-link' import { WorkspaceContextMenu } from '@/components/workspaces/workspace-context-menu' -import { getWorkspaceInitial } from '@/lib/workspaces/initials' import { useOrganizationWorkspaces } from '@/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-workspaces' import { SidebarRenameRow } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-rename-row' import { SidebarRowAction } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-row-actions' @@ -84,10 +83,7 @@ export function WorkspaceList({ organizationId, pathname, flyout }: WorkspaceLis const isPinned = pinnedWorkspaceIds.has(workspace.id) const label = ( <> - + + } aria-label={`Rename workspace ${workspace.name}`} value={rename.value} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx index 73865f1b3ec..0de4b564c2a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx @@ -20,7 +20,6 @@ import { Folder, Plus } from '@sim/emcn/icons' import { IdentityTile } from '@/components/identity-tile/identity-tile' import { isBrowserAgentAvailable } from '@/lib/browser-agent/transport' import { isTerminalAvailable } from '@/lib/terminal/transport' -import { getWorkspaceInitial } from '@/lib/workspaces/initials' import { type AvailableItemsByType, type AvailableResources, @@ -463,9 +462,7 @@ export function WorkspaceResourceSubmenu({ onSelectWorkspace, }: WorkspaceResourceSubmenuProps) { const [open, setOpen] = useState(false) - const icon = ( - - ) + const icon = return ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx index c0ce35be12d..de7df004030 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx @@ -6,7 +6,6 @@ import { OverflowText } from '@sim/emcn' import { File, Workflow } from '@sim/emcn/icons' import { Command } from 'cmdk' import { IdentityTile } from '@/components/identity-tile/identity-tile' -import { getWorkspaceInitial } from '@/lib/workspaces/initials' import type { CommandItemProps } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils' import { COMMAND_ITEM_CLASSNAME } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils' import { BlockTile } from '@/blocks/block-tile' @@ -245,6 +244,7 @@ export const MemoizedWorkspaceItem = memo( function WorkspaceItem({ value, onSelect, + workspaceId, name, isCurrent, logoUrl, @@ -252,13 +252,14 @@ export const MemoizedWorkspaceItem = memo( }: { value: string onSelect: () => void + workspaceId: string name: string isCurrent?: boolean logoUrl?: string | null } & ResultMetaProps) { return ( - + {isCurrent && (current)} @@ -269,6 +270,7 @@ export const MemoizedWorkspaceItem = memo( }, (prev, next) => prev.value === next.value && + prev.workspaceId === next.workspaceId && prev.name === next.name && prev.isCurrent === next.isCurrent && prev.logoUrl === next.logoUrl && diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.tsx index 89b130ca569..7999f7ac6fa 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.tsx @@ -240,6 +240,7 @@ function renderSearchEntry( key={key} value={`${entry.item.name} ${key}`} onSelect={() => handlers.onSelectWorkspace(entry.item)} + workspaceId={entry.item.id} name={entry.item.name} isCurrent={entry.item.isCurrent} logoUrl={entry.item.logoUrl} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-modal.tsx index 07640aeff67..d0dd8c3482e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-modal.tsx @@ -18,7 +18,6 @@ import { useRouter } from 'next/navigation' import { IdentityTile } from '@/components/identity-tile/identity-tile' import type { MyInvitation } from '@/lib/api/contracts/invitations' import { getInvitationErrorMessage } from '@/lib/invitations/error-messages' -import { getWorkspaceInitial } from '@/lib/workspaces/initials' import { InvitationDisclosure } from '@/app/invite/components/invitation-disclosure' import { InvitationWorkspaceAccess } from '@/app/invite/components/invitation-workspace-access' import { @@ -160,9 +159,7 @@ export function ViewInvitationsModal({ open, onOpenChange }: ViewInvitationsModa icon={ singleWorkspaceGrant && ( ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx index ad1a19055af..d502b649fce 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx @@ -33,7 +33,6 @@ import { SettingsGuardedLink } from '@/components/settings/settings-guarded-link import { WorkspaceContextMenu } from '@/components/workspaces/workspace-context-menu' import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { WORKSPACE_SEARCH_THRESHOLD } from '@/lib/workspaces/constants' -import { getWorkspaceInitial } from '@/lib/workspaces/initials' import { InviteModal } from '@/app/workspace/[workspaceId]/components/invite-modal' import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { SidebarRenameRow } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-rename-row' @@ -308,8 +307,6 @@ function WorkspaceHeaderImpl({ } }, [isWorkspaceMenuOpen, editingWorkspaceId, editingName, workspaces, onRenameWorkspace]) - const workspaceInitial = getWorkspaceInitial(activeWorkspace?.name) - /** * Opens the context menu for a workspace at the specified position */ @@ -457,7 +454,7 @@ function WorkspaceHeaderImpl({ {activeWorkspace ? ( <> @@ -603,7 +600,6 @@ function WorkspaceHeaderImpl({ )} {filteredWorkspaces.map((workspace, idx) => { - const initial = getWorkspaceInitial(workspace.name) const isActive = workspace.id === workspaceId const isMenuOpen = menuOpenWorkspaceId === workspace.id const isKeyboardHighlighted = showSearch && isKeyboardNav && idx === activeIndex @@ -633,7 +629,7 @@ function WorkspaceHeaderImpl({ aria-label={`Rename workspace ${workspace.name}`} leadingAdornment={ @@ -726,7 +722,7 @@ function WorkspaceHeaderImpl({ onContextMenu={(e) => handleContextMenu(e, workspace)} > @@ -799,7 +795,7 @@ function WorkspaceHeaderImpl({ > {activeWorkspace ? ( diff --git a/apps/sim/components/identity-tile/identity-tile.tsx b/apps/sim/components/identity-tile/identity-tile.tsx index 2df3f7a4acb..a19584d66c2 100644 --- a/apps/sim/components/identity-tile/identity-tile.tsx +++ b/apps/sim/components/identity-tile/identity-tile.tsx @@ -1,8 +1,8 @@ +import { useId } from 'react' import { cn } from '@sim/emcn' +import { type GlyphShape, generateGlyph } from '@/lib/workspaces/glyph' -interface IdentityTileProps { - /** Letter shown when there is no uploaded mark. */ - initial: string +interface IdentityTileBaseProps { logoUrl?: string | null /** Accessible name for an uploaded mark; empty when the name is already beside it. */ alt?: string @@ -14,30 +14,125 @@ interface IdentityTileProps { size?: 'sm' | 'lg' } +type IdentityTileProps = IdentityTileBaseProps & + ( + | { + /** Letter shown when there is no uploaded mark. */ + initial: string + glyphSeed?: never + } + | { + /** Seed (a workspace id) for the generated glyph shown when there is no uploaded mark. */ + glyphSeed: string + initial?: never + } + ) + const SIZE_CLASS = { sm: 'size-[16px] rounded-sm text-micro', lg: 'size-9 rounded-lg text-base', } as const +const GLYPH_SIZE_CLASS = { + sm: 'size-[16px]', + lg: 'size-9', +} as const + +/** The loader's goo: blur, then crush alpha so parts within the blur radius melt together. */ +const GOO_ALPHA_MATRIX = '1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 19 -9' + +function glyphShape(shape: GlyphShape, key: number) { + switch (shape.kind) { + case 'circle': + return + case 'ring': + return ( + + ) + case 'capsule': { + const length = Math.hypot(shape.x2 - shape.x1, shape.y2 - shape.y1) + const deg = (Math.atan2(shape.y2 - shape.y1, shape.x2 - shape.x1) * 180) / Math.PI + return ( + + ) + } + case 'poly': + return p.join(',')).join(' ')} /> + } +} + +interface GlyphProps { + seed: string + className?: string + slot?: string + size: 'sm' | 'lg' +} + +function Glyph({ seed, className, slot, size }: GlyphProps) { + const filterId = `identity-glyph-${useId().replace(/[^a-zA-Z0-9-]/g, '')}` + return ( + + ) +} + /** - * The 16px mark for a workspace or organization: its uploaded logo, or its - * initial on a neutral tile. There is no per-entity color — every tile is the - * same gray so an uploaded mark is the only thing that distinguishes one from - * another, exactly as an icon would. + * The 16px mark for a workspace or organization: its uploaded logo, else a + * glyph seeded by the workspace id (so each is recognizable without an + * upload) or the organization's initial on a neutral tile. * * Chrome matches the chip family at tile scale: `rounded-sm` is the chip's * `rounded-lg` scaled to a 16px box, and the letter sits at the smallest type * token. The fill is `--surface-6`, one step past the chip hover and active * fills, so the tile still reads as a tile on a hovered or selected row instead - * of dissolving into it. The letter is the icon gray in light mode and steps up - * to the secondary text gray in dark mode, where the icon gray sits too close - * to that fill. Plain `img`/`div` - * rather than the emcn `Avatar`, whose Radix root renders a `` — and globals - * fade every `span` in the collapsed rail to `opacity: 0`, which would blank the - * mark exactly where it is the only thing left to see. + * of dissolving into it. The letter and the unfilled glyph are the icon gray in + * light mode and step up to the secondary text gray in dark mode, where the icon + * gray sits too close to the tile fill. Plain `img`/`div`/`svg` rather than the emcn + * `Avatar`, whose Radix root renders a `` — and globals fade every `span` + * in the collapsed rail to `opacity: 0`, which would blank the mark exactly + * where it is the only thing left to see. */ export function IdentityTile({ initial, + glyphSeed, logoUrl, alt = '', className, @@ -55,6 +150,9 @@ export function IdentityTile({ /> ) } + if (glyphSeed !== undefined) { + return + } return (
    `workspace-${i}`) + +function extent(s: GlyphShape): [number, number, number, number] { + switch (s.kind) { + case 'circle': + return [s.cx - s.r, s.cy - s.r, s.cx + s.r, s.cy + s.r] + case 'ring': + return [ + s.cx - s.r - s.w / 2, + s.cy - s.r - s.w / 2, + s.cx + s.r + s.w / 2, + s.cy + s.r + s.w / 2, + ] + case 'capsule': + return [ + Math.min(s.x1, s.x2) - s.w / 2, + Math.min(s.y1, s.y2) - s.w / 2, + Math.max(s.x1, s.x2) + s.w / 2, + Math.max(s.y1, s.y2) + s.w / 2, + ] + case 'poly': + return [ + Math.min(...s.points.map((p) => p[0])), + Math.min(...s.points.map((p) => p[1])), + Math.max(...s.points.map((p) => p[0])), + Math.max(...s.points.map((p) => p[1])), + ] + } +} + +describe('generateGlyph', () => { + it('is deterministic per seed', () => { + expect(generateGlyph('ws_abc')).toEqual(generateGlyph('ws_abc')) + }) + + it('gives nearly every seed a distinct glyph', () => { + const distinct = new Set(SEEDS.map((s) => JSON.stringify(generateGlyph(s)))) + expect(distinct.size).toBeGreaterThanOrEqual(SEEDS.length - 2) + }) + + it('keeps every shape family centered inside the 100-unit box', () => { + const kinds = new Set() + for (const seed of SEEDS) { + const shapes = generateGlyph(seed) + for (const s of shapes) kinds.add(s.kind) + const boxes = shapes.map(extent) + const x0 = Math.min(...boxes.map((b) => b[0])) + const y0 = Math.min(...boxes.map((b) => b[1])) + const x1 = Math.max(...boxes.map((b) => b[2])) + const y1 = Math.max(...boxes.map((b) => b[3])) + expect(x0).toBeGreaterThanOrEqual(11.9) + expect(y0).toBeGreaterThanOrEqual(11.9) + expect(x1).toBeLessThanOrEqual(88.1) + expect(y1).toBeLessThanOrEqual(88.1) + expect((x0 + x1) / 2).toBeCloseTo(50, 6) + expect((y0 + y1) / 2).toBeCloseTo(50, 6) + } + expect([...kinds].sort()).toEqual(['capsule', 'circle', 'poly', 'ring']) + }) + + it('rejects an empty seed', () => { + expect(() => generateGlyph('')).toThrow('non-empty seed') + }) +}) diff --git a/apps/sim/lib/workspaces/glyph.ts b/apps/sim/lib/workspaces/glyph.ts new file mode 100644 index 00000000000..97af0988f1e --- /dev/null +++ b/apps/sim/lib/workspaces/glyph.ts @@ -0,0 +1,179 @@ +/** + * Deterministic workspace glyphs: a workspace id seeds one of three blob + * shapes (burst, ring, glyph) laid out in a 100×100 box. Callers draw the + * shapes under a goo filter (blur + alpha threshold) so touching parts melt + * together, the same technique as the chat thinking loader. + */ + +export type GlyphShape = + | { kind: 'circle'; cx: number; cy: number; r: number } + | { kind: 'capsule'; x1: number; y1: number; x2: number; y2: number; w: number } + | { kind: 'ring'; cx: number; cy: number; r: number; w: number } + | { kind: 'poly'; points: [number, number][] } + +interface Rng { + range: (lo: number, hi: number) => number + int: (lo: number, hi: number) => number + pick: (items: readonly T[]) => T + chance: (p: number) => boolean +} + +const TAU = Math.PI * 2 +const DEG = Math.PI / 180 + +function hashString(s: string): number { + let h = 1779033703 ^ s.length + for (let i = 0; i < s.length; i++) { + h = Math.imul(h ^ s.charCodeAt(i), 3432918353) + h = (h << 13) | (h >>> 19) + } + h = Math.imul(h ^ (h >>> 16), 2246822507) + h = Math.imul(h ^ (h >>> 13), 3266489909) + return (h ^ (h >>> 16)) >>> 0 +} + +/** mulberry32 seeded from a string hash. */ +function makeRng(seed: string): Rng { + let a = hashString(seed) + const next = () => { + a = (a + 0x6d2b79f5) | 0 + let t = Math.imul(a ^ (a >>> 15), 1 | a) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } + return { + range: (lo, hi) => lo + next() * (hi - lo), + int: (lo, hi) => Math.floor(lo + next() * (hi - lo + 1)), + pick: (items) => items[Math.floor(next() * items.length)], + chance: (p) => next() < p, + } +} + +const polar = (r: number, a: number): [number, number] => [ + 50 + r * Math.cos(a), + 50 + r * Math.sin(a), +] + +function burst(rng: Rng): GlyphShape[] { + const arms = rng.pick([3, 4, 4, 5, 6, 8]) + const length = rng.range(26, 38) + const width = rng.range(8, 13) + const shapes: GlyphShape[] = [{ kind: 'circle', cx: 50, cy: 50, r: rng.range(8, 16) }] + const knobs = rng.chance(0.5) + const knobR = rng.range(7, 11) + const knobGap = rng.pick([-6, -3, 0, 2, 4]) + const rotation = rng.range(0, 360) * DEG + for (let i = 0; i < arms; i++) { + const a = rotation + (i * TAU) / arms + const [x2, y2] = polar(length, a) + shapes.push({ kind: 'capsule', x1: 50, y1: 50, x2, y2, w: width }) + if (knobs) { + const [cx, cy] = polar(length + knobGap / 2, a) + shapes.push({ kind: 'circle', cx, cy, r: knobR }) + } + } + return shapes +} + +function ring(rng: Rng): GlyphShape[] { + const nubs = rng.int(0, 3) + const radius = rng.range(24, 30) + const thickness = rng.range(9, 13) + const inward = rng.chance(0.6) + const nubScale = rng.range(0.55, 0.8) + const centerDot = nubs === 0 || rng.chance(0.3) + const centerR = rng.range(6, 12) + const rotation = rng.range(0, 360) * DEG + const shapes: GlyphShape[] = [{ kind: 'ring', cx: 50, cy: 50, r: radius, w: thickness }] + for (let i = 0; i < nubs; i++) { + const [cx, cy] = polar( + radius + (inward ? -1 : 1) * thickness * 0.9, + rotation + (i * TAU) / nubs + ) + shapes.push({ kind: 'circle', cx, cy, r: thickness * nubScale }) + } + if (centerDot) shapes.push({ kind: 'circle', cx: 50, cy: 50, r: centerR }) + return shapes +} + +function glyph(rng: Rng): GlyphShape[] { + const knobs = rng.chance(0.7) + const sides = rng.pick([3, 3, 4, 5, 6]) + const knobR = rng.range(9, 14) + const inset = knobs ? 0.72 : 1 + const rotation = rng.range(0, 360) * DEG + const corners = Array.from({ length: sides }, (_, i) => polar(30, rotation + (i * TAU) / sides)) + const shapes: GlyphShape[] = [ + { + kind: 'poly', + points: corners.map(([x, y]) => [50 + (x - 50) * inset, 50 + (y - 50) * inset]), + }, + ] + if (knobs) for (const [cx, cy] of corners) shapes.push({ kind: 'circle', cx, cy, r: knobR }) + return shapes +} + +const BUILDERS = { burst, ring, glyph } as const +const KINDS = Object.keys(BUILDERS) as (keyof typeof BUILDERS)[] + +function bounds(s: GlyphShape): [number, number, number, number] { + switch (s.kind) { + case 'circle': + return [s.cx - s.r, s.cy - s.r, s.cx + s.r, s.cy + s.r] + case 'ring': { + const o = s.r + s.w / 2 + return [s.cx - o, s.cy - o, s.cx + o, s.cy + o] + } + case 'capsule': { + const h = s.w / 2 + return [ + Math.min(s.x1, s.x2) - h, + Math.min(s.y1, s.y2) - h, + Math.max(s.x1, s.x2) + h, + Math.max(s.y1, s.y2) + h, + ] + } + case 'poly': { + const xs = s.points.map((p) => p[0]) + const ys = s.points.map((p) => p[1]) + return [Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)] + } + } +} + +/** + * Centers the layout and scales it to at most 76 units across. Scale is + * capped at 1.1 because the gaps between parts are tuned to the goo filter's + * blur radius; enlarging them further would split parts that should neck. + */ +function fitToBox(shapes: GlyphShape[]): GlyphShape[] { + const boxes = shapes.map(bounds) + const x0 = Math.min(...boxes.map((b) => b[0])) + const y0 = Math.min(...boxes.map((b) => b[1])) + const x1 = Math.max(...boxes.map((b) => b[2])) + const y1 = Math.max(...boxes.map((b) => b[3])) + const k = Math.min(1.1, 76 / Math.max(x1 - x0, y1 - y0)) + const cx = (x0 + x1) / 2 + const cy = (y0 + y1) / 2 + const X = (x: number) => 50 + (x - cx) * k + const Y = (y: number) => 50 + (y - cy) * k + return shapes.map((s): GlyphShape => { + switch (s.kind) { + case 'circle': + return { kind: 'circle', cx: X(s.cx), cy: Y(s.cy), r: s.r * k } + case 'ring': + return { kind: 'ring', cx: X(s.cx), cy: Y(s.cy), r: s.r * k, w: s.w * k } + case 'capsule': + return { kind: 'capsule', x1: X(s.x1), y1: Y(s.y1), x2: X(s.x2), y2: Y(s.y2), w: s.w * k } + case 'poly': + return { kind: 'poly', points: s.points.map(([x, y]) => [X(x), Y(y)]) } + } + }) +} + +/** The glyph for a seed (a workspace id): same seed, same shapes, everywhere. */ +export function generateGlyph(seed: string): GlyphShape[] { + if (!seed) throw new Error('generateGlyph requires a non-empty seed') + const kind = makeRng(`type:${seed}`).pick(KINDS) + return fitToBox(BUILDERS[kind](makeRng(`${kind}:${seed}`))) +} diff --git a/apps/sim/lib/workspaces/initials.ts b/apps/sim/lib/workspaces/initials.ts deleted file mode 100644 index 1e590ae667e..00000000000 --- a/apps/sim/lib/workspaces/initials.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * The single-letter avatar initial for a workspace, ignoring the word - * "workspace" in its name (e.g. "Acme Workspace" → "A"). - */ -export function getWorkspaceInitial(name: string | undefined): string { - const stripped = (name ?? '').replace(/workspace/gi, '').trim() - return (stripped[0] || name?.[0] || 'W').toUpperCase() -} From a2f4994b00444775b4cc2d586903ddd146123a12 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 30 Sep 2026 18:22:11 -0700 Subject: [PATCH 2/3] improvement(workspaces): zoom workspace glyphs to fill the 16px tile --- apps/sim/components/identity-tile/identity-tile.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/sim/components/identity-tile/identity-tile.tsx b/apps/sim/components/identity-tile/identity-tile.tsx index a19584d66c2..e0567902fb5 100644 --- a/apps/sim/components/identity-tile/identity-tile.tsx +++ b/apps/sim/components/identity-tile/identity-tile.tsx @@ -41,6 +41,13 @@ const GLYPH_SIZE_CLASS = { /** The loader's goo: blur, then crush alpha so parts within the blur radius melt together. */ const GOO_ALPHA_MATRIX = '1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 19 -9' +/** + * Crops to the 76-unit span `generateGlyph` fits into (plus a small margin) so the + * glyph fills the tile. Zooming via the viewBox, not the geometry, keeps the gaps + * the goo blur is tuned to. + */ +const GLYPH_VIEW_BOX = '10 10 80 80' + function glyphShape(shape: GlyphShape, key: number) { switch (shape.kind) { case 'circle': @@ -89,7 +96,7 @@ function Glyph({ seed, className, slot, size }: GlyphProps) {