Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion src/_layouts/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,43 @@
<footer>{% include footer.html %}</footer>
</div>
</div>
<div class="ai_helper_overlay" aria-hidden="true"></div>
<div class="ai-helper" data-ai-helper>
<button
class="ai-helper__toggle"
type="button"
aria-expanded="false"
aria-controls="ai-helper-chat"
data-ai-helper-toggle
>
<span class="sr-only">Open AI assistent</span>
<span class="ai-helper__icon" aria-hidden="true"></span>
</button>

<section class="ai-helper__chat" id="ai-helper-chat" hidden data-ai-helper-chat>
<p class="ai-helper__title">Wizz AI assistent</p>
<div class="ai-helper__messages" role="log" aria-live="polite" data-ai-helper-messages>
<p class="ai-helper__message ai-helper__message--assistant">
👋 Vraag iets over de site, widgets of nieuwste updates.
</p>
</div>
<form class="ai-helper__form" data-ai-helper-form>
<label class="sr-only" for="ai-helper-input">Bericht voor assistent</label>
<input
class="ai-helper__input"
id="ai-helper-input"
type="text"
name="prompt"
autocomplete="off"
placeholder="Type je vraag..."
required
/>
<button class="ai-helper__send" type="submit">Send</button>
</form>
</section>
</div>

<script src="/assets/js/site.js" defer></script>
<script src="/assets/js/ai-assistant.js" type="module"></script>
<script src="/assets/js/login/login-buttons.js" type="module"></script>

{% if page.js %} {% for src in page.js %}
Expand Down
117 changes: 111 additions & 6 deletions src/assets/css/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -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,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'><rect width='64' height='64' fill='none'/><circle cx='32' cy='28' r='16' fill='rgb(198,140,76)'/><rect x='16' y='12' width='32' height='10' fill='rgb(56,40,20)'/><rect x='18' y='22' width='10' height='4' fill='rgb(56,40,20)'/><rect x='26' y='26' width='4' height='4' fill='rgb(0,0,0)'/><rect x='34' y='26' width='4' height='4' fill='rgb(0,0,0)'/><rect x='28' y='34' width='8' height='2' fill='rgb(80,30,30)'/><line x1='10' y1='30' x2='4' y2='24' stroke='rgb(255,255,255)' stroke-width='2'/><line x1='54' y1='30' x2='60' y2='24' stroke='rgb(255,255,255)' stroke-width='2'/></svg>");
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);
}
Expand Down
93 changes: 93 additions & 0 deletions src/assets/js/ai-assistant.js
Original file line number Diff line number Diff line change
@@ -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();
}
47 changes: 47 additions & 0 deletions src/test/ai-assistant.test.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading