feat(app): glassmorphism chat UI with widget embed system

- Glassmorphism chat window matching Proux design rules (glass layers,
  gradient backgrounds, glow effects, blur intensities, inner glows)
- Conversation ID displayed in header, side-switching (left/right layout)
- Chat store with Pinia: conversations, messages, streaming state
- OpenRouter AI integration with SSE streaming (Llama 4 Maverick free model)
- Chat components: ChatWindow, ChatHeader, ChatInput, ChatMessage,
  StreamingDots (typing indicator)
- Embeddable widget system: floating action button (FAB) + modal popup
  with scale-in animation, side-aware positioning
- Widget demo page (/widget-demo) showing AIUI embedded in a mock "Acme App"
  with documentation of 4 integration methods: script tag, web component,
  npm package, and iframe
- Theme composable with dark/light mode, system preference detection
- Custom CSS: glass variants (subtle/medium/strong/light/dark), glow effects,
  gradient text, animation keyframes, thin scrollbar
- Responsive: mobile-first, 44px touch targets, dvh viewport

Made-with: Cursor
This commit is contained in:
Dorian
2026-03-02 14:20:34 +00:00
parent c28e6dd811
commit 27864cf92e
16 changed files with 1057 additions and 32 deletions
@@ -0,0 +1,63 @@
<template>
<div class="glass-subtle rounded-t-2xl flex items-center justify-between px-4 py-3">
<div class="flex items-center gap-3 min-w-0">
<div class="w-8 h-8 rounded-full bg-accent/20 flex items-center justify-center shrink-0">
<span class="text-accent text-sm font-bold">AI</span>
</div>
<div class="min-w-0">
<h2 class="text-sm font-semibold text-white truncate">{{ title }}</h2>
<p class="text-[10px] text-white/40 truncate">{{ conversationId }}</p>
</div>
</div>
<div class="flex items-center gap-1">
<button
class="p-2 rounded-lg text-white/40 hover:text-white/80 hover:bg-white/5 transition-all duration-200"
:title="side === 'right' ? 'Move panel to left' : 'Move panel to right'"
aria-label="Switch panel side"
@click="$emit('switchSide')"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path v-if="side === 'right'" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
<path v-else stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 5l7 7-7 7M5 5l7 7-7 7" />
</svg>
</button>
<button
class="p-2 rounded-lg text-white/40 hover:text-white/80 hover:bg-white/5 transition-all duration-200"
aria-label="New conversation"
@click="$emit('newChat')"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
</button>
<button
v-if="showClose"
class="p-2 rounded-lg text-white/40 hover:text-white/80 hover:bg-white/5 transition-all duration-200"
aria-label="Close"
@click="$emit('close')"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
title: string
conversationId: string
side: 'left' | 'right'
showClose?: boolean
}>()
defineEmits<{
switchSide: []
newChat: []
close: []
}>()
</script>
@@ -0,0 +1,72 @@
<template>
<div class="p-3 md:p-4">
<div
class="glass rounded-2xl px-4 py-3 flex items-end gap-3 transition-all duration-300 inner-glow"
:class="isFocused ? 'border-white/20 glow-soft' : ''"
>
<textarea
ref="textareaRef"
v-model="text"
rows="1"
:placeholder="placeholder"
class="flex-1 resize-none bg-transparent text-sm text-white outline-none placeholder:text-white/30 min-h-[24px] max-h-[120px]"
@focus="isFocused = true"
@blur="isFocused = false"
@keydown.enter.exact.prevent="send"
@input="autoResize"
/>
<button
:disabled="!canSend"
class="shrink-0 w-9 h-9 flex items-center justify-center rounded-xl transition-all duration-200"
:class="canSend
? 'bg-accent text-white hover:bg-accent-hover active:scale-90'
: 'bg-white/5 text-white/20 cursor-not-allowed'"
aria-label="Send message"
@click="send"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5" />
</svg>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick } from 'vue'
const props = withDefaults(
defineProps<{
disabled?: boolean
placeholder?: string
}>(),
{
disabled: false,
placeholder: 'Message AIUI...',
}
)
const emit = defineEmits<{
send: [text: string]
}>()
const text = ref('')
const isFocused = ref(false)
const textareaRef = ref<HTMLTextAreaElement | null>(null)
const canSend = computed(() => text.value.trim().length > 0 && !props.disabled)
function send() {
if (!canSend.value) return
emit('send', text.value.trim())
text.value = ''
nextTick(autoResize)
}
function autoResize() {
const el = textareaRef.value
if (!el) return
el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, 120) + 'px'
}
</script>
@@ -0,0 +1,43 @@
<template>
<div
class="flex animate-fade-up"
:class="isUser ? 'justify-end' : 'justify-start'"
:style="{ animationDelay: `${index * 30}ms` }"
>
<div
class="max-w-[85%] md:max-w-[70%] rounded-2xl px-4 py-3 transition-all duration-200"
:class="bubbleClasses"
>
<p class="text-sm leading-relaxed whitespace-pre-wrap break-words">{{ message.content }}</p>
<div class="flex items-center gap-2 mt-1.5">
<span class="text-[10px] opacity-40 select-none">{{ formattedTime }}</span>
<span v-if="isUser && message.status" class="text-[10px] opacity-30">
{{ message.status === 'sent' ? '' : message.status === 'delivered' ? '' : '' }}
</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Message } from '@aiui/core/types/message'
const props = defineProps<{
message: Message
index: number
}>()
const isUser = computed(() => props.message.role === 'user')
const bubbleClasses = computed(() =>
isUser.value
? 'glass-strong text-white rounded-br-md'
: 'glass-dark text-gray-100 rounded-bl-md'
)
const formattedTime = computed(() => {
const d = new Date(props.message.timestamp)
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
})
</script>
@@ -0,0 +1,122 @@
<template>
<div
class="flex flex-col h-full rounded-2xl overflow-hidden transition-all duration-300"
:class="variant === 'modal' ? 'glass-dark' : ''"
>
<ChatHeader
:title="title"
:conversation-id="displayId"
:side="side"
:show-close="showClose"
@switch-side="$emit('switchSide')"
@new-chat="handleNewChat"
@close="$emit('close')"
/>
<div
ref="messageListRef"
class="flex-1 overflow-y-auto scrollbar-thin p-4 space-y-3"
>
<div v-if="messages.length === 0" class="flex items-center justify-center h-full">
<div class="text-center space-y-3 animate-fade-up">
<div class="w-16 h-16 rounded-2xl glass flex items-center justify-center mx-auto">
<span class="text-2xl"></span>
</div>
<p class="text-sm text-white/30">Start a conversation</p>
</div>
</div>
<ChatMessage
v-for="(msg, i) in messages"
:key="msg.id"
:message="msg"
:index="i"
/>
<StreamingDots v-if="isStreaming && lastMessageEmpty" />
</div>
<ChatInput
:disabled="isStreaming"
:placeholder="isStreaming ? 'Waiting for response...' : 'Message AIUI...'"
@send="handleSend"
/>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch, nextTick } from 'vue'
import { useChatStore } from '@/stores/chat'
import { useAI } from '@/composables/useAI'
import ChatHeader from './ChatHeader.vue'
import ChatMessage from './ChatMessage.vue'
import ChatInput from './ChatInput.vue'
import StreamingDots from './StreamingDots.vue'
withDefaults(
defineProps<{
variant?: 'standalone' | 'modal'
side?: 'left' | 'right'
showClose?: boolean
}>(),
{
variant: 'standalone',
side: 'right',
showClose: false,
}
)
defineEmits<{
switchSide: []
close: []
}>()
const chatStore = useChatStore()
const { sendMessage } = useAI()
const messageListRef = ref<HTMLElement | null>(null)
const messages = computed(() => chatStore.messages)
const isStreaming = computed(() => chatStore.isStreaming)
const title = computed(
() => chatStore.activeConversation?.title ?? 'New Chat'
)
const displayId = computed(
() => chatStore.activeConversationId?.slice(0, 8) ?? '—'
)
const lastMessageEmpty = computed(() => {
const msgs = messages.value
if (msgs.length === 0) return true
return msgs[msgs.length - 1].content === ''
})
function handleNewChat() {
chatStore.createConversation()
}
async function handleSend(text: string) {
await sendMessage(text)
}
watch(
() => messages.value.length,
() => {
nextTick(() => {
const el = messageListRef.value
if (el) el.scrollTop = el.scrollHeight
})
}
)
watch(
() => messages.value[messages.value.length - 1]?.content,
() => {
nextTick(() => {
const el = messageListRef.value
if (el) el.scrollTop = el.scrollHeight
})
}
)
</script>
@@ -0,0 +1,14 @@
<template>
<div class="flex justify-start animate-fade-up">
<div class="glass-dark rounded-2xl rounded-bl-md px-4 py-3">
<div class="flex items-center gap-1.5">
<span
v-for="i in 3"
:key="i"
class="w-1.5 h-1.5 rounded-full bg-white/40 animate-pulse-glow"
:style="{ animationDelay: `${i * 200}ms` }"
/>
</div>
</div>
</div>
</template>
@@ -0,0 +1,32 @@
<template>
<div>
<WidgetFab
:is-open="isOpen"
:position="fabPosition"
@toggle="isOpen = !isOpen"
/>
<WidgetModal
:is-open="isOpen"
:side="side"
@close="isOpen = false"
@switch-side="toggleSide"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import WidgetFab from './WidgetFab.vue'
import WidgetModal from './WidgetModal.vue'
const isOpen = ref(false)
const side = ref<'left' | 'right'>('right')
const fabPosition = computed(() =>
side.value === 'left' ? 'bottom-left' : 'bottom-right'
)
function toggleSide() {
side.value = side.value === 'right' ? 'left' : 'right'
}
</script>
@@ -0,0 +1,53 @@
<template>
<button
class="fixed z-50 w-14 h-14 rounded-full glass-strong flex items-center justify-center
shadow-lg hover:shadow-xl transition-all duration-300
hover:scale-105 active:scale-95 glow-accent"
:class="positionClasses"
:aria-label="isOpen ? 'Close AIUI' : 'Open AIUI'"
@click="$emit('toggle')"
>
<svg
class="w-6 h-6 text-accent transition-transform duration-300"
:class="isOpen ? 'rotate-45' : ''"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
v-if="!isOpen"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
<path
v-else
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
</button>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
isOpen: boolean
position?: 'bottom-right' | 'bottom-left'
}>(),
{ position: 'bottom-right' }
)
defineEmits<{ toggle: [] }>()
const positionClasses = computed(() =>
props.position === 'bottom-left'
? 'bottom-6 left-6'
: 'bottom-6 right-6'
)
</script>
@@ -0,0 +1,65 @@
<template>
<Teleport to="body">
<Transition name="widget">
<div
v-if="isOpen"
class="fixed z-40 animate-scale-in"
:class="positionClasses"
>
<div
class="w-[380px] h-[600px] md:w-[420px] md:h-[640px] rounded-2xl overflow-hidden shadow-2xl"
:style="{ boxShadow: '0 20px 60px rgba(0, 0, 0, 0.5), 0 0 40px rgba(247, 147, 26, 0.08)' }"
>
<ChatWindow
variant="modal"
:side="side"
show-close
@switch-side="$emit('switchSide')"
@close="$emit('close')"
/>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import ChatWindow from '@/components/chat/ChatWindow.vue'
const props = withDefaults(
defineProps<{
isOpen: boolean
side?: 'left' | 'right'
}>(),
{ side: 'right' }
)
defineEmits<{
close: []
switchSide: []
}>()
const positionClasses = computed(() =>
props.side === 'left'
? 'bottom-24 left-6'
: 'bottom-24 right-6'
)
</script>
<style scoped>
.widget-enter-active {
transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1);
}
.widget-leave-active {
transition: all 0.2s ease-in;
}
.widget-enter-from {
opacity: 0;
transform: scale(0.9) translateY(20px);
}
.widget-leave-to {
opacity: 0;
transform: scale(0.9) translateY(20px);
}
</style>