/* * Copyright (C) 2026 Fluxer Contributors * * This file is part of Fluxer. * * Fluxer is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Fluxer is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with Fluxer. If not, see . */ import fs from 'node:fs'; import path from 'node:path'; import {fileURLToPath} from 'node:url'; import {TYPING_BRIDGE_RIGHT_SHIFT_RATIO, TYPING_WIDTH_MULTIPLIER} from '@app/components/uikit/TypingConstants'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); type AvatarSize = 16 | 20 | 24 | 32 | 36 | 40 | 44 | 48 | 56 | 80 | 120; interface StatusConfig { statusSize: number; cutoutRadius: number; cutoutCenter: number; } const STATUS_CONFIG: Record = { 16: {statusSize: 10, cutoutRadius: 5, cutoutCenter: 13}, 20: {statusSize: 10, cutoutRadius: 5, cutoutCenter: 17}, 24: {statusSize: 10, cutoutRadius: 7, cutoutCenter: 20}, 32: {statusSize: 10, cutoutRadius: 8, cutoutCenter: 27}, 36: {statusSize: 10, cutoutRadius: 8, cutoutCenter: 30}, 40: {statusSize: 12, cutoutRadius: 9, cutoutCenter: 34}, 44: {statusSize: 14, cutoutRadius: 10, cutoutCenter: 38}, 48: {statusSize: 14, cutoutRadius: 10, cutoutCenter: 42}, 56: {statusSize: 16, cutoutRadius: 11, cutoutCenter: 49}, 80: {statusSize: 16, cutoutRadius: 14, cutoutCenter: 68}, 120: {statusSize: 24, cutoutRadius: 20, cutoutCenter: 100}, }; const DESIGN_RULES = { mobileAspectRatio: 0.75, mobileCornerRadius: 0.12, mobileScreenWidth: 0.72, mobileScreenHeight: 0.7, mobileScreenY: 0.06, mobileWheelRadius: 0.13, mobileWheelY: 0.83, mobilePhoneExtraHeight: 2, mobileDisplayExtraHeight: 2, mobileDisplayExtraWidthPerSide: 2, idle: { cutoutRadiusRatio: 0.7, cutoutOffsetRatio: 0.35, }, dnd: { barWidthRatio: 1.3, barHeightRatio: 0.4, minBarHeight: 2, }, offline: { innerRingRatio: 0.6, }, } as const; const MOBILE_SCREEN_WIDTH_TRIM_PX = 4; const MOBILE_SCREEN_HEIGHT_TRIM_PX = 2; const MOBILE_SCREEN_X_OFFSET_PX = 0; const MOBILE_SCREEN_Y_OFFSET_PX = 3; function getStatusConfig(avatarSize: number): StatusConfig { if (STATUS_CONFIG[avatarSize]) { return STATUS_CONFIG[avatarSize]; } const sizes = Object.keys(STATUS_CONFIG) .map(Number) .sort((a, b) => a - b); const closest = sizes.reduce((prev, curr) => Math.abs(curr - avatarSize) < Math.abs(prev - avatarSize) ? curr : prev, ); return STATUS_CONFIG[closest]; } interface StatusGeometry { size: number; cx: number; cy: number; innerRadius: number; outerRadius: number; borderWidth: number; } interface MobileStatusGeometry extends StatusGeometry { phoneWidth: number; phoneHeight: number; phoneX: number; phoneY: number; phoneRx: number; bezelHeight: number; } function calculateStatusGeometry(avatarSize: number, isMobile: boolean = false): StatusGeometry | MobileStatusGeometry { const config = getStatusConfig(avatarSize); const statusSize = config.statusSize; const cutoutCenter = config.cutoutCenter; const cutoutRadius = config.cutoutRadius; const innerRadius = statusSize / 2; const outerRadius = cutoutRadius; const borderWidth = cutoutRadius - innerRadius; const baseGeometry = { size: statusSize, cx: cutoutCenter, cy: cutoutCenter, innerRadius, outerRadius, borderWidth, }; if (!isMobile) { return baseGeometry; } const phoneWidth = statusSize; const phoneHeight = Math.round(phoneWidth / DESIGN_RULES.mobileAspectRatio) + DESIGN_RULES.mobilePhoneExtraHeight; const phoneRx = Math.round(phoneWidth * DESIGN_RULES.mobileCornerRadius); const bezelHeight = Math.max(1, Math.round(phoneHeight * 0.05)); const phoneX = cutoutCenter - phoneWidth / 2; const phoneY = cutoutCenter - phoneHeight / 2; return { ...baseGeometry, phoneWidth, phoneHeight, phoneX, phoneY, phoneRx, bezelHeight, }; } function generateAvatarMaskDefault(size: number): string { const r = size / 2; return ``; } function generateAvatarMaskStatusRound(size: number): string { const r = size / 2; const status = calculateStatusGeometry(size); return `( <> )`; } function generateAvatarMaskStatusTyping(size: number): string { const r = size / 2; const status = calculateStatusGeometry(size); const typingWidth = Math.round(status.size * TYPING_WIDTH_MULTIPLIER); const typingHeight = status.size; const typingRx = status.outerRadius; const typingExtension = Math.max(0, typingWidth - status.size); const typingBridgeShift = typingExtension * TYPING_BRIDGE_RIGHT_SHIFT_RATIO; const x = status.cx - typingWidth / 2 + typingBridgeShift; const y = status.cy - typingHeight / 2; return `( <> )`; } function generateMobilePhoneMask(mobileStatus: MobileStatusGeometry): string { const displayExtraHeight = DESIGN_RULES.mobileDisplayExtraHeight; const displayExtraWidthPerSide = DESIGN_RULES.mobileDisplayExtraWidthPerSide; const screenWidth = mobileStatus.phoneWidth * DESIGN_RULES.mobileScreenWidth + displayExtraWidthPerSide * 2 - MOBILE_SCREEN_WIDTH_TRIM_PX; const screenHeight = mobileStatus.phoneHeight * DESIGN_RULES.mobileScreenHeight + displayExtraHeight - MOBILE_SCREEN_HEIGHT_TRIM_PX; const screenX = mobileStatus.phoneX + (mobileStatus.phoneWidth - screenWidth) / 2 + MOBILE_SCREEN_X_OFFSET_PX; const screenY = mobileStatus.phoneY + mobileStatus.phoneHeight * DESIGN_RULES.mobileScreenY - displayExtraHeight / 2 + MOBILE_SCREEN_Y_OFFSET_PX; const screenRx = Math.min(screenWidth, screenHeight) * 0.1; const wheelRadius = mobileStatus.phoneWidth * DESIGN_RULES.mobileWheelRadius; const wheelCx = mobileStatus.phoneX + mobileStatus.phoneWidth / 2; const wheelCy = mobileStatus.phoneY + mobileStatus.phoneHeight * DESIGN_RULES.mobileWheelY; return `( <> )`; } function generateStatusOnline(size: number, isMobile: boolean = false): string { const status = calculateStatusGeometry(size, isMobile); if (!isMobile) { return ``; } return generateMobilePhoneMask(status as MobileStatusGeometry); } function generateStatusIdle(size: number, isMobile: boolean = false): string { const status = calculateStatusGeometry(size, isMobile); if (!isMobile) { const cutoutRadius = Math.round(status.outerRadius * DESIGN_RULES.idle.cutoutRadiusRatio); const cutoutOffsetDistance = Math.round(status.outerRadius * DESIGN_RULES.idle.cutoutOffsetRatio); const cutoutCx = status.cx - cutoutOffsetDistance; const cutoutCy = status.cy - cutoutOffsetDistance; return `( <> )`; } return generateMobilePhoneMask(status as MobileStatusGeometry); } function generateStatusDnd(size: number, isMobile: boolean = false): string { const status = calculateStatusGeometry(size, isMobile); if (!isMobile) { const barWidth = Math.round(status.outerRadius * DESIGN_RULES.dnd.barWidthRatio); const rawBarHeight = status.outerRadius * DESIGN_RULES.dnd.barHeightRatio; const barHeight = Math.max(DESIGN_RULES.dnd.minBarHeight, Math.round(rawBarHeight)); const barX = status.cx - barWidth / 2; const barY = status.cy - barHeight / 2; const barRx = barHeight / 2; return `( <> )`; } return generateMobilePhoneMask(status as MobileStatusGeometry); } function generateStatusOffline(size: number): string { const status = calculateStatusGeometry(size); const innerRadius = Math.round(status.innerRadius * DESIGN_RULES.offline.innerRingRatio); return `( <> )`; } function generateStatusTyping(size: number): string { const status = calculateStatusGeometry(size); const typingWidth = Math.round(status.size * TYPING_WIDTH_MULTIPLIER); const typingHeight = status.size; const rx = status.outerRadius; const typingExtension = Math.max(0, typingWidth - status.size); const typingBridgeShift = typingExtension * TYPING_BRIDGE_RIGHT_SHIFT_RATIO; const x = status.cx - typingWidth / 2 + typingBridgeShift; const y = status.cy - typingHeight / 2; return ``; } const SIZES: Array = [16, 20, 24, 32, 36, 40, 44, 48, 56, 80, 120]; let output = `// @generated - DO NOT EDIT MANUALLY // Run: pnpm generate:masks type AvatarSize = ${SIZES.join(' | ')}; interface MaskDefinition { viewBox: string; content: React.ReactElement; } interface MaskSet { avatarDefault: MaskDefinition; avatarStatusRound: MaskDefinition; avatarStatusTyping: MaskDefinition; statusOnline: MaskDefinition; statusOnlineMobile: MaskDefinition; statusIdle: MaskDefinition; statusIdleMobile: MaskDefinition; statusDnd: MaskDefinition; statusDndMobile: MaskDefinition; statusOffline: MaskDefinition; statusTyping: MaskDefinition; } export const AVATAR_MASKS: Record = { `; for (const size of SIZES) { output += ` ${size}: { avatarDefault: { viewBox: '0 0 ${size} ${size}', content: ${generateAvatarMaskDefault(size)}, }, avatarStatusRound: { viewBox: '0 0 ${size} ${size}', content: ${generateAvatarMaskStatusRound(size)}, }, avatarStatusTyping: { viewBox: '0 0 ${size} ${size}', content: ${generateAvatarMaskStatusTyping(size)}, }, statusOnline: { viewBox: '0 0 ${size} ${size}', content: ${generateStatusOnline(size, false)}, }, statusOnlineMobile: { viewBox: '0 0 ${size} ${size}', content: ${generateStatusOnline(size, true)}, }, statusIdle: { viewBox: '0 0 ${size} ${size}', content: ${generateStatusIdle(size, false)}, }, statusIdleMobile: { viewBox: '0 0 ${size} ${size}', content: ${generateStatusIdle(size, true)}, }, statusDnd: { viewBox: '0 0 ${size} ${size}', content: ${generateStatusDnd(size, false)}, }, statusDndMobile: { viewBox: '0 0 ${size} ${size}', content: ${generateStatusDnd(size, true)}, }, statusOffline: { viewBox: '0 0 ${size} ${size}', content: ${generateStatusOffline(size)}, }, statusTyping: { viewBox: '0 0 ${size} ${size}', content: ${generateStatusTyping(size)}, }, }, `; } output += `} as const; export const SVGMasks = () => ( `; for (const size of SIZES) { const status = calculateStatusGeometry(size, false); const mobileStatus = calculateStatusGeometry(size, true) as MobileStatusGeometry; const cx = status.cx / size; const cy = status.cy / size; const r = status.outerRadius / size; const idleCutoutR = Math.round(status.outerRadius * DESIGN_RULES.idle.cutoutRadiusRatio) / size; const idleCutoutOffset = Math.round(status.outerRadius * DESIGN_RULES.idle.cutoutOffsetRatio) / size; const idleCutoutCx = cx - idleCutoutOffset; const idleCutoutCy = cy - idleCutoutOffset; const dndBarWidth = Math.round(status.outerRadius * DESIGN_RULES.dnd.barWidthRatio) / size; const dndBarHeight = Math.max(DESIGN_RULES.dnd.minBarHeight, Math.round(status.outerRadius * DESIGN_RULES.dnd.barHeightRatio)) / size; const dndBarX = cx - dndBarWidth / 2; const dndBarY = cy - dndBarHeight / 2; const dndBarRx = dndBarHeight / 2; const offlineInnerR = Math.round(status.innerRadius * DESIGN_RULES.offline.innerRingRatio) / size; const typingWidthPx = Math.round(status.size * TYPING_WIDTH_MULTIPLIER); const typingExtensionPx = Math.max(0, typingWidthPx - status.size); const typingBridgeShift = (typingExtensionPx * TYPING_BRIDGE_RIGHT_SHIFT_RATIO) / size; const typingWidth = typingWidthPx / size; const typingHeight = status.size / size; const typingX = cx - typingWidth / 2 + typingBridgeShift; const typingY = cy - typingHeight / 2; const typingRx = status.outerRadius / size; const cutoutPhoneWidth = (mobileStatus.phoneWidth + mobileStatus.borderWidth * 2) / size; const cutoutPhoneHeight = (mobileStatus.phoneHeight + mobileStatus.borderWidth * 2) / size; const cutoutPhoneX = (mobileStatus.phoneX - mobileStatus.borderWidth) / size; const cutoutPhoneY = (mobileStatus.phoneY - mobileStatus.borderWidth) / size; const cutoutPhoneRx = (mobileStatus.phoneRx + mobileStatus.borderWidth) / size; const displayExtraHeight = DESIGN_RULES.mobileDisplayExtraHeight; const displayExtraWidthPerSide = DESIGN_RULES.mobileDisplayExtraWidthPerSide; const screenWidthPx = mobileStatus.phoneWidth * DESIGN_RULES.mobileScreenWidth + displayExtraWidthPerSide * 2 - MOBILE_SCREEN_WIDTH_TRIM_PX; const screenHeightPx = mobileStatus.phoneHeight * DESIGN_RULES.mobileScreenHeight + displayExtraHeight - MOBILE_SCREEN_HEIGHT_TRIM_PX; const screenXpx = mobileStatus.phoneX + (mobileStatus.phoneWidth - screenWidthPx) / 2 + MOBILE_SCREEN_X_OFFSET_PX; const screenYpx = mobileStatus.phoneY + mobileStatus.phoneHeight * DESIGN_RULES.mobileScreenY - displayExtraHeight / 2 + MOBILE_SCREEN_Y_OFFSET_PX; const screenRxPx = Math.min(screenWidthPx, screenHeightPx) * 0.1; const mobileScreenX = ((screenXpx - mobileStatus.phoneX) / mobileStatus.phoneWidth).toFixed(4); const mobileScreenY = ((screenYpx - mobileStatus.phoneY) / mobileStatus.phoneHeight).toFixed(4); const mobileScreenWidth = (screenWidthPx / mobileStatus.phoneWidth).toFixed(4); const mobileScreenHeight = ((screenHeightPx / mobileStatus.phoneHeight) * DESIGN_RULES.mobileAspectRatio).toFixed(4); const mobileScreenRx = (screenRxPx / mobileStatus.phoneWidth).toFixed(4); const mobileScreenRy = ((screenRxPx / mobileStatus.phoneWidth) * DESIGN_RULES.mobileAspectRatio).toFixed(4); output += ` `; } output += ` ); `; const outputPath = path.join(__dirname, '../src/components/uikit/SVGMasks.tsx'); fs.writeFileSync(outputPath, output); console.log(`Generated ${outputPath}`); const layoutOutput = `// @generated - DO NOT EDIT MANUALLY // Run: pnpm generate:masks export interface StatusGeometry { size: number; cx: number; cy: number; radius: number; borderWidth: number; isMobile?: boolean; phoneWidth?: number; phoneHeight?: number; } const STATUS_GEOMETRY: Record = { ${SIZES.map((size) => { const geom = calculateStatusGeometry(size, false); return ` ${size}: {size: ${geom.size}, cx: ${geom.cx}, cy: ${geom.cy}, radius: ${geom.outerRadius}, borderWidth: ${geom.borderWidth}, isMobile: false}`; }).join(',\n')}, }; const STATUS_GEOMETRY_MOBILE: Record = { ${SIZES.map((size) => { const geom = calculateStatusGeometry(size, true) as MobileStatusGeometry; return ` ${size}: {size: ${geom.size}, cx: ${geom.cx}, cy: ${geom.cy}, radius: ${geom.outerRadius}, borderWidth: ${geom.borderWidth}, isMobile: true, phoneWidth: ${geom.phoneWidth}, phoneHeight: ${geom.phoneHeight}}`; }).join(',\n')}, }; export function getStatusGeometry(avatarSize: number, isMobile: boolean = false): StatusGeometry { const map = isMobile ? STATUS_GEOMETRY_MOBILE : STATUS_GEOMETRY; if (map[avatarSize]) { return map[avatarSize]; } const closestSize = Object.keys(map) .map(Number) .reduce((prev, curr) => (Math.abs(curr - avatarSize) < Math.abs(prev - avatarSize) ? curr : prev)); return map[closestSize]; } `; const layoutPath = path.join(__dirname, '../src/components/uikit/AvatarStatusGeometry.ts'); fs.writeFileSync(layoutPath, layoutOutput);