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:
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="h-dvh flex flex-col bg-gray-50 text-gray-900 dark:bg-gray-950 dark:text-gray-100">
|
||||
<div class="h-dvh flex flex-col">
|
||||
<RouterView />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
|
||||
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions'
|
||||
|
||||
interface OpenRouterMessage {
|
||||
role: 'system' | 'user' | 'assistant'
|
||||
content: string
|
||||
}
|
||||
|
||||
export function useAI() {
|
||||
const chatStore = useChatStore()
|
||||
|
||||
async function sendMessage(userText: string) {
|
||||
const apiKey = import.meta.env.VITE_OPENROUTER_API_KEY
|
||||
if (!apiKey) {
|
||||
console.error('VITE_OPENROUTER_API_KEY not set in .env.local')
|
||||
return
|
||||
}
|
||||
|
||||
let convId = chatStore.activeConversationId
|
||||
if (!convId) {
|
||||
convId = chatStore.createConversation()
|
||||
}
|
||||
|
||||
chatStore.addMessage(convId, { role: 'user', content: userText })
|
||||
|
||||
const assistantMsg = chatStore.addMessage(convId, { role: 'assistant', content: '' })
|
||||
if (!assistantMsg) return
|
||||
|
||||
chatStore.isStreaming = true
|
||||
|
||||
const history: OpenRouterMessage[] = chatStore.messages
|
||||
.filter((m) => m.id !== assistantMsg.id)
|
||||
.map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content }))
|
||||
|
||||
try {
|
||||
const res = await fetch(OPENROUTER_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'HTTP-Referer': window.location.origin,
|
||||
'X-Title': 'AIUI',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'meta-llama/llama-4-maverick:free',
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: 'You are AIUI, a helpful AI assistant. Be concise and helpful. When discussing films, provide rich details including genre, year, director, and rating.',
|
||||
},
|
||||
...history,
|
||||
],
|
||||
stream: true,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text()
|
||||
chatStore.appendToLastMessage(convId, `Error: ${res.status} — ${err}`)
|
||||
chatStore.isStreaming = false
|
||||
return
|
||||
}
|
||||
|
||||
const reader = res.body?.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
if (!reader) {
|
||||
chatStore.appendToLastMessage(convId, 'Error: No response body')
|
||||
chatStore.isStreaming = false
|
||||
return
|
||||
}
|
||||
|
||||
let buffer = ''
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() ?? ''
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed || !trimmed.startsWith('data: ')) continue
|
||||
const data = trimmed.slice(6)
|
||||
if (data === '[DONE]') break
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data)
|
||||
const delta = parsed.choices?.[0]?.delta?.content
|
||||
if (delta) {
|
||||
chatStore.appendToLastMessage(convId, delta)
|
||||
}
|
||||
} catch {
|
||||
// skip malformed chunks
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
chatStore.appendToLastMessage(convId, `\n\nConnection error: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||
} finally {
|
||||
chatStore.isStreaming = false
|
||||
}
|
||||
}
|
||||
|
||||
return { sendMessage }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
type ThemeName = 'dark' | 'light'
|
||||
|
||||
const currentTheme = ref<ThemeName>('dark')
|
||||
|
||||
export function useTheme() {
|
||||
const isDark = computed(() => currentTheme.value === 'dark')
|
||||
|
||||
const setTheme = (theme: ThemeName) => {
|
||||
currentTheme.value = theme
|
||||
localStorage.setItem('aiui-theme', theme)
|
||||
document.documentElement.classList.toggle('dark', theme === 'dark')
|
||||
}
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(isDark.value ? 'light' : 'dark')
|
||||
}
|
||||
|
||||
const initTheme = () => {
|
||||
const saved = localStorage.getItem('aiui-theme') as ThemeName | null
|
||||
if (saved) {
|
||||
setTheme(saved)
|
||||
} else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
setTheme('dark')
|
||||
}
|
||||
}
|
||||
|
||||
return { currentTheme, isDark, setTheme, toggleTheme, initTheme }
|
||||
}
|
||||
@@ -12,6 +12,11 @@ const router = createRouter({
|
||||
name: 'chat',
|
||||
component: () => import('./pages/ChatPage.vue'),
|
||||
},
|
||||
{
|
||||
path: '/widget-demo',
|
||||
name: 'widget-demo',
|
||||
component: () => import('./pages/WidgetDemoPage.vue'),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@@ -1,43 +1,94 @@
|
||||
<template>
|
||||
<div class="flex h-full">
|
||||
<main class="flex-1 flex flex-col min-w-0">
|
||||
<header class="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-800">
|
||||
<h1 class="text-lg font-semibold">AIUI</h1>
|
||||
<span class="text-xs text-gray-400">Phase 0 — Foundation</span>
|
||||
</header>
|
||||
<div class="h-full flex flex-col bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 relative overflow-hidden">
|
||||
<div class="absolute inset-0 pointer-events-none">
|
||||
<div class="absolute top-[-20%] left-[-10%] w-[500px] h-[500px] rounded-full bg-accent/5 blur-[120px]" />
|
||||
<div class="absolute bottom-[-20%] right-[-10%] w-[600px] h-[600px] rounded-full bg-info/5 blur-[120px]" />
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-4">
|
||||
<div class="max-w-3xl mx-auto space-y-4">
|
||||
<p class="text-sm text-gray-500 text-center py-12">
|
||||
Start a conversation. Ask about films, code, or anything else.
|
||||
<div class="relative z-10 flex-1 flex h-full p-3 md:p-4 gap-3 md:gap-4" :class="layoutClasses">
|
||||
<aside
|
||||
v-if="showSidebar"
|
||||
class="hidden lg:flex w-64 xl:w-72 shrink-0 flex-col glass rounded-2xl overflow-hidden"
|
||||
>
|
||||
<div class="p-4 flex items-center justify-between">
|
||||
<h1 class="text-lg font-bold gradient-text">AIUI</h1>
|
||||
<button
|
||||
class="p-2 rounded-lg text-white/40 hover:text-white hover:bg-white/5 transition-all"
|
||||
aria-label="New chat"
|
||||
@click="chatStore.createConversation()"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto scrollbar-thin px-2 pb-2 space-y-1">
|
||||
<button
|
||||
v-for="conv in conversations"
|
||||
:key="conv.id"
|
||||
class="w-full text-left px-3 py-2.5 rounded-xl text-sm transition-all duration-200 truncate"
|
||||
:class="conv.id === chatStore.activeConversationId
|
||||
? 'glass-strong text-white'
|
||||
: 'text-white/50 hover:text-white/80 hover:bg-white/5'"
|
||||
@click="chatStore.setActiveConversation(conv.id)"
|
||||
>
|
||||
{{ conv.title }}
|
||||
</button>
|
||||
|
||||
<p
|
||||
v-if="conversations.length === 0"
|
||||
class="text-xs text-white/20 text-center py-8"
|
||||
>
|
||||
No conversations yet
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-200 dark:border-gray-800 p-4">
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<div
|
||||
class="flex items-end gap-2 rounded-2xl border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-4 py-3 focus-within:border-primary focus-within:ring-2 focus-within:ring-primary/20 transition-colors"
|
||||
>
|
||||
<textarea
|
||||
rows="1"
|
||||
placeholder="Message AIUI..."
|
||||
class="flex-1 resize-none bg-transparent text-base outline-none placeholder:text-gray-400 min-h-[24px] max-h-[200px]"
|
||||
/>
|
||||
<button
|
||||
class="shrink-0 w-8 h-8 flex items-center justify-center rounded-lg bg-primary text-white hover:bg-primary-dark transition-colors"
|
||||
aria-label="Send message"
|
||||
>
|
||||
<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="M5 12h14m-7-7l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="p-3 border-t border-white/5">
|
||||
<div class="flex items-center gap-2 px-2 py-1.5 rounded-lg text-[11px] text-white/25">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-success animate-pulse" />
|
||||
<span>OpenRouter connected</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</aside>
|
||||
|
||||
<main class="flex-1 min-w-0 flex flex-col glass rounded-2xl overflow-hidden">
|
||||
<ChatWindow
|
||||
:side="panelSide"
|
||||
@switch-side="chatStore.switchSide()"
|
||||
/>
|
||||
</main>
|
||||
|
||||
<aside
|
||||
v-if="false"
|
||||
class="hidden xl:flex w-80 shrink-0 flex-col glass rounded-2xl overflow-hidden"
|
||||
>
|
||||
<div class="p-4 text-center text-white/20 text-sm">
|
||||
Content panel (coming soon)
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import ChatWindow from '@/components/chat/ChatWindow.vue'
|
||||
|
||||
const chatStore = useChatStore()
|
||||
const { initTheme } = useTheme()
|
||||
|
||||
const panelSide = computed(() => chatStore.panelSide)
|
||||
const conversations = computed(() => chatStore.conversationList)
|
||||
const showSidebar = true
|
||||
|
||||
const layoutClasses = computed(() =>
|
||||
panelSide.value === 'left' ? 'flex-row-reverse' : 'flex-row'
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
initTheme()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
<template>
|
||||
<div class="min-h-full bg-white text-gray-900 font-sans">
|
||||
<nav class="border-b border-gray-200 bg-white sticky top-0 z-30">
|
||||
<div class="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-lg bg-blue-600 flex items-center justify-center">
|
||||
<span class="text-white text-sm font-bold">A</span>
|
||||
</div>
|
||||
<span class="text-lg font-semibold">Acme App</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-6 text-sm text-gray-600">
|
||||
<a href="#" class="hover:text-gray-900">Dashboard</a>
|
||||
<a href="#" class="hover:text-gray-900">Projects</a>
|
||||
<a href="#" class="hover:text-gray-900">Settings</a>
|
||||
<RouterLink to="/" class="text-accent font-medium hover:underline">
|
||||
← Back to AIUI
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="max-w-6xl mx-auto px-6 py-12">
|
||||
<div class="mb-12">
|
||||
<h1 class="text-3xl font-bold mb-2">Widget Integration Demo</h1>
|
||||
<p class="text-gray-500 max-w-2xl">
|
||||
This page simulates a third-party web application with the AIUI chat widget embedded.
|
||||
Click the floating button in the bottom corner to open the AI assistant.
|
||||
You can also switch which side it appears on.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6 mb-16">
|
||||
<div v-for="n in 6" :key="n" class="bg-gray-50 rounded-2xl p-6 border border-gray-100">
|
||||
<div class="w-10 h-10 rounded-xl bg-blue-100 text-blue-600 flex items-center justify-center mb-4">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="font-semibold mb-1">Project {{ n }}</h3>
|
||||
<p class="text-sm text-gray-500 mb-3">
|
||||
Sample content card demonstrating how the widget overlays existing UI without disruption.
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="px-2.5 py-0.5 text-xs font-medium rounded-full bg-green-100 text-green-700">Active</span>
|
||||
<span class="text-xs text-gray-400">Updated 2h ago</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 rounded-2xl border border-gray-200 p-8 mb-16">
|
||||
<h2 class="text-xl font-bold mb-4">How to embed AIUI in your app</h2>
|
||||
<div class="space-y-6 text-sm text-gray-700">
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900 mb-2">Option 1: Script Tag (simplest)</h3>
|
||||
<pre class="bg-gray-900 text-gray-100 rounded-lg p-4 overflow-x-auto text-xs"><code><script src="https://cdn.example.com/aiui-widget.js"></script>
|
||||
<script>
|
||||
AIUI.init({
|
||||
apiKey: 'your-openrouter-key',
|
||||
position: 'bottom-right', // or 'bottom-left'
|
||||
theme: 'dark',
|
||||
})
|
||||
</script></code></pre>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900 mb-2">Option 2: Web Component</h3>
|
||||
<pre class="bg-gray-900 text-gray-100 rounded-lg p-4 overflow-x-auto text-xs"><code><!-- Load the custom element -->
|
||||
<script type="module" src="https://cdn.example.com/aiui-element.js"></script>
|
||||
|
||||
<!-- Use anywhere in your HTML -->
|
||||
<aiui-chat
|
||||
api-key="your-key"
|
||||
position="bottom-right"
|
||||
theme="dark"
|
||||
></aiui-chat></code></pre>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900 mb-2">Option 3: npm package (Vue/React/Svelte)</h3>
|
||||
<pre class="bg-gray-900 text-gray-100 rounded-lg p-4 overflow-x-auto text-xs"><code>npm install @aiui/widget
|
||||
|
||||
// Vue 3
|
||||
import { AIUIWidget } from '@aiui/widget'
|
||||
app.use(AIUIWidget, {
|
||||
apiKey: 'your-key',
|
||||
position: 'bottom-right',
|
||||
})
|
||||
|
||||
// React
|
||||
import { AIUIProvider } from '@aiui/widget/react'
|
||||
<AIUIProvider apiKey="your-key" position="bottom-right" /></code></pre>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900 mb-2">Option 4: iframe (maximum isolation)</h3>
|
||||
<pre class="bg-gray-900 text-gray-100 rounded-lg p-4 overflow-x-auto text-xs"><code><iframe
|
||||
src="https://app.aiui.dev/embed?key=your-key&theme=dark"
|
||||
style="position:fixed;bottom:80px;right:24px;width:420px;height:640px;
|
||||
border:none;border-radius:16px;z-index:9999;"
|
||||
allow="microphone"
|
||||
></iframe></code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 rounded-2xl border border-gray-200 p-8">
|
||||
<h2 class="text-xl font-bold mb-4">Technical Architecture</h2>
|
||||
<div class="grid md:grid-cols-2 gap-8 text-sm text-gray-700">
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900 mb-2">Script Tag / npm</h3>
|
||||
<ul class="space-y-1 list-disc list-inside">
|
||||
<li>Injects a Vue micro-app into a shadow DOM container</li>
|
||||
<li>Styles are encapsulated — no CSS conflicts with host</li>
|
||||
<li>Communicates via CustomEvents or a global AIUI API object</li>
|
||||
<li>Host app can pass context (current page, user info)</li>
|
||||
<li>Smallest footprint: ~80KB gzipped</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900 mb-2">Web Component</h3>
|
||||
<ul class="space-y-1 list-disc list-inside">
|
||||
<li>Framework-agnostic Custom Element (works in any HTML)</li>
|
||||
<li>Shadow DOM encapsulation by default</li>
|
||||
<li>Attributes for configuration, events for callbacks</li>
|
||||
<li>Can be lazy-loaded with dynamic import()</li>
|
||||
<li>Works in React, Angular, Svelte, plain HTML</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900 mb-2">iframe</h3>
|
||||
<ul class="space-y-1 list-disc list-inside">
|
||||
<li>Maximum isolation — separate browsing context</li>
|
||||
<li>Zero risk of CSS/JS conflicts</li>
|
||||
<li>Communication via postMessage API</li>
|
||||
<li>API key stays on embedded origin (more secure)</li>
|
||||
<li>Slightly larger overhead, but simplest integration</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900 mb-2">Host Communication</h3>
|
||||
<ul class="space-y-1 list-disc list-inside">
|
||||
<li>Host can send page context to AI for better answers</li>
|
||||
<li>Widget can trigger actions in host app via callbacks</li>
|
||||
<li>Conversation history syncs via encrypted IndexedDB</li>
|
||||
<li>Deep-link support: <code class="bg-gray-200 px-1 rounded">?aiui=open&q=help</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AIUIWidget />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RouterLink } from 'vue-router'
|
||||
import AIUIWidget from '@/components/widget/AIUIWidget.vue'
|
||||
</script>
|
||||
@@ -0,0 +1,95 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { Message, Conversation } from '@aiui/core/types/message'
|
||||
|
||||
export const useChatStore = defineStore('chat', () => {
|
||||
const conversations = ref<Map<string, Conversation>>(new Map())
|
||||
const activeConversationId = ref<string | null>(null)
|
||||
const isStreaming = ref(false)
|
||||
const panelSide = ref<'left' | 'right'>('right')
|
||||
|
||||
const activeConversation = computed(() => {
|
||||
if (!activeConversationId.value) return null
|
||||
return conversations.value.get(activeConversationId.value) ?? null
|
||||
})
|
||||
|
||||
const messages = computed(() => activeConversation.value?.messages ?? [])
|
||||
|
||||
const conversationList = computed(() =>
|
||||
Array.from(conversations.value.values()).sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
)
|
||||
|
||||
function createConversation(title = 'New Chat'): string {
|
||||
const id = crypto.randomUUID()
|
||||
const conversation: Conversation = {
|
||||
id,
|
||||
title,
|
||||
messages: [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
conversations.value.set(id, conversation)
|
||||
activeConversationId.value = id
|
||||
return id
|
||||
}
|
||||
|
||||
function addMessage(conversationId: string, message: Omit<Message, 'id' | 'timestamp'>) {
|
||||
const conv = conversations.value.get(conversationId)
|
||||
if (!conv) return
|
||||
|
||||
const msg: Message = {
|
||||
...message,
|
||||
id: crypto.randomUUID(),
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
conv.messages.push(msg)
|
||||
conv.updatedAt = Date.now()
|
||||
|
||||
if (conv.messages.length === 1 && message.role === 'user') {
|
||||
conv.title = message.content.slice(0, 60) + (message.content.length > 60 ? '...' : '')
|
||||
}
|
||||
|
||||
return msg
|
||||
}
|
||||
|
||||
function appendToLastMessage(conversationId: string, text: string) {
|
||||
const conv = conversations.value.get(conversationId)
|
||||
if (!conv || conv.messages.length === 0) return
|
||||
const last = conv.messages[conv.messages.length - 1]
|
||||
last.content += text
|
||||
}
|
||||
|
||||
function switchSide() {
|
||||
panelSide.value = panelSide.value === 'right' ? 'left' : 'right'
|
||||
}
|
||||
|
||||
function setActiveConversation(id: string) {
|
||||
if (conversations.value.has(id)) {
|
||||
activeConversationId.value = id
|
||||
}
|
||||
}
|
||||
|
||||
function deleteConversation(id: string) {
|
||||
conversations.value.delete(id)
|
||||
if (activeConversationId.value === id) {
|
||||
const remaining = conversationList.value
|
||||
activeConversationId.value = remaining.length > 0 ? remaining[0].id : null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
conversations,
|
||||
activeConversationId,
|
||||
activeConversation,
|
||||
messages,
|
||||
conversationList,
|
||||
isStreaming,
|
||||
panelSide,
|
||||
createConversation,
|
||||
addMessage,
|
||||
appendToLastMessage,
|
||||
switchSide,
|
||||
setActiveConversation,
|
||||
deleteConversation,
|
||||
}
|
||||
})
|
||||
@@ -13,6 +13,14 @@
|
||||
--color-warning: #F59E0B;
|
||||
--color-info: #3B82F6;
|
||||
|
||||
--color-glass-bg: rgba(255, 255, 255, 0.08);
|
||||
--color-glass-border: rgba(255, 255, 255, 0.12);
|
||||
--color-glass-bg-hover: rgba(255, 255, 255, 0.14);
|
||||
--color-glass-border-hover: rgba(255, 255, 255, 0.22);
|
||||
|
||||
--color-glass-bg-light: rgba(255, 255, 255, 0.65);
|
||||
--color-glass-border-light: rgba(255, 255, 255, 0.45);
|
||||
|
||||
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
--font-mono: 'Menlo', 'Monaco', 'Courier New', monospace;
|
||||
}
|
||||
@@ -25,6 +33,111 @@ body {
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
.glass {
|
||||
background: var(--color-glass-bg);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--color-glass-border);
|
||||
}
|
||||
|
||||
.glass-subtle {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.glass-strong {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.glass-light {
|
||||
background: var(--color-glass-bg-light);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--color-glass-border-light);
|
||||
}
|
||||
|
||||
.glass-dark {
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.glass:hover {
|
||||
background: var(--color-glass-bg-hover);
|
||||
border-color: var(--color-glass-border-hover);
|
||||
}
|
||||
|
||||
.glow-accent {
|
||||
box-shadow: 0 0 20px rgba(247, 147, 26, 0.15),
|
||||
0 0 40px rgba(247, 147, 26, 0.05);
|
||||
}
|
||||
|
||||
.glow-soft {
|
||||
box-shadow: 0 0 20px rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.inner-glow {
|
||||
box-shadow: inset 0 1px 1px rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.gradient-text {
|
||||
background: linear-gradient(to right, #ffffff, #9ca3af);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
@keyframes fadeSlideUp {
|
||||
from { opacity: 0; transform: translateY(12px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from { opacity: 0; transform: scale(0.92); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
@keyframes pulseGlow {
|
||||
0%, 100% { opacity: 0.4; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
.animate-fade-up {
|
||||
animation: fadeSlideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
.animate-scale-in {
|
||||
animation: scaleIn 0.25s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
.animate-pulse-glow {
|
||||
animation: pulseGlow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.scrollbar-thin {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.15) transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
animation-duration: 0.01ms !important;
|
||||
|
||||
Reference in New Issue
Block a user