docus 5.4.4 → 5.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/app/app.vue +16 -6
  2. package/app/components/app/AppHeader.vue +5 -0
  3. package/app/components/docs/DocsAsideRightBottom.vue +28 -1
  4. package/app/composables/useSeo.ts +207 -0
  5. package/app/pages/[[lang]]/[...slug].vue +17 -9
  6. package/app/plugins/i18n.ts +28 -11
  7. package/app/templates/landing.vue +4 -10
  8. package/app/types/index.d.ts +49 -0
  9. package/app/utils/navigation.ts +31 -0
  10. package/i18n/locales/en.json +27 -0
  11. package/i18n/locales/fr.json +28 -1
  12. package/modules/assistant/README.md +213 -0
  13. package/modules/assistant/index.ts +100 -0
  14. package/modules/assistant/runtime/components/AssistantChat.vue +21 -0
  15. package/modules/assistant/runtime/components/AssistantChatDisabled.vue +3 -0
  16. package/modules/assistant/runtime/components/AssistantFloatingInput.vue +110 -0
  17. package/modules/assistant/runtime/components/AssistantLoading.vue +164 -0
  18. package/modules/assistant/runtime/components/AssistantMatrix.vue +92 -0
  19. package/modules/assistant/runtime/components/AssistantPanel.vue +329 -0
  20. package/modules/assistant/runtime/components/AssistantPreStream.vue +46 -0
  21. package/modules/assistant/runtime/composables/useAssistant.ts +107 -0
  22. package/modules/assistant/runtime/composables/useHighlighter.ts +34 -0
  23. package/modules/assistant/runtime/server/api/search.ts +111 -0
  24. package/modules/assistant/runtime/types.ts +7 -0
  25. package/modules/config.ts +6 -4
  26. package/modules/css.ts +6 -2
  27. package/modules/markdown-rewrite.ts +130 -0
  28. package/nuxt.config.ts +22 -1
  29. package/nuxt.schema.ts +63 -0
  30. package/package.json +24 -15
  31. package/server/routes/sitemap.xml.ts +93 -0
  32. package/utils/meta.ts +9 -3
@@ -0,0 +1,213 @@
1
+ # Assistant Module
2
+
3
+ A Nuxt module that provides an AI-powered chat interface using MCP (Model Context Protocol) tools.
4
+
5
+ ## Features
6
+
7
+ - AI chat slideover component with streaming responses
8
+ - Floating input component for quick questions
9
+ - MCP tools integration for documentation search
10
+ - Syntax highlighting for code blocks
11
+ - FAQ suggestions
12
+ - Persistent chat state
13
+ - Keyboard shortcuts support
14
+
15
+ ## Installation
16
+
17
+ 1. Copy the `modules/assistant` folder to your Nuxt project
18
+ 2. Install the required dependencies:
19
+
20
+ ```bash
21
+ pnpm add @ai-sdk/mcp @ai-sdk/vue @ai-sdk/gateway ai motion-v shiki shiki-stream
22
+ ```
23
+
24
+ 3. Add the module to your `nuxt.config.ts`:
25
+
26
+ ```ts
27
+ export default defineNuxtConfig({
28
+ modules: ['./modules/assistant'],
29
+
30
+ assistant: {
31
+ apiPath: '/__docus__/assistant',
32
+ mcpServer: '/mcp',
33
+ model: 'google/gemini-3-flash',
34
+ }
35
+ })
36
+ ```
37
+
38
+ 4. Set up your API key as an environment variable:
39
+
40
+ ```bash
41
+ AI_GATEWAY_API_KEY=your-gateway-key
42
+ ```
43
+
44
+ > **Note:** The module will only be enabled if `AI_GATEWAY_API_KEY` is detected. If no key is found, the module is disabled and a message is logged to the console.
45
+
46
+ ## Usage
47
+
48
+ Add the components to your app:
49
+
50
+ ```vue
51
+ <template>
52
+ <div>
53
+ <!-- Button to open the chat -->
54
+ <AssistantChat />
55
+
56
+ <!-- Chat panel (place once in your app/layout) -->
57
+ <AssistantPanel />
58
+ </div>
59
+ </template>
60
+ ```
61
+
62
+ ### FAQ Questions
63
+
64
+ Configure FAQ questions in your `app.config.ts`:
65
+
66
+ ```ts
67
+ export default defineAppConfig({
68
+ assistant: {
69
+ faqQuestions: [
70
+ {
71
+ category: 'Getting Started',
72
+ items: ['How do I install?', 'How do I configure?'],
73
+ },
74
+ {
75
+ category: 'Advanced',
76
+ items: ['How do I customize?'],
77
+ },
78
+ ],
79
+ },
80
+ })
81
+ ```
82
+
83
+ You can also use localized FAQ questions:
84
+
85
+ ```ts
86
+ export default defineAppConfig({
87
+ assistant: {
88
+ faqQuestions: {
89
+ en: ['How do I install?', 'How do I configure?'],
90
+ fr: ['Comment installer ?', 'Comment configurer ?'],
91
+ },
92
+ },
93
+ })
94
+ ```
95
+
96
+ ### Floating Input
97
+
98
+ Use `AssistantFloatingInput` for a floating input at the bottom of the page.
99
+
100
+ **Recommended:** Use `Teleport` to render the floating input at the body level, ensuring it stays fixed at the bottom regardless of your component hierarchy:
101
+
102
+ ```vue
103
+ <template>
104
+ <div>
105
+ <!-- Teleport to body for proper fixed positioning -->
106
+ <Teleport to="body">
107
+ <ClientOnly>
108
+ <LazyAssistantFloatingInput />
109
+ </ClientOnly>
110
+ </Teleport>
111
+
112
+ <!-- Chat panel (required to display responses) -->
113
+ <AssistantPanel />
114
+ </div>
115
+ </template>
116
+ ```
117
+
118
+ The floating input:
119
+ - Appears at the bottom center of the viewport
120
+ - Automatically hides when the chat slideover is open
121
+ - Expands on focus for better typing experience
122
+ - Supports keyboard shortcuts: `⌘I` to focus, `Escape` to blur
123
+
124
+ ### Programmatic Control
125
+
126
+ Use the `useAssistant` composable to control the chat:
127
+
128
+ ```vue
129
+ <script setup>
130
+ const { open, close, toggle, isOpen, messages, clearMessages } = useAssistant()
131
+
132
+ // Open chat with an initial message
133
+ open('How do I install the module?')
134
+
135
+ // Open and clear previous messages
136
+ open('New question', true)
137
+
138
+ // Toggle chat visibility
139
+ toggle()
140
+
141
+ // Clear all messages
142
+ clearMessages()
143
+ </script>
144
+ ```
145
+
146
+ ## Module Options
147
+
148
+ | Option | Type | Default | Description |
149
+ |--------|------|---------|-------------|
150
+ | `apiPath` | `string` | `/__docus__/assistant` | API endpoint path for the chat |
151
+ | `mcpServer` | `string` | `/mcp` | MCP server path or full URL (e.g., `https://docs.example.com/mcp` for external servers) |
152
+ | `model` | `string` | `google/gemini-3-flash` | AI model identifier for AI SDK Gateway |
153
+
154
+ ## Components
155
+
156
+ ### `<AssistantChat>`
157
+
158
+ Button to toggle the chat panel. The tooltip text is automatically translated using i18n (`assistant.tooltip`).
159
+
160
+ ### `<AssistantPanel>`
161
+
162
+ Main chat interface displayed as a side panel. Configuration is done via `app.config.ts` (see FAQ Questions section above).
163
+
164
+ ### `<AssistantFloatingInput>`
165
+
166
+ Floating input field positioned at the bottom of the viewport. No props required.
167
+
168
+ **Keyboard shortcuts:**
169
+ - `⌘I` / `Ctrl+I` - Focus the input
170
+ - `Escape` - Blur the input
171
+ - `Enter` - Submit the question
172
+
173
+ ## Composables
174
+
175
+ ### `useAssistant`
176
+
177
+ Main composable for controlling the chat state.
178
+
179
+ ```ts
180
+ const {
181
+ isOpen, // Ref<boolean> - Whether the chat is open
182
+ messages, // Ref<UIMessage[]> - Chat messages
183
+ pendingMessage, // Ref<string | undefined> - Pending message to send
184
+ faqQuestions, // ComputedRef<FaqCategory[]> - FAQ questions from config
185
+ open, // (message?: string, clearPrevious?: boolean) => void
186
+ close, // () => void
187
+ toggle, // () => void
188
+ clearMessages, // () => void
189
+ clearPending, // () => void
190
+ } = useAssistant()
191
+ ```
192
+
193
+ ### `useHighlighter`
194
+
195
+ Composable for syntax highlighting code blocks with Shiki.
196
+
197
+ ## Requirements
198
+
199
+ - Nuxt 4.x
200
+ - Nuxt UI 3.x (for `USlideover`, `UButton`, `UTextarea`, `UChatMessages`, etc.)
201
+ - An MCP server running (path configurable via `mcpServer`)
202
+ - `AI_GATEWAY_API_KEY` environment variable
203
+
204
+ ## Customization
205
+
206
+ ### System Prompt
207
+
208
+ To customize the AI's behavior, edit the system prompt in:
209
+ `runtime/server/api/search.ts`
210
+
211
+ ### Styling
212
+
213
+ The components use Nuxt UI and Tailwind CSS design tokens. Customize the appearance by modifying the component files or overriding the UI props.
@@ -0,0 +1,100 @@
1
+ import { addComponent, addImports, addServerHandler, createResolver, defineNuxtModule, logger } from '@nuxt/kit'
2
+
3
+ export interface AssistantModuleOptions {
4
+ /**
5
+ * API endpoint path for the assistant
6
+ * @default '/__docus__/assistant'
7
+ */
8
+ apiPath?: string
9
+ /**
10
+ * MCP server URL or path.
11
+ * - Use a path like '/mcp' to use the built-in Docus MCP server
12
+ * - Use a full URL like 'https://docs.example.com/mcp' for external MCP servers
13
+ * @default '/mcp'
14
+ */
15
+ mcpServer?: string
16
+ /**
17
+ * AI model to use via AI SDK Gateway
18
+ * @default 'google/gemini-3-flash'
19
+ */
20
+ model?: string
21
+ }
22
+
23
+ const log = logger.withTag('Docus')
24
+
25
+ export default defineNuxtModule<AssistantModuleOptions>({
26
+ meta: {
27
+ name: 'assistant',
28
+ configKey: 'assistant',
29
+ },
30
+ defaults: {
31
+ apiPath: '/__docus__/assistant',
32
+ mcpServer: '/mcp',
33
+ model: 'google/gemini-3-flash',
34
+ },
35
+ setup(options, nuxt) {
36
+ const hasApiKey = !!process.env.AI_GATEWAY_API_KEY
37
+
38
+ const { resolve } = createResolver(import.meta.url)
39
+
40
+ nuxt.options.runtimeConfig.public.assistant = {
41
+ enabled: hasApiKey,
42
+ apiPath: options.apiPath!,
43
+ }
44
+
45
+ addImports([
46
+ {
47
+ name: 'useAssistant',
48
+ from: resolve('./runtime/composables/useAssistant'),
49
+ },
50
+ ])
51
+
52
+ const components = [
53
+ 'AssistantChat',
54
+ 'AssistantPanel',
55
+ 'AssistantFloatingInput',
56
+ 'AssistantLoading',
57
+ 'AssistantMatrix',
58
+ ]
59
+
60
+ components.forEach(name =>
61
+ addComponent({
62
+ name,
63
+ filePath: hasApiKey
64
+ ? resolve(`./runtime/components/${name}.vue`)
65
+ : resolve('./runtime/components/AssistantChatDisabled.vue'),
66
+ }),
67
+ )
68
+
69
+ if (!hasApiKey) {
70
+ log.warn('AI assistant disabled: AI_GATEWAY_API_KEY not found')
71
+ return
72
+ }
73
+
74
+ nuxt.options.runtimeConfig.assistant = {
75
+ mcpServer: options.mcpServer!,
76
+ model: options.model!,
77
+ }
78
+
79
+ const routePath = options.apiPath!.replace(/^\//, '')
80
+ addServerHandler({
81
+ route: `/${routePath}`,
82
+ handler: resolve('./runtime/server/api/search'),
83
+ })
84
+ },
85
+ })
86
+
87
+ declare module 'nuxt/schema' {
88
+ interface PublicRuntimeConfig {
89
+ assistant: {
90
+ enabled: boolean
91
+ apiPath: string
92
+ }
93
+ }
94
+ interface RuntimeConfig {
95
+ assistant: {
96
+ mcpServer: string
97
+ model: string
98
+ }
99
+ }
100
+ }
@@ -0,0 +1,21 @@
1
+ <script setup lang="ts">
2
+ import { useDocusI18n } from '../../../../app/composables/useDocusI18n'
3
+
4
+ const appConfig = useAppConfig()
5
+ const { toggle } = useAssistant()
6
+ const { t } = useDocusI18n()
7
+
8
+ const tooltipText = computed(() => t('assistant.tooltip'))
9
+ const triggerIcon = computed(() => appConfig.assistant?.icons?.trigger || 'i-lucide-sparkles')
10
+ </script>
11
+
12
+ <template>
13
+ <UTooltip :text="tooltipText">
14
+ <UButton
15
+ :icon="triggerIcon"
16
+ variant="ghost"
17
+ class="rounded-full"
18
+ @click="toggle"
19
+ />
20
+ </UTooltip>
21
+ </template>
@@ -0,0 +1,3 @@
1
+ <template>
2
+ <div />
3
+ </template>
@@ -0,0 +1,110 @@
1
+ <script setup lang="ts">
2
+ import { AnimatePresence, motion } from 'motion-v'
3
+ import { useDocusI18n } from '../../../../app/composables/useDocusI18n'
4
+
5
+ const route = useRoute()
6
+ const appConfig = useAppConfig()
7
+ const { open, isOpen } = useAssistant()
8
+ const { t } = useDocusI18n()
9
+ const input = ref('')
10
+ const isVisible = ref(true)
11
+ const inputRef = ref<{ inputRef: HTMLInputElement } | null>(null)
12
+
13
+ const isDocsRoute = computed(() => route.meta.layout === 'docs')
14
+ const isFloatingInputEnabled = computed(() => appConfig.assistant?.floatingInput !== false)
15
+ const focusInputShortcut = computed(() => appConfig.assistant?.shortcuts?.focusInput || 'meta_i')
16
+ const placeholder = computed(() => t('assistant.placeholder'))
17
+
18
+ const shortcutDisplayKeys = computed(() => {
19
+ const shortcut = focusInputShortcut.value
20
+ const parts = shortcut.split('_')
21
+ return parts.map(part => part === 'meta' ? 'meta' : part.toUpperCase())
22
+ })
23
+
24
+ function handleSubmit() {
25
+ if (!input.value.trim()) return
26
+
27
+ const message = input.value
28
+ isVisible.value = false
29
+
30
+ setTimeout(() => {
31
+ open(message, true)
32
+ input.value = ''
33
+ isVisible.value = true
34
+ }, 200)
35
+ }
36
+
37
+ const shortcuts = computed(() => ({
38
+ [focusInputShortcut.value]: {
39
+ usingInput: true,
40
+ handler: () => {
41
+ if (!isDocsRoute.value || !isFloatingInputEnabled.value) return
42
+ inputRef.value?.inputRef?.focus()
43
+ },
44
+ },
45
+ escape: {
46
+ usingInput: true,
47
+ handler: () => {
48
+ inputRef.value?.inputRef?.blur()
49
+ },
50
+ },
51
+ }))
52
+
53
+ defineShortcuts(shortcuts)
54
+ </script>
55
+
56
+ <template>
57
+ <AnimatePresence>
58
+ <motion.div
59
+ v-if="isFloatingInputEnabled && isDocsRoute && isVisible && !isOpen"
60
+ key="floating-input"
61
+ :initial="{ y: 20, opacity: 0 }"
62
+ :animate="{ y: 0, opacity: 1 }"
63
+ :exit="{ y: 100, opacity: 0 }"
64
+ :transition="{ duration: 0.2, ease: 'easeOut' }"
65
+ class="fixed inset-x-0 z-10 px-4 sm:px-80 bottom-[max(1.5rem,env(safe-area-inset-bottom))]"
66
+ style="will-change: transform"
67
+ >
68
+ <form
69
+ class="flex w-full justify-center"
70
+ @submit.prevent="handleSubmit"
71
+ >
72
+ <div class="w-full max-w-96">
73
+ <UInput
74
+ ref="inputRef"
75
+ v-model="input"
76
+ :placeholder="placeholder"
77
+ size="lg"
78
+ maxlength="1000"
79
+ :ui="{
80
+ root: 'group w-full! min-w-0 sm:max-w-96 transition-all duration-300 ease-out [@media(hover:hover)]:hover:scale-105 [@media(hover:hover)]:focus-within:scale-105',
81
+ base: 'bg-default shadow-lg rounded-xl text-base',
82
+ trailing: 'pe-2',
83
+ }"
84
+ @keydown.enter.exact.prevent="handleSubmit"
85
+ >
86
+ <template #trailing>
87
+ <div class="flex items-center gap-2">
88
+ <div class="hidden sm:flex group-focus-within:hidden items-center gap-1">
89
+ <UKbd
90
+ v-for="key in shortcutDisplayKeys"
91
+ :key="key"
92
+ :value="key"
93
+ />
94
+ </div>
95
+
96
+ <UButton
97
+ type="submit"
98
+ icon="i-lucide-arrow-up"
99
+ color="primary"
100
+ size="xs"
101
+ :disabled="!input.trim()"
102
+ />
103
+ </div>
104
+ </template>
105
+ </UInput>
106
+ </div>
107
+ </form>
108
+ </motion.div>
109
+ </AnimatePresence>
110
+ </template>
@@ -0,0 +1,164 @@
1
+ <script setup lang="ts">
2
+ import { motion } from 'motion-v'
3
+ import { useDocusI18n } from '../../../../app/composables/useDocusI18n'
4
+
5
+ interface ToolCall {
6
+ toolCallId: string
7
+ toolName: string
8
+ args: Record<string, unknown>
9
+ }
10
+
11
+ const props = defineProps<{
12
+ text?: string
13
+ toolCalls?: ToolCall[]
14
+ isLoading?: boolean
15
+ }>()
16
+
17
+ const { t } = useDocusI18n()
18
+
19
+ const messages = computed(() => [
20
+ t('assistant.loading.searching'),
21
+ t('assistant.loading.reading'),
22
+ t('assistant.loading.analyzing'),
23
+ t('assistant.loading.finding'),
24
+ ])
25
+
26
+ const finishedMessage = computed(() => t('assistant.loading.finished'))
27
+
28
+ const currentIndex = ref(0)
29
+ const targetText = computed(() => {
30
+ if (!props.isLoading) {
31
+ return finishedMessage.value
32
+ }
33
+ return props.text || messages.value[currentIndex.value]
34
+ })
35
+ const displayedText = ref(targetText.value)
36
+
37
+ const chars = 'abcdefghijklmnopqrstuvwxyz'
38
+
39
+ function scrambleText(from: string, to: string) {
40
+ const maxLength = Math.max(from.length, to.length)
41
+ let frame = 0
42
+ const totalFrames = 15
43
+
44
+ const animate = () => {
45
+ frame++
46
+ let result = ''
47
+
48
+ for (let i = 0; i < maxLength; i++) {
49
+ const progress = frame / totalFrames
50
+ const charProgress = progress * maxLength
51
+
52
+ if (i < charProgress - 2) {
53
+ result += to[i] || ''
54
+ }
55
+ else if (i < charProgress) {
56
+ result += chars[Math.floor(Math.random() * chars.length)]
57
+ }
58
+ else {
59
+ result += from[i] || ''
60
+ }
61
+ }
62
+
63
+ displayedText.value = result
64
+
65
+ if (frame < totalFrames) {
66
+ requestAnimationFrame(animate)
67
+ }
68
+ else {
69
+ displayedText.value = to
70
+ }
71
+ }
72
+
73
+ requestAnimationFrame(animate)
74
+ }
75
+
76
+ let textInterval: ReturnType<typeof setInterval> | null = null
77
+
78
+ watch(targetText, (newText, oldText) => {
79
+ if (newText !== oldText && newText && oldText) {
80
+ scrambleText(oldText, newText)
81
+ }
82
+ })
83
+
84
+ // Stop text rotation when loading finishes
85
+ watch(() => props.isLoading, (isLoading) => {
86
+ if (!isLoading && textInterval) {
87
+ clearInterval(textInterval)
88
+ textInterval = null
89
+ }
90
+ })
91
+
92
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
93
+ function getToolLabel(toolName: string, args: any) {
94
+ const path = args?.path || ''
95
+
96
+ if (toolName === 'list-pages') {
97
+ return t('assistant.toolListPages')
98
+ }
99
+
100
+ if (toolName === 'get-page') {
101
+ return `${t('assistant.toolReadPage')} ${path || '...'}`
102
+ }
103
+
104
+ return toolName
105
+ }
106
+
107
+ onMounted(() => {
108
+ // Text rotation only when loading
109
+ if (!props.text && props.isLoading) {
110
+ textInterval = setInterval(() => {
111
+ currentIndex.value = (currentIndex.value + 1) % messages.value.length
112
+ }, 3500)
113
+ }
114
+ })
115
+
116
+ onUnmounted(() => {
117
+ if (textInterval) clearInterval(textInterval)
118
+ })
119
+ </script>
120
+
121
+ <template>
122
+ <div class="flex flex-col gap-2">
123
+ <!-- Main loader with matrix and text -->
124
+ <div class="flex items-center text-xs text-muted overflow-hidden">
125
+ <motion.div
126
+ v-if="isLoading"
127
+ :initial="{ opacity: 1, width: 'auto' }"
128
+ :exit="{ opacity: 0, width: 0 }"
129
+ :transition="{ duration: 0.2 }"
130
+ class="shrink-0 mr-2"
131
+ >
132
+ <AssistantMatrix />
133
+ </motion.div>
134
+ <motion.span
135
+ :animate="{ x: 0 }"
136
+ :transition="{ duration: 0.2 }"
137
+ class="font-mono tracking-tight"
138
+ >
139
+ {{ displayedText }}
140
+ </motion.span>
141
+ </div>
142
+
143
+ <!-- Tool calls displayed below -->
144
+ <div
145
+ v-if="toolCalls?.length"
146
+ class="flex flex-col gap-1"
147
+ :class="isLoading ? 'pl-[22px]' : 'pl-0'"
148
+ >
149
+ <motion.div
150
+ v-for="tool in toolCalls"
151
+ :key="`${tool.toolCallId}-${JSON.stringify(tool.args)}`"
152
+ :initial="{ opacity: 0, x: -4 }"
153
+ :animate="{ opacity: 1, x: 0 }"
154
+ :transition="{ duration: 0.15 }"
155
+ class="flex items-center gap-1.5"
156
+ >
157
+ <span class="size-1 rounded-full bg-current opacity-40" />
158
+ <span class="text-[11px] text-dimmed truncate max-w-[200px]">
159
+ {{ getToolLabel(tool.toolName, tool.args) }}
160
+ </span>
161
+ </motion.div>
162
+ </div>
163
+ </div>
164
+ </template>
@@ -0,0 +1,92 @@
1
+ <script setup lang="ts">
2
+ interface Props {
3
+ size?: number
4
+ dotSize?: number
5
+ gap?: number
6
+ }
7
+
8
+ const props = withDefaults(defineProps<Props>(), {
9
+ size: 4,
10
+ dotSize: 2,
11
+ gap: 2,
12
+ })
13
+
14
+ const totalDots = computed(() => props.size * props.size)
15
+ const activeDots = ref<Set<number>>(new Set())
16
+
17
+ // Patterns for 4x4 grid (indices 0-15)
18
+ // Grid layout:
19
+ // 0 1 2 3
20
+ // 4 5 6 7
21
+ // 8 9 10 11
22
+ // 12 13 14 15
23
+ const patterns = [
24
+ // Spiral inward
25
+ [[0], [1], [2], [3], [7], [11], [15], [14], [13], [12], [8], [4], [5], [6], [10], [9]],
26
+ // Wave horizontal
27
+ [[0, 4, 8, 12], [1, 5, 9, 13], [2, 6, 10, 14], [3, 7, 11, 15]],
28
+ // Diamond pulse
29
+ [[5, 6, 9, 10], [1, 4, 7, 8, 11, 14], [0, 3, 12, 15], [1, 4, 7, 8, 11, 14], [5, 6, 9, 10]],
30
+ // Loading bar
31
+ [[0, 1, 2, 3], [4, 5, 6, 7], [8, 9, 10, 11], [12, 13, 14, 15]],
32
+ // Corners rotate
33
+ [[0], [3], [15], [12]],
34
+ // Cross pulse
35
+ [[5, 6, 9, 10], [1, 2, 4, 7, 8, 11, 13, 14], [0, 3, 12, 15]],
36
+ // Snake
37
+ [[0], [1], [2], [3], [7], [6], [5], [4], [8], [9], [10], [11], [15], [14], [13], [12]],
38
+ // Diagonal wave
39
+ [[0], [1, 4], [2, 5, 8], [3, 6, 9, 12], [7, 10, 13], [11, 14], [15]],
40
+ ]
41
+
42
+ let patternIndex = 0
43
+ let stepIndex = 0
44
+ let interval: ReturnType<typeof setInterval> | null = null
45
+
46
+ function nextStep() {
47
+ const pattern = patterns[patternIndex]
48
+ if (!pattern) return
49
+
50
+ activeDots.value = new Set(pattern[stepIndex])
51
+ stepIndex++
52
+
53
+ if (stepIndex >= pattern.length) {
54
+ stepIndex = 0
55
+ patternIndex = (patternIndex + 1) % patterns.length
56
+ }
57
+ }
58
+
59
+ const gridStyle = computed(() => ({
60
+ display: 'grid',
61
+ gridTemplateColumns: `repeat(${props.size}, 1fr)`,
62
+ gap: `${props.gap}px`,
63
+ width: `${props.size * props.dotSize + (props.size - 1) * props.gap}px`,
64
+ height: `${props.size * props.dotSize + (props.size - 1) * props.gap}px`,
65
+ }))
66
+
67
+ const dotStyle = computed(() => ({
68
+ width: `${props.dotSize}px`,
69
+ height: `${props.dotSize}px`,
70
+ }))
71
+
72
+ onMounted(() => {
73
+ interval = setInterval(nextStep, 120)
74
+ nextStep()
75
+ })
76
+
77
+ onUnmounted(() => {
78
+ if (interval) clearInterval(interval)
79
+ })
80
+ </script>
81
+
82
+ <template>
83
+ <div :style="gridStyle">
84
+ <span
85
+ v-for="i in totalDots"
86
+ :key="i"
87
+ class="rounded-[0.5px] bg-current transition-opacity duration-100"
88
+ :class="activeDots.has(i - 1) ? 'opacity-100' : 'opacity-20'"
89
+ :style="dotStyle"
90
+ />
91
+ </div>
92
+ </template>