diff --git a/apps/web/app/(auth)/login/page.tsx b/apps/web/app/(auth)/login/page.tsx index a1d89c16..6a748e99 100644 --- a/apps/web/app/(auth)/login/page.tsx +++ b/apps/web/app/(auth)/login/page.tsx @@ -12,10 +12,10 @@ import { CardDescription, CardContent, CardFooter, -} from "@multica/ui/components/ui/card"; -import { Input } from "@multica/ui/components/ui/input"; -import { Button } from "@multica/ui/components/ui/button"; -import { Label } from "@multica/ui/components/ui/label"; +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; function LoginPageContent() { const router = useRouter(); diff --git a/apps/web/app/(dashboard)/_components/app-sidebar.tsx b/apps/web/app/(dashboard)/_components/app-sidebar.tsx index 79428b46..b1ebd3b4 100644 --- a/apps/web/app/(dashboard)/_components/app-sidebar.tsx +++ b/apps/web/app/(dashboard)/_components/app-sidebar.tsx @@ -14,7 +14,7 @@ import { Plus, Check, } from "lucide-react"; -import { MulticaIcon } from "@multica/ui/components/multica-icon"; +import { MulticaIcon } from "@/components/multica-icon"; import { Sidebar, SidebarContent, @@ -25,7 +25,7 @@ import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, -} from "@multica/ui/components/ui/sidebar"; +} from "@/components/ui/sidebar"; import { DropdownMenu, DropdownMenuContent, @@ -34,10 +34,10 @@ import { DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, -} from "@multica/ui/components/ui/dropdown-menu"; -import { Input } from "@multica/ui/components/ui/input"; -import { Label } from "@multica/ui/components/ui/label"; -import { Button } from "@multica/ui/components/ui/button"; +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, @@ -45,7 +45,7 @@ import { DialogTitle, DialogDescription, DialogFooter, -} from "@multica/ui/components/ui/dialog"; +} from "@/components/ui/dialog"; import { useAuthStore } from "@/features/auth"; import { useWorkspaceStore } from "@/features/workspace"; diff --git a/apps/web/app/(dashboard)/agents/page.tsx b/apps/web/app/(dashboard)/agents/page.tsx index c762a8d7..7e9c6ea4 100644 --- a/apps/web/app/(dashboard)/agents/page.tsx +++ b/apps/web/app/(dashboard)/agents/page.tsx @@ -40,11 +40,11 @@ import { DialogTitle, DialogDescription, DialogFooter, -} from "@multica/ui/components/ui/dialog"; -import { Button } from "@multica/ui/components/ui/button"; -import { Input } from "@multica/ui/components/ui/input"; -import { Textarea } from "@multica/ui/components/ui/textarea"; -import { Label } from "@multica/ui/components/ui/label"; +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label"; import { api } from "@/shared/api"; import { useAuthStore } from "@/features/auth"; import { useWorkspaceStore } from "@/features/workspace"; diff --git a/apps/web/app/(dashboard)/inbox/page.tsx b/apps/web/app/(dashboard)/inbox/page.tsx index da8b1237..ea804419 100644 --- a/apps/web/app/(dashboard)/inbox/page.tsx +++ b/apps/web/app/(dashboard)/inbox/page.tsx @@ -11,7 +11,7 @@ import { ArrowRightLeft, } from "lucide-react"; import type { InboxItem, InboxItemType, InboxSeverity, InboxNewPayload } from "@multica/types"; -import { Button } from "@multica/ui/components/ui/button"; +import { Button } from "@/components/ui/button"; import { api } from "@/shared/api"; import { useWSEvent } from "@/features/realtime"; diff --git a/apps/web/app/(dashboard)/issues/[id]/page.test.tsx b/apps/web/app/(dashboard)/issues/[id]/page.test.tsx index 6e23b41f..bf5c1faa 100644 --- a/apps/web/app/(dashboard)/issues/[id]/page.test.tsx +++ b/apps/web/app/(dashboard)/issues/[id]/page.test.tsx @@ -66,8 +66,8 @@ vi.mock("@/features/realtime", () => ({ useWSEvent: () => {}, })); -// Mock @multica/ui calendar (react-day-picker needs browser APIs) -vi.mock("@multica/ui/components/ui/calendar", () => ({ +// Mock calendar (react-day-picker needs browser APIs) +vi.mock("@/components/ui/calendar", () => ({ Calendar: () => null, })); diff --git a/apps/web/app/(dashboard)/issues/[id]/page.tsx b/apps/web/app/(dashboard)/issues/[id]/page.tsx index 76e56bca..27483389 100644 --- a/apps/web/app/(dashboard)/issues/[id]/page.tsx +++ b/apps/web/app/(dashboard)/issues/[id]/page.tsx @@ -23,16 +23,16 @@ import { AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, -} from "@multica/ui/components/ui/alert-dialog"; -import { Calendar } from "@multica/ui/components/ui/calendar"; +} from "@/components/ui/alert-dialog"; +import { Calendar } from "@/components/ui/calendar"; import { Popover, PopoverTrigger, PopoverContent, -} from "@multica/ui/components/ui/popover"; -import { Button } from "@multica/ui/components/ui/button"; -import { Input } from "@multica/ui/components/ui/input"; -import { ActorAvatar } from "@multica/ui/components/common/actor-avatar"; +} from "@/components/ui/popover"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { ActorAvatar } from "@/components/common/actor-avatar"; import type { Issue, Comment, UpdateIssueRequest } from "@multica/types"; import { StatusPicker, PriorityPicker, AssigneePicker } from "@/features/issues/components"; import { api } from "@/shared/api"; diff --git a/apps/web/app/(dashboard)/issues/page.tsx b/apps/web/app/(dashboard)/issues/page.tsx index 31157240..040db17a 100644 --- a/apps/web/app/(dashboard)/issues/page.tsx +++ b/apps/web/app/(dashboard)/issues/page.tsx @@ -29,18 +29,19 @@ import { DialogTitle, DialogFooter, DialogTrigger, -} from "@multica/ui/components/ui/dialog"; -import { Button } from "@multica/ui/components/ui/button"; -import { Input } from "@multica/ui/components/ui/input"; -import { Textarea } from "@multica/ui/components/ui/textarea"; +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem, -} from "@multica/ui/components/ui/select"; -import { ActorAvatar } from "@multica/ui/components/common/actor-avatar"; + SelectGroup, +} from "@/components/ui/select"; +import { ActorAvatar } from "@/components/common/actor-avatar"; import { StatusIcon, PriorityIcon } from "@/features/issues/components"; import { api } from "@/shared/api"; import { useActorName } from "@/features/workspace"; @@ -567,10 +568,12 @@ export default function IssuesPage() { - All Status - {ALL_STATUSES.map((s) => ( - {STATUS_CONFIG[s].label} - ))} + + All Status + {ALL_STATUSES.map((s) => ( + {STATUS_CONFIG[s].label} + ))} + diff --git a/apps/web/app/(dashboard)/knowledge-base/page.tsx b/apps/web/app/(dashboard)/knowledge-base/page.tsx index a49168b4..d33cdfe4 100644 --- a/apps/web/app/(dashboard)/knowledge-base/page.tsx +++ b/apps/web/app/(dashboard)/knowledge-base/page.tsx @@ -7,8 +7,8 @@ import { Search, Link as LinkIcon, } from "lucide-react"; -import { Input } from "@multica/ui/components/ui/input"; -import { Button } from "@multica/ui/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/apps/web/app/(dashboard)/layout.tsx b/apps/web/app/(dashboard)/layout.tsx index 60be7c5c..4388ba1f 100644 --- a/apps/web/app/(dashboard)/layout.tsx +++ b/apps/web/app/(dashboard)/layout.tsx @@ -2,8 +2,8 @@ import { useEffect } from "react"; import { useRouter } from "next/navigation"; -import { MulticaIcon } from "@multica/ui/components/multica-icon"; -import { SidebarProvider, SidebarInset } from "@multica/ui/components/ui/sidebar"; +import { MulticaIcon } from "@/components/multica-icon"; +import { SidebarProvider, SidebarInset } from "@/components/ui/sidebar"; import { useAuthStore } from "@/features/auth"; import { useWorkspaceStore } from "@/features/workspace"; import { AppSidebar } from "./_components/app-sidebar"; diff --git a/apps/web/app/(dashboard)/settings/page.tsx b/apps/web/app/(dashboard)/settings/page.tsx index ea117c7b..a2be16db 100644 --- a/apps/web/app/(dashboard)/settings/page.tsx +++ b/apps/web/app/(dashboard)/settings/page.tsx @@ -3,17 +3,17 @@ import { useEffect, useState } from "react"; import { Settings, Users, Building2, Save, Crown, Shield, User, Plus, Trash2, LogOut } from "lucide-react"; import type { MemberWithUser, MemberRole } from "@multica/types"; -import { Input } from "@multica/ui/components/ui/input"; -import { Textarea } from "@multica/ui/components/ui/textarea"; -import { Label } from "@multica/ui/components/ui/label"; -import { Button } from "@multica/ui/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem, -} from "@multica/ui/components/ui/select"; +} from "@/components/ui/select"; import { useAuthStore } from "@/features/auth"; import { useWorkspaceStore } from "@/features/workspace"; import { api } from "@/shared/api"; diff --git a/apps/web/app/custom.css b/apps/web/app/custom.css new file mode 100644 index 00000000..c9567711 --- /dev/null +++ b/apps/web/app/custom.css @@ -0,0 +1,31 @@ +/* ============================================================================= + * Multica Web — Custom styles (non-shadcn) + * ============================================================================= */ + +/* Shiki dual themes: CSS-only light/dark switching via CSS variables */ +/* @see https://shiki.style/guide/dual-themes */ +.shiki, +.shiki span { + color: var(--shiki-light); +} + +.dark .shiki, +.dark .shiki span { + color: var(--shiki-dark) !important; +} + +/* Multica icon: entrance spin animation */ +@keyframes entrance-spin { + 0% { transform: rotate(0deg); opacity: 0; } + 50% { opacity: 1; } + 100% { transform: rotate(360deg); opacity: 1; } +} + +.animate-entrance-spin { + animation: entrance-spin 0.6s ease-out forwards; +} + +/* Shadcn sidebar: remove default padding from inset container */ +[data-slot="sidebar-container"] { + padding: 0 !important; +} diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 30c92deb..18f4f442 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -1,2 +1,155 @@ @import "tailwindcss"; -@import "@multica/ui/globals.css"; +@import "tw-animate-css"; +@import "shadcn/tailwind.css"; +@import "./custom.css"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --font-heading: var(--font-sans); + --font-sans: var(--font-sans); + --font-mono: var(--font-mono); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-success: var(--success); + --color-warning: var(--warning); + --color-info: var(--info); + --color-canvas: var(--canvas); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --color-foreground: var(--foreground); + --color-background: var(--background); + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) * 1.4); + --radius-2xl: calc(var(--radius) * 1.8); + --radius-3xl: calc(var(--radius) * 2.2); + --radius-4xl: calc(var(--radius) * 2.6); +} + +:root { + --background: oklch(1 0 0); + --foreground: oklch(0.141 0.005 285.823); + --card: oklch(1 0 0); + --card-foreground: oklch(0.141 0.005 285.823); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.141 0.005 285.823); + --primary: oklch(0.21 0.006 285.885); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.967 0.001 286.375); + --secondary-foreground: oklch(0.21 0.006 285.885); + --muted: oklch(0.967 0.001 286.375); + --muted-foreground: oklch(0.552 0.016 285.938); + --accent: oklch(0.967 0.001 286.375); + --accent-foreground: oklch(0.21 0.006 285.885); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.92 0.004 286.32); + --input: oklch(0.92 0.004 286.32); + --ring: oklch(0.705 0.015 286.067); + --chart-1: oklch(0.871 0.006 286.286); + --chart-2: oklch(0.552 0.016 285.938); + --chart-3: oklch(0.442 0.017 285.786); + --chart-4: oklch(0.37 0.013 285.805); + --chart-5: oklch(0.274 0.006 286.033); + --radius: 0.625rem; + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.141 0.005 285.823); + --sidebar-primary: oklch(0.21 0.006 285.885); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.967 0.001 286.375); + --sidebar-accent-foreground: oklch(0.21 0.006 285.885); + --sidebar-border: oklch(0.92 0.004 286.32); + --sidebar-ring: oklch(0.705 0.015 286.067); + --canvas: oklch(0.95 0.002 286); + --success: oklch(0.55 0.16 145); + --warning: oklch(0.75 0.16 85); + --info: oklch(0.55 0.18 250); + --scrollbar-thumb: oklch(0.82 0.003 286); + --scrollbar-thumb-hover: oklch(0.705 0.015 286.067); + --scrollbar-track: transparent; +} + +.dark { + --background: oklch(0.141 0.005 285.823); + --foreground: oklch(0.985 0 0); + --card: oklch(0.21 0.006 285.885); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.21 0.006 285.885); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.92 0.004 286.32); + --primary-foreground: oklch(0.21 0.006 285.885); + --secondary: oklch(0.274 0.006 286.033); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.274 0.006 286.033); + --muted-foreground: oklch(0.705 0.015 286.067); + --accent: oklch(0.274 0.006 286.033); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.552 0.016 285.938); + --chart-1: oklch(0.871 0.006 286.286); + --chart-2: oklch(0.552 0.016 285.938); + --chart-3: oklch(0.442 0.017 285.786); + --chart-4: oklch(0.37 0.013 285.805); + --chart-5: oklch(0.274 0.006 286.033); + --sidebar: oklch(0.21 0.006 285.885); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.274 0.006 286.033); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.552 0.016 285.938); + --canvas: oklch(0.2 0.005 286); + --success: oklch(0.65 0.15 145); + --warning: oklch(0.70 0.16 85); + --info: oklch(0.65 0.18 250); + --scrollbar-thumb: oklch(1 0 0 / 15%); + --scrollbar-thumb-hover: oklch(1 0 0 / 30%); + --scrollbar-track: transparent; +} + +@layer base { + * { + @apply border-border outline-ring/50; + scrollbar-width: thin; + scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track); + } + *::-webkit-scrollbar { width: 6px; height: 6px; } + *::-webkit-scrollbar-track { background: var(--scrollbar-track); } + *::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); border-radius: 3px; } + *::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-thumb-hover); } + body { + @apply bg-background text-foreground; + } + html { + @apply font-sans; + } +} \ No newline at end of file diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 8c4bc1b7..da6e533b 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,10 +1,15 @@ import type { Metadata } from "next"; -import { ThemeProvider } from "@multica/ui/components/theme-provider"; -import { Toaster } from "@multica/ui/components/ui/sonner"; +import { Geist, Geist_Mono } from "next/font/google"; +import { ThemeProvider } from "@/components/theme-provider"; +import { Toaster } from "@/components/ui/sonner"; +import { cn } from "@/lib/utils"; import { AuthInitializer } from "@/features/auth"; import { WSProvider } from "@/features/realtime"; import "./globals.css"; +const geist = Geist({ subsets: ["latin"], variable: "--font-sans" }); +const geistMono = Geist_Mono({ subsets: ["latin"], variable: "--font-mono" }); + export const metadata: Metadata = { title: "Multica", description: "AI-native task management", @@ -20,14 +25,13 @@ export default function RootLayout({ children: React.ReactNode; }) { return ( - + - + {children} diff --git a/apps/web/app/pair/local/page.tsx b/apps/web/app/pair/local/page.tsx index f25fae49..0ce7e1f6 100644 --- a/apps/web/app/pair/local/page.tsx +++ b/apps/web/app/pair/local/page.tsx @@ -4,14 +4,14 @@ import Link from "next/link"; import { Suspense, useEffect, useMemo, useState } from "react"; import { useSearchParams } from "next/navigation"; import type { DaemonPairingSession } from "@multica/types"; -import { Button } from "@multica/ui/components/ui/button"; -import { Label } from "@multica/ui/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; import { Select, SelectTrigger, SelectContent, SelectItem, -} from "@multica/ui/components/ui/select"; +} from "@/components/ui/select"; import { api } from "@/shared/api"; import { useAuthStore } from "@/features/auth"; import { useWorkspaceStore } from "@/features/workspace"; diff --git a/apps/web/components.json b/apps/web/components.json new file mode 100644 index 00000000..661ed3de --- /dev/null +++ b/apps/web/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-nova", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "zinc", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "inverted-translucent", + "menuAccent": "subtle", + "registries": {} +} diff --git a/apps/web/components/common/actor-avatar.tsx b/apps/web/components/common/actor-avatar.tsx new file mode 100644 index 00000000..c9133a7a --- /dev/null +++ b/apps/web/components/common/actor-avatar.tsx @@ -0,0 +1,44 @@ +import { Bot } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface ActorAvatarProps { + actorType: string; + actorId: string; + size?: number; + getName?: (type: string, id: string) => string; + getInitials?: (type: string, id: string) => string; + className?: string; +} + +function ActorAvatar({ + actorType, + actorId, + size = 20, + getName, + getInitials, + className, +}: ActorAvatarProps) { + const name = getName?.(actorType, actorId); + const initials = getInitials?.(actorType, actorId); + const isAgent = actorType === "agent"; + + return ( +
+ {isAgent ? ( + + ) : ( + initials + )} +
+ ); +} + +export { ActorAvatar, type ActorAvatarProps }; diff --git a/apps/web/components/loading-indicator.tsx b/apps/web/components/loading-indicator.tsx new file mode 100644 index 00000000..8216e45b --- /dev/null +++ b/apps/web/components/loading-indicator.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { Spinner } from "@/components/spinner"; +import { cn } from "@/lib/utils"; + +export type LoadingVariant = "generating" | "streaming"; + +interface LoadingIndicatorProps { + variant: LoadingVariant; + className?: string; +} + +const VARIANT_TEXT: Record = { + generating: "Generating...", + streaming: "Streaming...", +}; + +/** + * Unified loading indicator for chat. + * Use "generating" when waiting for AI response (no content yet). + * Use "streaming" when content is actively being received. + */ +export function LoadingIndicator({ variant, className }: LoadingIndicatorProps) { + return ( +
+ + {VARIANT_TEXT[variant]} +
+ ); +} diff --git a/apps/web/components/markdown/CodeBlock.tsx b/apps/web/components/markdown/CodeBlock.tsx new file mode 100644 index 00000000..414740c9 --- /dev/null +++ b/apps/web/components/markdown/CodeBlock.tsx @@ -0,0 +1,250 @@ +import * as React from 'react' +import { codeToHtml, bundledLanguages, type BundledLanguage } from 'shiki' +import { cn } from '@/lib/utils' + +export interface CodeBlockProps { + code: string + language?: string + className?: string + /** + * Render mode affects code block styling: + * - 'terminal': Minimal, keeps control chars visible + * - 'minimal': Clean code, basic styling + * - 'full': Rich styling with background, copy button, etc. + */ + mode?: 'terminal' | 'minimal' | 'full' +} + +// Map common aliases to Shiki language names +const LANGUAGE_ALIASES: Record = { + js: 'javascript', + ts: 'typescript', + py: 'python', + sh: 'bash', + zsh: 'bash', + yml: 'yaml', + rb: 'ruby', + rs: 'rust', + kt: 'kotlin', + 'objective-c': 'objc', + objc: 'objc' +} + +// Simple LRU cache for highlighted code +const highlightCache = new Map() +const CACHE_MAX_SIZE = 200 + +function getCacheKey(code: string, lang: string): string { + return `${lang}:${code}` +} + +function isValidLanguage(lang: string): lang is BundledLanguage { + const normalized = LANGUAGE_ALIASES[lang] || lang + return normalized in bundledLanguages +} + +/** + * CodeBlock - Syntax highlighted code block using Shiki + * + * Uses Shiki dual themes with CSS variables for light/dark switching. + * No JS-based dark mode detection needed — theme switching is handled + * entirely via CSS (see globals.css for .shiki/.dark .shiki rules). + * + * @see https://shiki.style/guide/dual-themes + */ +export function CodeBlock({ + code, + language = 'text', + className, + mode = 'full' +}: CodeBlockProps): React.JSX.Element { + const [highlighted, setHighlighted] = React.useState(null) + const [isLoading, setIsLoading] = React.useState(true) + const [copied, setCopied] = React.useState(false) + + // Resolve language alias - keep as string to allow 'text' fallback + const langLower = language.toLowerCase() + const resolvedLang: string = LANGUAGE_ALIASES[langLower] || langLower + + React.useEffect(() => { + let cancelled = false + + async function highlight(): Promise { + const cacheKey = getCacheKey(code, resolvedLang) + + const cached = highlightCache.get(cacheKey) + if (cached) { + if (!cancelled) { + setHighlighted(cached) + setIsLoading(false) + } + return + } + + try { + // Use valid language or fallback to plaintext + const lang = isValidLanguage(resolvedLang) ? resolvedLang : 'text' + + // Dual themes: Shiki outputs CSS variables for both themes in one pass. + // CSS handles switching via .dark selector (see globals.css). + const html = await codeToHtml(code, { + lang, + themes: { + light: 'github-light', + dark: 'github-dark', + }, + defaultColor: false, + }) + + // Cache the result + if (highlightCache.size >= CACHE_MAX_SIZE) { + const firstKey = highlightCache.keys().next().value + if (firstKey) highlightCache.delete(firstKey) + } + highlightCache.set(cacheKey, html) + + if (!cancelled) { + setHighlighted(html) + setIsLoading(false) + } + } catch (error) { + // Fallback to plain text on error + console.warn(`Shiki highlighting failed for language "${resolvedLang}":`, error) + if (!cancelled) { + setHighlighted(null) + setIsLoading(false) + } + } + } + + highlight() + + return () => { + cancelled = true + } + }, [code, resolvedLang]) + + const handleCopy = React.useCallback(async () => { + try { + await navigator.clipboard.writeText(code) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } catch (err) { + console.error('Failed to copy code:', err) + } + }, [code]) + + // Terminal mode: raw monospace with minimal styling + if (mode === 'terminal') { + return ( +
+        {code}
+      
+ ) + } + + // Minimal mode: just syntax highlighting, no chrome + if (mode === 'minimal') { + if (isLoading || !highlighted) { + return ( +
+          {code}
+        
+ ) + } + + return ( +
+ ) + } + + // Full mode: rich styling with header and copy button + return ( +
+ {/* Language label + copy button */} +
+ + {resolvedLang !== 'text' ? resolvedLang : 'plain text'} + + +
+ + {/* Code content */} +
+ {isLoading || !highlighted ? ( +
+            {code}
+          
+ ) : ( +
+ )} +
+
+ ) +} + +/** + * InlineCode - Styled inline code span + * Features: subtle background (3%), subtle border (5%), 75% opacity text + */ +export function InlineCode({ + children, + className +}: { + children: React.ReactNode + className?: string +}): React.JSX.Element { + return ( + + {children} + + ) +} diff --git a/apps/web/components/markdown/Markdown.tsx b/apps/web/components/markdown/Markdown.tsx new file mode 100644 index 00000000..348cfc37 --- /dev/null +++ b/apps/web/components/markdown/Markdown.tsx @@ -0,0 +1,301 @@ +import * as React from 'react' +import ReactMarkdown, { type Components } from 'react-markdown' +import rehypeRaw from 'rehype-raw' +import remarkGfm from 'remark-gfm' +import { cn } from '@/lib/utils' +import { CodeBlock, InlineCode } from './CodeBlock' +import { preprocessLinks } from './linkify' + +/** + * Render modes for markdown content: + * + * - 'terminal': Raw output with minimal formatting, control chars visible + * Best for: Debug output, raw logs, when you want to see exactly what's there + * + * - 'minimal': Clean rendering with syntax highlighting but no extra chrome + * Best for: Chat messages, inline content, when you want readability without clutter + * + * - 'full': Rich rendering with beautiful tables, styled code blocks, proper typography + * Best for: Documentation, long-form content, when presentation matters + */ +export type RenderMode = 'terminal' | 'minimal' | 'full' + +export interface MarkdownProps { + children: string + /** + * Render mode controlling formatting level + * @default 'minimal' + */ + mode?: RenderMode + className?: string + /** + * Message ID for memoization (optional) + * When provided, memoizes parsed blocks to avoid re-parsing during streaming + */ + id?: string + /** + * Callback when a URL is clicked + */ + onUrlClick?: (url: string) => void + /** + * Callback when a file path is clicked + */ + onFileClick?: (path: string) => void +} + +// File path detection regex - matches paths starting with /, ~/, or ./ +const FILE_PATH_REGEX = + /^(?:\/|~\/|\.\/)[\w\-./@]+\.(?:ts|tsx|js|jsx|mjs|cjs|md|json|yaml|yml|py|go|rs|css|scss|less|html|htm|txt|log|sh|bash|zsh|swift|kt|java|c|cpp|h|hpp|rb|php|xml|toml|ini|cfg|conf|env|sql|graphql|vue|svelte|astro|prisma)$/i + +/** + * Create custom components based on render mode + */ +function createComponents( + mode: RenderMode, + onUrlClick?: (url: string) => void, + onFileClick?: (path: string) => void +): Partial { + const baseComponents: Partial = { + // Links: Make clickable with callbacks + a: ({ href, children }) => { + const handleClick = (e: React.MouseEvent): void => { + e.preventDefault() + if (href) { + // Check if it's a file path + if (FILE_PATH_REGEX.test(href) && onFileClick) { + onFileClick(href) + } else if (onUrlClick) { + onUrlClick(href) + } else { + // Default: open in new window + window.open(href, '_blank', 'noopener,noreferrer') + } + } + } + + return ( + + {children} + + ) + } + } + + // Terminal mode: minimal formatting + if (mode === 'terminal') { + return { + ...baseComponents, + // No special code handling - just monospace + code: ({ children }) => {children}, + pre: ({ children }) =>
{children}
, + // Minimal paragraph spacing + p: ({ children }) =>

{children}

, + // Simple lists + ul: ({ children }) =>
    {children}
, + ol: ({ children }) =>
    {children}
, + li: ({ children }) =>
  • {children}
  • , + // Plain tables + table: ({ children }) => {children}
    , + th: ({ children }) => {children}, + td: ({ children }) => {children} + } + } + + // Minimal mode: clean with syntax highlighting + if (mode === 'minimal') { + return { + ...baseComponents, + // Inline code + code: ({ className, children, ...props }) => { + const match = /language-(\w+)/.exec(className || '') + const isBlock = + 'node' in props && props.node?.position?.start.line !== props.node?.position?.end.line + + // Block code - use CodeBlock with full mode + if (match || isBlock) { + const code = String(children).replace(/\n$/, '') + return + } + + // Inline code + return {children} + }, + pre: ({ children }) => <>{children}, + // Comfortable paragraph spacing + p: ({ children }) =>

    {children}

    , + // Styled lists + ul: ({ children }) => ( +
      + {children} +
    + ), + ol: ({ children }) =>
      {children}
    , + li: ({ children }) =>
  • {children}
  • , + // Clean tables + table: ({ children }) => ( +
    + {children}
    +
    + ), + thead: ({ children }) => {children}, + th: ({ children }) => ( + {children} + ), + td: ({ children }) => {children}, + // Headings - H1/H2 same size, differentiated by weight + h1: ({ children }) =>

    {children}

    , + h2: ({ children }) => ( +

    {children}

    + ), + h3: ({ children }) => ( +

    {children}

    + ), + // Blockquotes + blockquote: ({ children }) => ( +
    + {children} +
    + ), + // Horizontal rules + hr: () =>
    , + // Strong/emphasis + strong: ({ children }) => {children}, + em: ({ children }) => {children} + } + } + + // Full mode: rich styling + return { + ...baseComponents, + // Full code blocks with copy button + code: ({ className, children, ...props }) => { + const match = /language-(\w+)/.exec(className || '') + const isBlock = + 'node' in props && props.node?.position?.start.line !== props.node?.position?.end.line + + if (match || isBlock) { + const code = String(children).replace(/\n$/, '') + return + } + + return {children} + }, + pre: ({ children }) => <>{children}, + // Rich paragraph spacing + p: ({ children }) =>

    {children}

    , + // Styled lists + ul: ({ children }) => ( +
      + {children} +
    + ), + ol: ({ children }) =>
      {children}
    , + li: ({ children }) =>
  • {children}
  • , + // Beautiful tables + table: ({ children }) => ( +
    + {children}
    +
    + ), + thead: ({ children }) => {children}, + tbody: ({ children }) => {children}, + th: ({ children }) => {children}, + td: ({ children }) => {children}, + tr: ({ children }) => {children}, + // Rich headings + h1: ({ children }) =>

    {children}

    , + h2: ({ children }) => ( +

    {children}

    + ), + h3: ({ children }) =>

    {children}

    , + h4: ({ children }) =>

    {children}

    , + // Styled blockquotes + blockquote: ({ children }) => ( +
    + {children} +
    + ), + // Task lists (GFM) + input: ({ type, checked }) => { + if (type === 'checkbox') { + return ( + + ) + } + return + }, + // Horizontal rules + hr: () =>
    , + // Strong/emphasis + strong: ({ children }) => {children}, + em: ({ children }) => {children}, + del: ({ children }) => {children} + } +} + +/** + * Markdown - Customizable markdown renderer with multiple render modes + * + * Features: + * - Three render modes: terminal, minimal, full + * - Syntax highlighting via Shiki + * - GFM support (tables, task lists, strikethrough) + * - Clickable links and file paths + * - Memoization for streaming performance + */ +export function Markdown({ + children, + mode = 'minimal', + className, + onUrlClick, + onFileClick +}: MarkdownProps): React.JSX.Element { + const components = React.useMemo( + () => createComponents(mode, onUrlClick, onFileClick), + [mode, onUrlClick, onFileClick] + ) + + // Preprocess to convert raw URLs and file paths to markdown links + const processedContent = React.useMemo(() => preprocessLinks(children), [children]) + + return ( +
    + + {processedContent} + +
    + ) +} + +/** + * MemoizedMarkdown - Optimized for streaming scenarios + * + * Splits content into blocks and memoizes each block separately, + * so only new/changed blocks re-render during streaming. + */ +export const MemoizedMarkdown = React.memo(Markdown, (prevProps, nextProps) => { + // If id is provided, use it for memoization + if (prevProps.id && nextProps.id) { + return ( + prevProps.id === nextProps.id && + prevProps.children === nextProps.children && + prevProps.mode === nextProps.mode + ) + } + // Otherwise compare content and mode + return prevProps.children === nextProps.children && prevProps.mode === nextProps.mode +}) +MemoizedMarkdown.displayName = 'MemoizedMarkdown' diff --git a/apps/web/components/markdown/StreamingMarkdown.tsx b/apps/web/components/markdown/StreamingMarkdown.tsx new file mode 100644 index 00000000..aad2f330 --- /dev/null +++ b/apps/web/components/markdown/StreamingMarkdown.tsx @@ -0,0 +1,225 @@ +import * as React from 'react' +import { Markdown, type RenderMode } from './Markdown' + +export interface StreamingMarkdownProps { + content: string + isStreaming: boolean + mode?: RenderMode + className?: string + onUrlClick?: (url: string) => void + onFileClick?: (path: string) => void +} + +interface Block { + content: string + isCodeBlock: boolean +} + +/** + * djb2 hash (XOR variant) by Daniel J. Bernstein. + * Used to generate stable React keys for completed content blocks. + * + * - 5381: empirically chosen initial value that produces fewer collisions + * - (hash << 5) + hash: equivalent to hash * 33 + * - ^ charCode: XOR variant, favored by Bernstein over additive version + * - >>> 0: convert to unsigned 32-bit integer + * + * Not cryptographic — just fast with good distribution for short strings. + * @see http://www.cse.yorku.ca/~oz/hash.html + */ +function simpleHash(str: string): string { + let hash = 5381 + for (let i = 0; i < str.length; i++) { + hash = ((hash << 5) + hash) ^ str.charCodeAt(i) + } + return (hash >>> 0).toString(36) +} + +/** + * Split content into blocks (paragraphs and code blocks) + * + * Block boundaries: + * - Double newlines (paragraph separators) + * - Code fences (```) + * + * This is intentionally simple - just string scanning, no regex per line. + */ +function splitIntoBlocks(content: string): Block[] { + const blocks: Block[] = [] + const lines = content.split('\n') + let currentBlock = '' + let inCodeBlock = false + let inMathBlock = false + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] ?? '' + + // Check for code fence (``` at start of line, optionally followed by language) + if (line.startsWith('```')) { + if (!inCodeBlock) { + // Starting a code block - flush current paragraph first + if (currentBlock.trim()) { + blocks.push({ content: currentBlock.trim(), isCodeBlock: false }) + currentBlock = '' + } + inCodeBlock = true + currentBlock = line + '\n' + } else { + // Ending a code block + currentBlock += line + blocks.push({ content: currentBlock, isCodeBlock: true }) + currentBlock = '' + inCodeBlock = false + } + } else if (inCodeBlock) { + // Inside code block - append line + currentBlock += line + '\n' + // Check for display math fence ($$) + } else if (line.trim() === '$$') { + if (!inMathBlock) { + // Starting a math block - flush current paragraph first + if (currentBlock.trim()) { + blocks.push({ content: currentBlock.trim(), isCodeBlock: false }) + currentBlock = '' + } + inMathBlock = true + currentBlock = line + '\n' + } else { + // Ending a math block + currentBlock += line + blocks.push({ content: currentBlock, isCodeBlock: false }) + currentBlock = '' + inMathBlock = false + } + } else if (inMathBlock) { + // Inside math block - append line (don't split on blank lines) + currentBlock += line + '\n' + } else if (line === '') { + // Empty line outside code block = paragraph boundary + if (currentBlock.trim()) { + blocks.push({ content: currentBlock.trim(), isCodeBlock: false }) + currentBlock = '' + } + } else { + // Regular text line + if (currentBlock) { + currentBlock += '\n' + line + } else { + currentBlock = line + } + } + } + + // Flush remaining content + if (currentBlock) { + blocks.push({ + content: inCodeBlock || inMathBlock ? currentBlock : currentBlock.trim(), + isCodeBlock: inCodeBlock + }) + } + + return blocks +} + +/** + * Memoized block component + * + * Only re-renders if content or mode changes. + * The key is assigned by the parent based on content hash, + * so identical content won't even attempt to render. + */ +const MemoizedBlock = React.memo( + function Block({ + content, + mode, + className, + onUrlClick, + onFileClick + }: { + content: string + mode: RenderMode + className?: string + onUrlClick?: (url: string) => void + onFileClick?: (path: string) => void + }) { + return ( + + {content} + + ) + }, + (prev, next) => { + // Only re-render if content actually changed + return prev.content === next.content && prev.mode === next.mode && prev.className === next.className + } +) +MemoizedBlock.displayName = 'MemoizedBlock' + +/** + * StreamingMarkdown - Optimized markdown renderer for streaming content + * + * Splits content into blocks (paragraphs, code blocks) and memoizes each block + * independently. Only the last (active) block re-renders during streaming. + * + * Key insight: Completed blocks get a content-hash as their React key. + * Same content = same key = React skips re-render entirely. + * + * @example + * Content: "Hello\n\n```js\ncode\n```\n\nMore..." + * + * Block 1: "Hello" -> key="block-abc123" -> memoized + * Block 2: "```js\ncode\n```" -> key="block-xyz789" -> memoized + * Block 3: "More..." -> key="active-2" -> re-renders + */ +export function StreamingMarkdown({ + content, + isStreaming, + mode = 'minimal', + className, + onUrlClick, + onFileClick +}: StreamingMarkdownProps): React.JSX.Element { + // Split into blocks - memoized to avoid recomputation + // Must be called unconditionally to satisfy Rules of Hooks + const blocks = React.useMemo( + () => (isStreaming ? splitIntoBlocks(content) : []), + [content, isStreaming] + ) + + // Not streaming - use simple Markdown (no block splitting needed) + if (!isStreaming) { + return ( + + {content} + + ) + } + + // Empty content - return null, let parent handle loading indicator + if (blocks.length === 0) { + return <> + } + + return ( + <> + {blocks.map((block, i) => { + const isLastBlock = i === blocks.length - 1 + + // Complete blocks use content hash as key -> stable identity -> memoized + // Last block uses "active" prefix -> always re-renders on content change + const key = isLastBlock ? `active-${i}` : `block-${i}-${simpleHash(block.content)}` + + return ( + + ) + })} + + ) +} diff --git a/apps/web/components/markdown/index.ts b/apps/web/components/markdown/index.ts new file mode 100644 index 00000000..a2a89c50 --- /dev/null +++ b/apps/web/components/markdown/index.ts @@ -0,0 +1,4 @@ +export { Markdown, MemoizedMarkdown, type MarkdownProps, type RenderMode } from './Markdown' +export { CodeBlock, InlineCode, type CodeBlockProps } from './CodeBlock' +export { StreamingMarkdown, type StreamingMarkdownProps } from './StreamingMarkdown' +export { preprocessLinks, detectLinks, hasLinks } from './linkify' diff --git a/apps/web/components/markdown/linkify.ts b/apps/web/components/markdown/linkify.ts new file mode 100644 index 00000000..f4ee1809 --- /dev/null +++ b/apps/web/components/markdown/linkify.ts @@ -0,0 +1,215 @@ +import LinkifyIt from 'linkify-it' + +/** + * Linkify - URL and file path detection for markdown preprocessing + * + * Uses linkify-it (12M downloads/week) for battle-tested URL detection, + * plus custom regex for local file paths. + */ + +// Initialize linkify-it with default settings (fuzzy URLs, emails enabled) +const linkify = new LinkifyIt() + +// File path regex - detects /path, ~/path, ./path with common extensions +// Matches paths that start with /, ~/, or ./ followed by path chars and a file extension +const FILE_PATH_REGEX = + /(?:^|[\s([{<])((\/|~\/|\.\/)[\w\-./@]+\.(?:ts|tsx|js|jsx|mjs|cjs|md|json|yaml|yml|py|go|rs|css|scss|less|html|htm|txt|log|sh|bash|zsh|swift|kt|java|c|cpp|h|hpp|rb|php|xml|toml|ini|cfg|conf|env|sql|graphql|vue|svelte|astro|prisma|dockerfile|makefile|gitignore))(?=[\s)\]}.,;:!?>]|$)/gi + +interface DetectedLink { + type: 'url' | 'email' | 'file' + text: string + url: string + start: number + end: number +} + +interface CodeRange { + start: number + end: number +} + +/** + * Find all code block and inline code ranges in text + * These ranges should be excluded from link detection + */ +function findCodeRanges(text: string): CodeRange[] { + const ranges: CodeRange[] = [] + + // Find fenced code blocks (```...```) + const fencedRegex = /```[\s\S]*?```/g + let match + while ((match = fencedRegex.exec(text)) !== null) { + ranges.push({ start: match.index, end: match.index + match[0].length }) + } + + // Find display math blocks ($$...$$) + const displayMathRegex = /\$\$[\s\S]*?\$\$/g + while ((match = displayMathRegex.exec(text)) !== null) { + const pos = match.index + const insideOther = ranges.some((r) => pos >= r.start && pos < r.end) + if (!insideOther) { + ranges.push({ start: pos, end: pos + match[0].length }) + } + } + + // Find inline math ($...$) + const inlineMathRegex = /(? pos >= r.start && pos < r.end) + if (!insideOther) { + ranges.push({ start: pos, end: pos + match[0].length }) + } + } + + // Find inline code (`...`) + // But skip escaped backticks and code inside fenced blocks + const inlineRegex = /(? pos >= r.start && pos < r.end) + if (!insideOther) { + ranges.push({ start: pos, end: pos + match[0].length }) + } + } + + return ranges +} + +/** + * Check if a position is inside any code range + */ +function isInsideCode(pos: number, ranges: CodeRange[]): boolean { + return ranges.some((r) => pos >= r.start && pos < r.end) +} + +/** + * Check if a link at given position is already a markdown link + * Looks for patterns like [text](url) or [text][ref] + */ +function isAlreadyLinked(text: string, linkStart: number, linkEnd: number): boolean { + // Check if preceded by ]( which indicates we're inside a markdown link href + // Pattern: [text](URL) - we're checking if URL is our link + const before = text.slice(Math.max(0, linkStart - 2), linkStart) + if (before.endsWith('](')) return true + + // Check if preceded by ][ for reference links + if (before.endsWith('][')) return true + + // Check if the link text is wrapped in [] + // Pattern: [URL](href) - URL is being used as link text + const charBefore = text[linkStart - 1] + const charAfter = text[linkEnd] + if (charBefore === '[' && charAfter === ']') return true + + return false +} + +/** + * Check if ranges overlap + */ +function rangesOverlap( + a: { start: number; end: number }, + b: { start: number; end: number } +): boolean { + return a.start < b.end && b.start < a.end +} + +/** + * Detect all links (URLs, emails, file paths) in text + */ +export function detectLinks(text: string): DetectedLink[] { + const links: DetectedLink[] = [] + + // 1. Detect URLs and emails with linkify-it + const urlMatches = linkify.match(text) || [] + for (const match of urlMatches) { + links.push({ + type: match.schema === 'mailto:' ? 'email' : 'url', + text: match.text, + url: match.url, + start: match.index, + end: match.lastIndex + }) + } + + // 2. Detect file paths with custom regex + // Reset regex state + FILE_PATH_REGEX.lastIndex = 0 + let fileMatch + while ((fileMatch = FILE_PATH_REGEX.exec(text)) !== null) { + const path = fileMatch[1] + if (!path) continue // Skip if no capture group + + // Calculate actual start position (after any leading whitespace/punctuation) + const fullMatch = fileMatch[0] + const pathOffset = fullMatch.indexOf(path) + const start = fileMatch.index + pathOffset + + // Check for overlaps with URL matches (URLs take precedence) + const pathRange = { start, end: start + path.length } + const overlapsUrl = links.some((link) => rangesOverlap(pathRange, link)) + if (overlapsUrl) continue + + links.push({ + type: 'file', + text: path, + url: path, // File paths are passed as-is to onFileClick handler + start, + end: start + path.length + }) + } + + // Sort by position + return links.sort((a, b) => a.start - b.start) +} + +/** + * Preprocess text to convert raw URLs and file paths into markdown links + * Skips code blocks and already-linked content + */ +export function preprocessLinks(text: string): string { + // Quick check - if no potential links, return early + if (!linkify.pretest(text) && !/[~/.]\//.test(text)) { + return text + } + + const codeRanges = findCodeRanges(text) + const links = detectLinks(text) + + if (links.length === 0) return text + + // Build result, converting raw links to markdown links + let result = '' + let lastIndex = 0 + + for (const link of links) { + // Skip if inside code block + if (isInsideCode(link.start, codeRanges)) continue + + // Skip if already a markdown link + if (isAlreadyLinked(text, link.start, link.end)) continue + + // Add text before this link + result += text.slice(lastIndex, link.start) + + // Convert to markdown link + result += `[${link.text}](${link.url})` + + lastIndex = link.end + } + + // Add remaining text + result += text.slice(lastIndex) + + return result +} + +/** + * Test if text contains any detectable links + * Useful for optimization - skip preprocessing if no links present + */ +export function hasLinks(text: string): boolean { + return linkify.pretest(text) || /[~/.]\/[\w]/.test(text) +} diff --git a/apps/web/components/multica-icon.tsx b/apps/web/components/multica-icon.tsx new file mode 100644 index 00000000..45d0e5b5 --- /dev/null +++ b/apps/web/components/multica-icon.tsx @@ -0,0 +1,105 @@ +import { useState, useEffect } from "react"; +import { cn } from "@/lib/utils"; + +interface MulticaIconProps extends React.ComponentProps<"span"> { + /** + * If true, play a one-time entrance spin animation. + */ + animate?: boolean; + /** + * If true, disable hover spin animation. + */ + noSpin?: boolean; + /** + * If true, show a border around the icon. + */ + bordered?: boolean; + /** + * Size of the bordered icon: "sm" (default), "md", "lg" + */ + size?: "sm" | "md" | "lg"; +} + +const borderedSizes = { + sm: { wrapper: "p-1.5", icon: "size-3.5" }, + md: { wrapper: "p-2", icon: "size-4" }, + lg: { wrapper: "p-2.5", icon: "size-5" }, +}; + +/** + * Pure CSS 8-pointed asterisk icon matching the Multica logo. + * Uses currentColor so it adapts to light/dark themes automatically. + * Clip-path polygon traced from the original SVG path coordinates. + */ +export function MulticaIcon({ + className, + animate = false, + noSpin = false, + bordered = false, + size = "sm", + ...props +}: MulticaIconProps) { + const [entranceDone, setEntranceDone] = useState(!animate); + + useEffect(() => { + if (!animate) return; + const timer = setTimeout(() => setEntranceDone(true), 600); + return () => clearTimeout(timer); + }, [animate]); + + const clipPath = `polygon( + 45% 62.1%, 45% 100%, 55% 100%, 55% 62.1%, + 81.8% 88.9%, 88.9% 81.8%, 62.1% 55%, 100% 55%, + 100% 45%, 62.1% 45%, 88.9% 18.2%, 81.8% 11.1%, + 55% 37.9%, 55% 0%, 45% 0%, 45% 37.9%, + 18.2% 11.1%, 11.1% 18.2%, 37.9% 45%, 0% 45%, + 0% 55%, 37.9% 55%, 11.1% 81.8%, 18.2% 88.9% + )`; + + if (bordered) { + const sizeConfig = borderedSizes[size]; + return ( +