🚀 Socket Launch Week Day 5:Introducing Repository Access Permissions and Custom Roles.Learn more
Sign In

@shadowob/shared

Package Overview
Dependencies
Maintainers
1
Versions
77
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@shadowob/shared - npm Package Compare versions

Comparing version
0.3.4
to
0.4.0
+28
-7
package.json
{
"name": "@shadowob/shared",
"version": "0.3.4",
"version": "0.4.0",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": "./src/index.ts",
"./types": "./src/types/index.ts",
"./constants": "./src/constants/index.ts",
"./utils": "./src/utils/index.ts"
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./types": {
"import": "./dist/types/index.js",
"types": "./dist/types/index.d.ts"
},
"./constants": {
"import": "./dist/constants/index.js",
"types": "./dist/constants/index.d.ts"
},
"./utils": {
"import": "./dist/utils/index.js",
"types": "./dist/utils/index.d.ts"
}
},
"files": [
"dist"
],
"dependencies": {
"nanoid": "^5.1.7"
},
"devDependencies": {
"tsup": "^8.5.0",
"typescript": "^5.9.3"
},
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"test": "vitest run",

@@ -18,0 +39,0 @@ "test:watch": "vitest"

import { describe, expect, it } from 'vitest'
import { CLIENT_EVENTS, SERVER_EVENTS } from '../src/constants/events'
import { LIMITS } from '../src/constants/limits'
describe('CLIENT_EVENTS', () => {
it('should have all expected client events', () => {
expect(CLIENT_EVENTS.CHANNEL_JOIN).toBe('channel:join')
expect(CLIENT_EVENTS.CHANNEL_LEAVE).toBe('channel:leave')
expect(CLIENT_EVENTS.MESSAGE_SEND).toBe('message:send')
expect(CLIENT_EVENTS.MESSAGE_TYPING).toBe('message:typing')
expect(CLIENT_EVENTS.PRESENCE_UPDATE).toBe('presence:update')
})
it('should be readonly', () => {
const events = CLIENT_EVENTS
expect(Object.keys(events)).toHaveLength(5)
})
})
describe('SERVER_EVENTS', () => {
it('should have all expected server events', () => {
expect(SERVER_EVENTS.MESSAGE_NEW).toBe('message:new')
expect(SERVER_EVENTS.MESSAGE_UPDATE).toBe('message:update')
expect(SERVER_EVENTS.MESSAGE_DELETE).toBe('message:delete')
expect(SERVER_EVENTS.MEMBER_TYPING).toBe('member:typing')
expect(SERVER_EVENTS.MEMBER_JOIN).toBe('member:join')
expect(SERVER_EVENTS.MEMBER_LEAVE).toBe('member:leave')
expect(SERVER_EVENTS.PRESENCE_CHANGE).toBe('presence:change')
expect(SERVER_EVENTS.REACTION_ADD).toBe('reaction:add')
expect(SERVER_EVENTS.REACTION_REMOVE).toBe('reaction:remove')
expect(SERVER_EVENTS.NOTIFICATION_NEW).toBe('notification:new')
expect(SERVER_EVENTS.DM_MESSAGE_NEW).toBe('dm:message:new')
})
it('should have 11 server events', () => {
expect(Object.keys(SERVER_EVENTS)).toHaveLength(11)
})
})
describe('LIMITS', () => {
it('should define message limits', () => {
expect(LIMITS.MESSAGE_CONTENT_MAX).toBe(4000)
expect(LIMITS.MESSAGES_PER_PAGE).toBe(50)
})
it('should define username limits', () => {
expect(LIMITS.USERNAME_MIN).toBe(3)
expect(LIMITS.USERNAME_MAX).toBe(32)
})
it('should define file upload limit', () => {
expect(LIMITS.FILE_UPLOAD_MAX_SIZE).toBe(10 * 1024 * 1024)
})
it('should define server/channel limits', () => {
expect(LIMITS.SERVER_NAME_MAX).toBe(100)
expect(LIMITS.CHANNEL_NAME_MAX).toBe(100)
expect(LIMITS.SERVERS_PER_USER_MAX).toBe(100)
expect(LIMITS.CHANNELS_PER_SERVER_MAX).toBe(200)
})
it('should define invite code length', () => {
expect(LIMITS.INVITE_CODE_LENGTH).toBe(8)
})
it('should define password min length', () => {
expect(LIMITS.PASSWORD_MIN).toBe(8)
})
})
import { describe, expect, it } from 'vitest'
import { formatDate, generateInviteCode, isValidEmail, slugify } from '../src/utils'
describe('generateInviteCode', () => {
it('should generate a string of length 8', () => {
const code = generateInviteCode()
expect(code).toHaveLength(8)
})
it('should only contain alphanumeric characters', () => {
const code = generateInviteCode()
expect(code).toMatch(/^[A-Za-z0-9]+$/)
})
it('should generate unique codes', () => {
const codes = new Set(Array.from({ length: 100 }, () => generateInviteCode()))
expect(codes.size).toBe(100)
})
})
describe('formatDate', () => {
it('should format a Date object to ISO string', () => {
const date = new Date('2024-01-15T12:30:00Z')
expect(formatDate(date)).toBe('2024-01-15T12:30:00.000Z')
})
it('should format a date string to ISO string', () => {
const result = formatDate('2024-01-15T12:30:00Z')
expect(result).toBe('2024-01-15T12:30:00.000Z')
})
it('should handle various date string formats', () => {
const result = formatDate('2024-01-15')
expect(result).toContain('2024-01-15')
})
})
describe('isValidEmail', () => {
it('should return true for valid emails', () => {
expect(isValidEmail('user@shadowob.com')).toBe(true)
expect(isValidEmail('first.last@domain.org')).toBe(true)
expect(isValidEmail('user+tag@sub.domain.com')).toBe(true)
})
it('should return false for invalid emails', () => {
expect(isValidEmail('')).toBe(false)
expect(isValidEmail('user')).toBe(false)
expect(isValidEmail('user@')).toBe(false)
expect(isValidEmail('@domain.com')).toBe(false)
expect(isValidEmail('user @domain.com')).toBe(false)
expect(isValidEmail('user@domain')).toBe(false)
})
})
describe('slugify', () => {
it('should convert text to lowercase slug', () => {
expect(slugify('Hello World')).toBe('hello-world')
})
it('should replace special characters with hyphens', () => {
expect(slugify('Hello, World!')).toBe('hello-world')
})
it('should trim leading/trailing hyphens', () => {
expect(slugify(' Hello World ')).toBe('hello-world')
expect(slugify('---hello---')).toBe('hello')
})
it('should handle multiple consecutive special characters', () => {
expect(slugify('hello...world')).toBe('hello-world')
})
it('should keep numbers', () => {
expect(slugify('Version 2.0')).toBe('version-2-0')
})
it('should handle empty string', () => {
expect(slugify('')).toBe('')
})
})
// ─── Client → Server ───
export const CLIENT_EVENTS = {
CHANNEL_JOIN: 'channel:join',
CHANNEL_LEAVE: 'channel:leave',
MESSAGE_SEND: 'message:send',
MESSAGE_TYPING: 'message:typing',
PRESENCE_UPDATE: 'presence:update',
} as const
// ─── Server → Client ───
export const SERVER_EVENTS = {
MESSAGE_NEW: 'message:new',
MESSAGE_UPDATE: 'message:update',
MESSAGE_DELETE: 'message:delete',
MEMBER_TYPING: 'member:typing',
MEMBER_JOIN: 'member:join',
MEMBER_LEAVE: 'member:leave',
PRESENCE_CHANGE: 'presence:change',
REACTION_ADD: 'reaction:add',
REACTION_REMOVE: 'reaction:remove',
NOTIFICATION_NEW: 'notification:new',
DM_MESSAGE_NEW: 'dm:message:new',
} as const
export type ClientEvent = (typeof CLIENT_EVENTS)[keyof typeof CLIENT_EVENTS]
export type ServerEvent = (typeof SERVER_EVENTS)[keyof typeof SERVER_EVENTS]
export * from './events'
export * from './limits'
export const LIMITS = {
/** Max message content length */
MESSAGE_CONTENT_MAX: 4000,
/** Max username length */
USERNAME_MAX: 32,
/** Min username length */
USERNAME_MIN: 3,
/** Max display name length */
DISPLAY_NAME_MAX: 64,
/** Max server name length */
SERVER_NAME_MAX: 100,
/** Max channel name length */
CHANNEL_NAME_MAX: 100,
/** Max thread name length */
THREAD_NAME_MAX: 100,
/** Max file upload size (10MB) */
FILE_UPLOAD_MAX_SIZE: 10 * 1024 * 1024,
/** Messages per page (cursor pagination) */
MESSAGES_PER_PAGE: 50,
/** Max servers per user */
SERVERS_PER_USER_MAX: 100,
/** Max channels per server */
CHANNELS_PER_SERVER_MAX: 200,
/** Invite code length */
INVITE_CODE_LENGTH: 8,
/** Password min length */
PASSWORD_MIN: 8,
/** Max reactions per message per user */
REACTIONS_PER_MESSAGE_MAX: 20,
} as const
export * from './constants'
export * from './types'
export * from './utils'
export type AgentStatus = 'running' | 'stopped' | 'error'
export type AgentKernelType = 'claude-code' | 'cursor' | 'mcp-server' | 'custom'
export type AgentCapability =
| 'chat'
| 'code-gen'
| 'code-review'
| 'research'
| 'tools'
| 'file-access'
export interface Agent {
id: string
userId: string
kernelType: AgentKernelType
config: Record<string, unknown>
containerId: string | null
status: AgentStatus
ownerId: string
createdAt: string
updatedAt: string
user?: {
id: string
username: string
displayName: string
avatarUrl: string | null
}
capabilities?: AgentCapability[]
}
export interface CreateAgentRequest {
name: string
kernelType: AgentKernelType
config?: Record<string, unknown>
}
export interface AgentInfo {
id: string
name: string
kernelType: AgentKernelType
status: AgentStatus
capabilities: AgentCapability[]
}
export type ChannelType = 'text' | 'voice' | 'announcement'
export interface Channel {
id: string
name: string
type: ChannelType
serverId: string
topic: string | null
position: number
createdAt: string
updatedAt: string
/** Last message timestamp for sorting by activity */
lastMessageAt?: string | null
}
export interface CreateChannelRequest {
name: string
type?: ChannelType
topic?: string
}
export interface UpdateChannelRequest {
name?: string
topic?: string
position?: number
}
/** Channel sorting options */
export type ChannelSortBy = 'createdAt' | 'updatedAt' | 'lastMessageAt' | 'lastAccessedAt'
export type ChannelSortDirection = 'asc' | 'desc'
export interface ChannelSortOptions {
by: ChannelSortBy
direction: ChannelSortDirection
}
export type FriendshipStatus = 'pending' | 'accepted' | 'blocked'
export interface Friendship {
id: string
requesterId: string
addresseeId: string
status: FriendshipStatus
createdAt: string
updatedAt: string
}
export type FriendSource = 'friend' | 'owned_claw' | 'rented_claw'
export interface FriendEntry {
friendshipId: string
/** Where this friend entry comes from */
source: FriendSource
user: {
id: string
username: string
displayName: string | null
avatarUrl: string | null
status: string
isBot: boolean
}
createdAt: string
}
export * from './agent.types'
export * from './channel.types'
export * from './friendship.types'
export * from './message.types'
export * from './server.types'
export * from './user.types'
export interface Message {
id: string
content: string
channelId: string
authorId: string
threadId: string | null
replyToId: string | null
isEdited: boolean
isPinned: boolean
createdAt: string
updatedAt: string
author?: {
id: string
username: string
displayName: string
avatarUrl: string | null
isBot: boolean
}
attachments?: Attachment[]
reactions?: ReactionGroup[]
}
export interface Attachment {
id: string
messageId: string
filename: string
url: string
contentType: string
size: number
width: number | null
height: number | null
createdAt: string
}
export interface ReactionGroup {
emoji: string
count: number
userIds: string[]
}
export interface Thread {
id: string
name: string
channelId: string
parentMessageId: string
creatorId: string
isArchived: boolean
createdAt: string
updatedAt: string
}
export interface SendMessageRequest {
content: string
threadId?: string
replyToId?: string
}
export interface UpdateMessageRequest {
content: string
}
export type NotificationType = 'mention' | 'reply' | 'dm' | 'system'
export interface Notification {
id: string
userId: string
type: NotificationType
title: string
body: string | null
referenceId: string | null
referenceType: string | null
isRead: boolean
createdAt: string
}
export interface DmChannel {
id: string
userAId: string
userBId: string
lastMessageAt: string | null
createdAt: string
otherUser?: {
id: string
username: string
displayName: string
avatarUrl: string | null
status: string
}
}
/** DM message — mirrors channel Message but scoped to DM channels */
export interface DmMessage {
id: string
content: string
dmChannelId: string
authorId: string
replyToId: string | null
isEdited: boolean
createdAt: string
updatedAt: string
author?: {
id: string
username: string
displayName: string
avatarUrl: string | null
isBot: boolean
}
attachments?: DmAttachment[]
reactions?: ReactionGroup[]
}
export interface DmAttachment {
id: string
dmMessageId: string
filename: string
url: string
contentType: string
size: number
width: number | null
height: number | null
createdAt: string
}
export interface UpdateDmMessageRequest {
content: string
}
export interface Server {
id: string
name: string
iconUrl: string | null
ownerId: string
inviteCode: string
createdAt: string
updatedAt: string
}
export interface CreateServerRequest {
name: string
iconUrl?: string
}
export interface UpdateServerRequest {
name?: string
iconUrl?: string
}
export type MemberRole = 'owner' | 'admin' | 'member'
export interface Member {
id: string
userId: string
serverId: string
role: MemberRole
nickname: string | null
joinedAt: string
user?: {
id: string
username: string
displayName: string
avatarUrl: string | null
status: string
isBot: boolean
}
}
export type UserStatus = 'online' | 'idle' | 'dnd' | 'offline'
export interface User {
id: string
email: string
username: string
displayName: string
avatarUrl: string | null
status: UserStatus
isBot: boolean
createdAt: string
updatedAt: string
}
export interface UserProfile {
id: string
username: string
displayName: string
avatarUrl: string | null
status: UserStatus
isBot: boolean
}
export interface LoginRequest {
email: string
password: string
}
export interface RegisterRequest {
email: string
username: string
displayName: string
password: string
}
export interface AuthResponse {
user: User
accessToken: string
refreshToken: string
}
export type CatPattern = 'none' | 'tabby' | 'tuxedo' | 'siamese' | 'calico' | 'bicolor'
export type CatExpression =
| 'smile'
| 'open'
| 'flat'
| 'sad'
| 'surprised'
| 'kawaii'
| 'winking'
| 'smirk'
export type CatDecoration = 'none' | 'glasses' | 'blush' | 'scar' | 'flower' | 'fish' | 'headband'
export type BgPattern = 'none' | 'dots' | 'stripes' | 'grid' | 'stars'
export interface CatConfig {
bg: string
bgPattern: BgPattern
body: string
pattern: CatPattern
patternColor: string
eyeColor: string
expression: CatExpression
decoration: CatDecoration
}
const COLORS = {
bg: [
'transparent',
'#1e1f22',
'#313338',
'#5865F2',
'#23a559',
'#da373c',
'#f472b6',
'#3b82f6',
'#fbbf24',
'#a855f7',
'#1abc9c',
'#f39c12',
'#e74c3c',
],
body: [
'#2d2d30',
'#e8842c',
'#e8e8e8',
'#7a7a80',
'#d4a574',
'#6b8094',
'#f472b6',
'#c8d6e5',
'#3e2723',
'#bdc3c7',
'#ffb8b8',
],
eyes: [
'#f8e71c',
'#00f3ff',
'#4ade80',
'#60a5fa',
'#a855f7',
'#fbbf24',
'#f87171',
'#ffc0cb',
'#1dd1a1',
'#e056fd',
],
pattern: ['#1a1a1c', '#ffffff', '#5a4a46', '#3d3d40', '#9a9aa0', '#d1ccc0', '#2d3436'],
}
function getRandomElement<T>(arr: readonly T[]): T {
return arr[Math.floor(Math.random() * arr.length)]!
}
export function generateRandomCatConfig(): CatConfig {
return {
bg: getRandomElement(COLORS.bg),
bgPattern: getRandomElement(['none', 'dots', 'stripes', 'grid', 'stars'] as BgPattern[]),
body: getRandomElement(COLORS.body),
pattern: getRandomElement([
'none',
'tabby',
'tuxedo',
'siamese',
'calico',
'bicolor',
] as CatPattern[]),
patternColor: getRandomElement(COLORS.pattern),
eyeColor: getRandomElement(COLORS.eyes),
expression: getRandomElement([
'smile',
'open',
'flat',
'sad',
'surprised',
'kawaii',
'winking',
'smirk',
] as CatExpression[]),
decoration: getRandomElement([
'none',
'glasses',
'blush',
'scar',
'flower',
'fish',
'headband',
] as CatDecoration[]),
}
}
export function renderCatSvg(config: CatConfig): string {
const { bg, bgPattern, body, pattern, patternColor, eyeColor, expression, decoration } = config
const stroke = '#1a1a1c'
let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">`
if (bg && bg !== 'transparent') {
svg += `<rect width="100" height="100" fill="${bg}" rx="20" />`
const pColor = `rgba(255,255,255,0.15)`
const cleanBg = bg.replace('#', '')
if (bgPattern === 'dots') {
svg += `<pattern id="p-${cleanBg}-dots" x="0" y="0" width="10" height="10" patternUnits="userSpaceOnUse"><circle cx="2" cy="2" r="2" fill="${pColor}"/></pattern><rect width="100" height="100" fill="url(#p-${cleanBg}-dots)" rx="20" />`
} else if (bgPattern === 'stripes') {
svg += `<pattern id="p-${cleanBg}-str" x="0" y="0" width="12" height="12" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><line x1="0" y1="0" x2="0" y2="12" stroke="${pColor}" stroke-width="4"/></pattern><rect width="100" height="100" fill="url(#p-${cleanBg}-str)" rx="20" />`
} else if (bgPattern === 'grid') {
svg += `<pattern id="p-${cleanBg}-grid" width="16" height="16" patternUnits="userSpaceOnUse"><path d="M 16 0 L 0 0 0 16" fill="none" stroke="${pColor}" stroke-width="1"/></pattern><rect width="100" height="100" fill="url(#p-${cleanBg}-grid)" rx="20" />`
} else if (bgPattern === 'stars') {
svg += `<pattern id="p-${cleanBg}-star" width="20" height="20" patternUnits="userSpaceOnUse"><path d="M10,2 L12,8 L18,8 L13,12 L15,18 L10,14 L5,18 L7,12 L2,8 L8,8 Z" fill="${pColor}" transform="scale(0.5) translate(5,5)"/></pattern><rect width="100" height="100" fill="url(#p-${cleanBg}-star)" rx="20" />`
}
}
svg += `<ellipse cx="50" cy="85" rx="30" ry="6" fill="rgba(0,0,0,0.2)"/>`
// Ears
svg += `<path d="M22,45 C15,22 28,18 34,22 C38,25 40,38 40,38" fill="${body}" stroke="${stroke}" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>`
svg += `<path d="M78,45 C85,22 72,18 66,22 C62,25 60,38 60,38" fill="${body}" stroke="${stroke}" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>`
svg += `<path d="M26,38 C22,26 29,24 33,26 C35,28 36,34 36,34" fill="#ffb8b8" opacity="0.8"/>`
svg += `<path d="M74,38 C78,26 71,24 67,26 C65,28 64,34 64,34" fill="#ffb8b8" opacity="0.8"/>`
// Face
svg += `<ellipse cx="50" cy="58" rx="38" ry="30" fill="${body}" stroke="${stroke}" stroke-width="2.5"/>`
// Patterns
if (pattern === 'tabby') {
svg += `<path d="M50,30 L50,40 M42,32 L46,39 M58,32 L54,39" stroke="${patternColor}" stroke-width="3" stroke-linecap="round" opacity="0.7"/>`
svg += `<path d="M18,55 L26,57 M20,62 L27,62" stroke="${patternColor}" stroke-width="2.5" stroke-linecap="round" opacity="0.7"/>`
svg += `<path d="M82,55 L74,57 M80,62 L73,62" stroke="${patternColor}" stroke-width="2.5" stroke-linecap="round" opacity="0.7"/>`
} else if (pattern === 'tuxedo') {
svg += `<path d="M50,56 C30,68 28,88 50,88 C72,88 70,68 50,56" fill="${patternColor}" opacity="0.95"/>`
svg += `<ellipse cx="50" cy="65" rx="16" ry="12" fill="${patternColor}" opacity="0.95"/>`
} else if (pattern === 'siamese') {
svg += `<ellipse cx="50" cy="62" rx="20" ry="16" fill="${patternColor}" opacity="0.6"/>`
} else if (pattern === 'calico') {
svg += `<path d="M25,40 Q35,30 45,45 Q35,55 25,40" fill="${patternColor}" opacity="0.8"/>`
svg += `<path d="M75,45 Q65,60 55,50 Q65,35 75,45" fill="#e8842c" opacity="0.8"/>`
} else if (pattern === 'bicolor') {
svg += `<path d="M12,58 Q30,30 50,40 Q60,70 50,88 Q12,88 12,58 Z" fill="${patternColor}" opacity="0.8"/>`
}
if (decoration === 'blush') {
svg += `<ellipse cx="28" cy="62" rx="5" ry="3" fill="#ff7675" opacity="0.7"/>`
svg += `<ellipse cx="72" cy="62" rx="5" ry="3" fill="#ff7675" opacity="0.7"/>`
}
if (decoration === 'scar') {
svg += `<path d="M28,42 L40,52 M30,48 L35,43 M34,51 L39,46 M32,53 L37,48" stroke="#d63031" stroke-width="1.5" stroke-linecap="round"/>`
}
// Eyes
const drawEye = (cx: number, cy: number, lookDir: number = 0) => {
if (expression === 'kawaii') {
return `<path d="M${cx - 8},${cy} Q${cx},${cy - 8} ${cx + 8},${cy} Q${cx},${cy - 3} ${cx - 8},${cy}" fill="${eyeColor}" stroke="${stroke}" stroke-width="1.5"/><circle cx="${cx + lookDir}" cy="${cy - 2}" r="3" fill="white"/><circle cx="${cx - 3 + lookDir}" cy="${cy}" r="1" fill="white"/>`
} else if (expression === 'winking' && cx > 50) {
return `<path d="M${cx - 7},${cy + 2} Q${cx},${cy - 4} ${cx + 7},${cy + 2}" fill="none" stroke="${stroke}" stroke-width="2.5" stroke-linecap="round"/>`
} else if (expression === 'surprised') {
return `<circle cx="${cx}" cy="${cy}" r="8" fill="white" stroke="${stroke}" stroke-width="1.5"/><circle cx="${cx}" cy="${cy}" r="3" fill="${eyeColor}"/>`
} else {
return `<circle cx="${cx}" cy="${cy}" r="7.5" fill="${eyeColor}" stroke="${stroke}" stroke-width="1.5"/><circle cx="${cx - 2 + lookDir}" cy="${cy - 2}" r="2.5" fill="white"/><circle cx="${cx + 2 + lookDir}" cy="${cy + 1}" r="1" fill="white"/>`
}
}
if (expression === 'smirk') {
svg += `<path d="M26,50 L42,50" stroke="${stroke}" stroke-width="2.5" stroke-linecap="round"/>`
svg += drawEye(66, 52, 2)
} else {
svg += drawEye(34, 52, 0)
svg += drawEye(66, 52, 0)
}
if (decoration === 'glasses') {
svg += `<circle cx="34" cy="52" r="11" fill="rgba(255,255,255,0.2)" stroke="#2d3436" stroke-width="2.5"/>`
svg += `<circle cx="66" cy="52" r="11" fill="rgba(255,255,255,0.2)" stroke="#2d3436" stroke-width="2.5"/>`
svg += `<path d="M45,50 Q50,48 55,50" fill="none" stroke="#2d3436" stroke-width="2.5" stroke-linecap="round"/>`
}
// Nose
svg += `<path d="M47,62 L53,62 L50,65 Z" fill="#ff9ff3" stroke="${stroke}" stroke-width="1" stroke-linejoin="round"/>`
// Mouth
if (expression === 'smile' || expression === 'kawaii' || expression === 'winking') {
svg += `<path d="M42,67 Q46,72 50,67 M50,67 Q54,72 58,67" fill="none" stroke="${stroke}" stroke-width="2" stroke-linecap="round"/>`
} else if (expression === 'open') {
svg += `<path d="M46,67 Q50,75 54,67 Z" fill="#ff7675" stroke="${stroke}" stroke-width="1.5" stroke-linejoin="round"/>`
} else if (expression === 'flat') {
svg += `<path d="M47,68 L53,68" stroke="${stroke}" stroke-width="2" stroke-linecap="round"/>`
} else if (expression === 'sad') {
svg += `<path d="M43,70 Q50,64 57,70" fill="none" stroke="${stroke}" stroke-width="2" stroke-linecap="round"/>`
} else if (expression === 'surprised') {
svg += `<circle cx="50" cy="70" r="3" fill="#1a1a1c"/>`
} else if (expression === 'smirk') {
svg += `<path d="M46,67 Q52,69 56,64" fill="none" stroke="${stroke}" stroke-width="2" stroke-linecap="round"/>`
}
// Whiskers
svg += `<path d="M28,62 L15,60 M28,65 L14,66" stroke="${stroke}" stroke-width="1.5" stroke-linecap="round" opacity="0.6"/>`
svg += `<path d="M72,62 L85,60 M72,65 L86,66" stroke="${stroke}" stroke-width="1.5" stroke-linecap="round" opacity="0.6"/>`
// Decorations
if (decoration === 'headband') {
svg += `<path d="M16,35 Q50,20 84,35" fill="none" stroke="#e17055" stroke-width="6" stroke-linecap="round"/>`
svg += `<path d="M50,15 L60,25 L50,28 L40,25 Z" fill="#fab1a0" stroke="#e17055" stroke-width="2"/>`
} else if (decoration === 'flower') {
svg += `<path d="M75,30 Q80,20 85,30 Q95,25 85,35 Q90,45 80,40 Q70,45 75,35 Q65,25 75,30 Z" fill="#ffeaa7" stroke="#fdcb6e" stroke-width="1.5"/>`
svg += `<circle cx="80" cy="33" r="3" fill="#d63031"/>`
} else if (decoration === 'fish') {
svg += `<path d="M40,82 Q50,75 60,82 L65,78 L65,86 L60,82 Q50,89 40,82 Z" fill="#81ecec" stroke="${stroke}" stroke-width="1.5"/>`
svg += `<circle cx="45" cy="81" r="1" fill="${stroke}"/>`
}
svg += `</svg>`
return `data:image/svg+xml,${encodeURIComponent(svg.replace(/\n\s*/g, ''))}`
}
import { customAlphabet } from 'nanoid'
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
export const generateInviteCode = customAlphabet(alphabet, 8)
export function formatDate(date: string | Date): string {
const d = typeof date === 'string' ? new Date(date) : date
return d.toISOString()
}
export function isValidEmail(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
}
export function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
export * from './avatar-generator'
export * from './pixel-cats'
/**
* Pixel Art Cat Avatar System
* 8 unique cat variants with distinct color schemes inspired by the Shadow logo
*/
interface CatColors {
body: string
stroke: string
earInner: string
eyeL: string
eyeR: string
nose: string
}
const variants: CatColors[] = [
// 0: Shadow — Black cat (logo style)
{
body: '#2d2d30',
stroke: '#1a1a1c',
earInner: '#3d3d40',
eyeL: '#f8e71c',
eyeR: '#00f3ff',
nose: '#3a2a26',
},
// 1: Mikan — Orange tabby
{
body: '#e8842c',
stroke: '#1a1a1c',
earInner: '#f5a623',
eyeL: '#4ade80',
eyeR: '#4ade80',
nose: '#d46b1a',
},
// 2: Yuki — White cat
{
body: '#e8e8e8',
stroke: '#a0a0a0',
earInner: '#ffc0cb',
eyeL: '#60a5fa',
eyeR: '#60a5fa',
nose: '#f5a0b0',
},
// 3: Haiiro — Gray cat
{
body: '#7a7a80',
stroke: '#4a4a50',
earInner: '#9a9aa0',
eyeL: '#fbbf24',
eyeR: '#fbbf24',
nose: '#5a4a46',
},
// 4: Tuxedo — Black & white accents
{
body: '#2d2d30',
stroke: '#1a1a1c',
earInner: '#e0e0e0',
eyeL: '#22c55e',
eyeR: '#22c55e',
nose: '#3a2a26',
},
// 5: Mocha — Cream/beige
{
body: '#d4a574',
stroke: '#8b6914',
earInner: '#e8c9a0',
eyeL: '#d97706',
eyeR: '#d97706',
nose: '#a0705a',
},
// 6: Blue — Russian blue
{
body: '#6b8094',
stroke: '#3d5060',
earInner: '#8ba0b4',
eyeL: '#22c55e',
eyeR: '#22c55e',
nose: '#5a6a76',
},
// 7: Sakura — Fantasy pink
{
body: '#f472b6',
stroke: '#be185d',
earInner: '#f9a8d4',
eyeL: '#a855f7',
eyeR: '#c084fc',
nose: '#d44a8c',
},
]
function makeCatSvg(c: CatColors): string {
return [
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">',
`<path d="M22,45 Q15,22 28,22 Q34,22 40,38" fill="${c.body}" stroke="${c.stroke}" stroke-width="2.5" stroke-linecap="round"/>`,
`<path d="M78,45 Q85,22 72,22 Q66,22 60,38" fill="${c.body}" stroke="${c.stroke}" stroke-width="2.5" stroke-linecap="round"/>`,
`<path d="M26,38 Q22,26 29,26 Q33,26 36,34" fill="${c.earInner}" opacity="0.5"/>`,
`<path d="M74,38 Q78,26 71,26 Q67,26 64,34" fill="${c.earInner}" opacity="0.5"/>`,
`<ellipse cx="50" cy="58" rx="36" ry="28" fill="${c.body}" stroke="${c.stroke}" stroke-width="2.5"/>`,
`<circle cx="35" cy="52" r="7" fill="${c.eyeL}" stroke="${c.stroke}" stroke-width="1.5"/>`,
`<circle cx="33" cy="49" r="2.5" fill="white"/>`,
`<circle cx="65" cy="52" r="7" fill="${c.eyeR}" stroke="${c.stroke}" stroke-width="1.5"/>`,
`<circle cx="63" cy="49" r="2.5" fill="white"/>`,
`<ellipse cx="50" cy="62" rx="3.5" ry="2.2" fill="${c.nose}"/>`,
`<path d="M42,67 Q46,72 50,67" fill="none" stroke="${c.stroke}" stroke-width="2" stroke-linecap="round"/>`,
`<path d="M50,67 Q54,72 58,67" fill="none" stroke="${c.stroke}" stroke-width="2" stroke-linecap="round"/>`,
'</svg>',
].join('')
}
const catDataUris = variants.map((v) => {
const svg = makeCatSvg(v)
return `data:image/svg+xml,${encodeURIComponent(svg)}`
})
/** Get avatar data URI by index (0-7) */
export function getCatAvatar(index: number): string {
return catDataUris[index % catDataUris.length]!
}
/** Get deterministic avatar by user ID string */
export function getCatAvatarByUserId(userId: string): string {
let hash = 0
for (let i = 0; i < userId.length; i++) {
hash = ((hash << 5) - hash + userId.charCodeAt(i)) | 0
}
return getCatAvatar(Math.abs(hash) % catDataUris.length)
}
/** Get all cat avatars for selection UI */
export function getAllCatAvatars(): { index: number; name: string; dataUri: string }[] {
const names = ['影子', '蜜柑', '小雪', '灰灰', '燕尾服', '摩卡', '蓝蓝', '小樱']
return catDataUris.map((uri, i) => ({ index: i, name: names[i]!, dataUri: uri }))
}
/** Get the raw SVG string for a cat variant (useful for generating PNG files) */
export function getCatSvgString(index: number): string {
return makeCatSvg(variants[index % variants.length]!)
}
export const CAT_AVATAR_COUNT = variants.length
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist"
},
"include": ["src"]
}