diff --git a/src/_layouts/base.html b/src/_layouts/base.html index 02c2299..2ce9e74 100644 --- a/src/_layouts/base.html +++ b/src/_layouts/base.html @@ -30,9 +30,43 @@ - +
+ + + +
+ {% if page.js %} {% for src in page.js %} diff --git a/src/assets/css/main.css b/src/assets/css/main.css index 0442dd6..9260169 100644 --- a/src/assets/css/main.css +++ b/src/assets/css/main.css @@ -86,23 +86,128 @@ body::before { z-index: 0; } -.ai_helper_overlay { +.ai-helper { position: fixed; top: 12px; right: 12px; + z-index: 9999; +} + +.ai-helper__toggle { width: 64px; height: 64px; - z-index: 9999; - pointer-events: none; + border: 0; + border-radius: 50%; + cursor: pointer; + background: transparent; + padding: 0; +} + +.ai-helper__icon { + display: block; + width: 100%; + height: 100%; border-radius: 50%; overflow: hidden; - background-image: url("data:image/svg+xml;utf8,"); background-image: url('/assets/img/g-wizz.svg'); background-size: contain; background-repeat: no-repeat; - animation: ai_helper_overlay 0.4s infinite alternate ease-in-out; + animation: ai-helper-icon 0.4s infinite alternate ease-in-out; +} + +.ai-helper__chat { + position: absolute; + top: 74px; + right: 0; + width: min(90vw, 340px); + max-height: min(70vh, 480px); + background: color-mix(in oklab, var(--card) 94%, #000 6%); + border: 1px solid var(--border-strong); + border-radius: var(--radius); + padding: 14px; + box-shadow: + 0 0 22px rgba(0, 0, 0, 0.7), + var(--glow); + display: grid; + gap: 10px; +} + +.ai-helper__title { + margin: 0; + font-size: 11px; + color: var(--muted); +} + +.ai-helper__messages { + display: grid; + gap: 8px; + overflow-y: auto; + max-height: 260px; + padding-right: 4px; +} + +.ai-helper__message { + margin: 0; + padding: 8px 10px; + border: 1px solid var(--border-strong); + border-radius: 10px; + font-size: 10px; + line-height: 1.6; +} + +.ai-helper__message--assistant { + background: color-mix(in oklab, var(--primary) 12%, transparent); +} + +.ai-helper__message--user { + background: color-mix(in oklab, var(--primary) 24%, transparent); +} + +.ai-helper__form { + display: grid; + grid-template-columns: 1fr auto; + gap: 8px; +} + +.ai-helper__input, +.ai-helper__send { + font: inherit; + color: var(--text); + border: 1px solid var(--border-strong); + border-radius: 10px; + background: color-mix(in oklab, var(--card) 86%, #000 14%); +} + +.ai-helper__input { + min-width: 0; + padding: 8px 10px; +} + +.ai-helper__send { + padding: 8px 10px; + cursor: pointer; +} + +.ai-helper__toggle:focus-visible, +.ai-helper__input:focus-visible, +.ai-helper__send:focus-visible { + outline: none; + box-shadow: 0 0 0 4px var(--ring); } -@keyframes ai_helper_overlay { + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +@keyframes ai-helper-icon { 0% { transform: rotate(-4deg); } diff --git a/src/assets/js/ai-assistant.js b/src/assets/js/ai-assistant.js new file mode 100644 index 0000000..9dadbc9 --- /dev/null +++ b/src/assets/js/ai-assistant.js @@ -0,0 +1,93 @@ +const DEFAULT_REPLY = + 'Ik heb nog geen live AI-backend. Gebruik dit venster voor snelle navigatiehulp en ideetjes.'; + +const KEYWORD_RESPONSES = [ + { + terms: ['blog', 'post', 'artikel'], + reply: + 'Open /blog/ voor nieuwste artikelen. Wil je filters? Gebruik categorieknoppen op de blogpagina.', + }, + { + terms: ['widget', 'dashboard', 'pomodoro', 'weer', 'clock'], + reply: 'Dashboard bevat Pomodoro, Weather en Clock widgets. Je vindt deze via /dashboard/.', + }, + { + terms: ['update', 'nieuw', 'changelog'], + reply: 'Open /updates/ om recente commits, bijdragers en release-notes te bekijken.', + }, +]; + +export const normalizePrompt = (value = '') => value.trim().toLowerCase(); + +export const getAssistantReply = (prompt) => { + const normalizedPrompt = normalizePrompt(prompt); + if (!normalizedPrompt) { + return 'Typ een korte vraag, bijvoorbeeld: "Waar staat de Pomodoro?"'; + } + + const match = KEYWORD_RESPONSES.find(({ terms }) => + terms.some((term) => normalizedPrompt.includes(term)), + ); + + return match ? match.reply : DEFAULT_REPLY; +}; + +export const createMessage = (text, tone) => { + const message = document.createElement('p'); + message.className = `ai-helper__message ai-helper__message--${tone}`; + message.textContent = text; + return message; +}; + +export const initAiAssistant = (root = document) => { + const toggle = root.querySelector('[data-ai-helper-toggle]'); + const panel = root.querySelector('[data-ai-helper-chat]'); + const form = root.querySelector('[data-ai-helper-form]'); + const input = root.querySelector('#ai-helper-input'); + const log = root.querySelector('[data-ai-helper-messages]'); + + if (!toggle || !panel || !form || !input || !log) return; + + const setOpen = (isOpen) => { + panel.hidden = !isOpen; + toggle.setAttribute('aria-expanded', String(isOpen)); + if (isOpen) { + input.focus(); + } + }; + + toggle.addEventListener('click', () => { + const isOpen = toggle.getAttribute('aria-expanded') !== 'true'; + setOpen(isOpen); + }); + + form.addEventListener('submit', (event) => { + event.preventDefault(); + + const prompt = input.value; + const cleanedPrompt = prompt.trim(); + if (!cleanedPrompt) { + log.append(createMessage(getAssistantReply(''), 'assistant')); + log.scrollTop = log.scrollHeight; + form.reset(); + input.focus(); + return; + } + + log.append(createMessage(cleanedPrompt, 'user')); + log.append(createMessage(getAssistantReply(cleanedPrompt), 'assistant')); + log.scrollTop = log.scrollHeight; + form.reset(); + input.focus(); + }); + + document.addEventListener('keydown', (event) => { + if (event.key === 'Escape') { + setOpen(false); + } + }); +}; + +if (typeof document !== 'undefined') { + initAiAssistant(); +} diff --git a/src/test/ai-assistant.test.js b/src/test/ai-assistant.test.js new file mode 100644 index 0000000..9700b86 --- /dev/null +++ b/src/test/ai-assistant.test.js @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from 'vitest'; +import { getAssistantReply, normalizePrompt } from '../assets/js/ai-assistant.js'; + +describe('ai assistant helpers', () => { + it('normalizes prompt text', () => { + expect(normalizePrompt(' HeLLo ')).toBe('hello'); + }); + + it('returns guidance for empty prompts', () => { + expect(getAssistantReply('')).toContain('Typ een korte vraag'); + }); + + it('matches prompts to known sections', () => { + expect(getAssistantReply('Waar is de blog pagina?')).toContain('/blog/'); + expect(getAssistantReply('toont dashboard widgets')).toContain('/dashboard/'); + expect(getAssistantReply('laat updates zien')).toContain('/updates/'); + }); + + it('returns default fallback when no keyword matches', () => { + expect(getAssistantReply('onbekende vraag')).toContain('nog geen live AI-backend'); + }); + + it('registers behavior only when required nodes exist', async () => { + vi.resetModules(); + + const fakeToggle = { + state: 'false', + setAttribute: vi.fn(function setAttribute(_name, value) { + this.state = value; + }), + getAttribute: vi.fn(function getAttribute() { + return this.state; + }), + addEventListener: vi.fn(), + }; + + const fakeRoot = { + querySelector: (selector) => { + if (selector === '[data-ai-helper-toggle]') return fakeToggle; + return null; + }, + }; + + const { initAiAssistant } = await import('../assets/js/ai-assistant.js'); + expect(() => initAiAssistant(fakeRoot)).not.toThrow(); + }); +});