feat: Add CMD+K command palette with ninja-keys integration

Implement a command palette accessible via CMD+K/Ctrl+K using the ninja-keys
web component. Features include:

- New /api/cmd-k endpoint serving dynamic CV entries (experiences, projects, courses)
- Language-aware responses with 1-hour cache headers
- Scroll-to-section functionality for quick navigation
- Enhanced keyboard shortcuts modal with CMD+K documentation
- Comprehensive test coverage for API and UI interactions

Also includes cleanup of deprecated debug test files and various UI polish
improvements to contact form, themes, and action bar components.
This commit is contained in:
juanatsap
2025-12-01 13:03:06 +00:00
parent 976b8ae2e2
commit 9a848e8c53
45 changed files with 3070 additions and 1587 deletions
+315
View File
@@ -0,0 +1,315 @@
# CMD+K Command Palette API Documentation
## Overview
The CV application provides a command palette (CMD+K / Ctrl+K) powered by [ninja-keys](https://github.com/nickadam/ninja-keys) web component. Dynamic entries (experiences, projects, courses) are loaded from a backend API endpoint, allowing automatic updates when CV data changes without modifying JavaScript code.
## Architecture
### Design Decision
**API-First Approach**: Rather than hardcoding entries in JavaScript or reading from DOM elements, the command palette fetches its dynamic data from a dedicated API endpoint. This provides:
1. **Automatic Updates**: New CV entries appear in CMD+K without code changes
2. **Language Support**: API returns localized data based on language parameter
3. **Cache Efficiency**: 1-hour cache headers reduce redundant requests
4. **Separation of Concerns**: Frontend only handles rendering; backend owns data
### Data Flow
```
User opens CMD+K (Ctrl+K / Cmd+K)
ninja-keys-init.js initializes
fetch('/api/cmd-k?lang={en|es}')
Backend loads CV data from JSON files
Maps experiences, projects, courses to actions
Returns JSON with action arrays
Frontend combines with static actions
ninja-keys displays searchable command palette
```
## API Endpoint
### GET /api/cmd-k
Returns dynamic entries for the ninja-keys command palette.
**URL**: `/api/cmd-k`
**Method**: `GET`
**Authentication**: None (public endpoint)
#### Query Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `lang` | string | `en` | Language code (`en` or `es`) |
#### Response
**Content-Type**: `application/json`
**Cache-Control**: `public, max-age=3600` (1 hour)
```json
{
"experiences": [
{
"id": "exp-olympic-broadcasting",
"title": "Olympic Broadcasting Services",
"section": "Experience",
"keywords": "Olympic Broadcasting Services Senior SAP Technical Consultant"
}
],
"projects": [
{
"id": "proj-somos-una-ola",
"title": "Somos Una Ola",
"section": "Projects",
"keywords": "Somos Una Ola Volunteer project promoting beach cleaning..."
}
],
"courses": [
{
"id": "course-codecademy-certifications",
"title": "Codecademy Certifications",
"section": "Courses",
"keywords": "Codecademy Certifications Codecademy"
}
]
}
```
#### Response Fields
| Field | Type | Description |
|-------|------|-------------|
| `experiences` | array | Work experience entries |
| `projects` | array | Personal/professional project entries |
| `courses` | array | Course and certification entries |
Each entry contains:
| Field | Type | Description |
|-------|------|-------------|
| `id` | string | Unique identifier (e.g., `exp-{companyId}`, `proj-{projectId}`) |
| `title` | string | Display title for the command palette |
| `section` | string | Section label (`Experience`, `Projects`, `Courses`) |
| `keywords` | string | Searchable keywords for filtering |
#### Example Requests
```bash
# English (default)
curl http://localhost:1999/api/cmd-k
# Spanish
curl http://localhost:1999/api/cmd-k?lang=es
# With jq formatting
curl -s http://localhost:1999/api/cmd-k | jq '.'
# Check response headers
curl -I http://localhost:1999/api/cmd-k
```
#### Error Responses
| Status | Description |
|--------|-------------|
| 500 | Failed to load CV data |
## Frontend Integration
### ninja-keys-init.js
The frontend JavaScript fetches from the API and combines with static actions:
```javascript
// Fetch dynamic entries from API
async function fetchDynamicEntries() {
try {
const response = await fetch(`/api/cmd-k?lang=${lang}`);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
return await response.json();
} catch (error) {
console.error('Failed to fetch CMD+K data:', error);
return { experiences: [], projects: [], courses: [] };
}
}
// Combine with static actions
const dynamicData = await fetchDynamicEntries();
const actions = [
...staticActions,
...mapExperienceActions(dynamicData.experiences || []),
...mapProjectActions(dynamicData.projects || []),
...mapCourseActions(dynamicData.courses || [])
];
ninjaKeys.data = actions;
```
### Action Mapping
Dynamic entries are converted to ninja-keys actions with handlers:
```javascript
function mapExperienceActions(experiences) {
return experiences.map(exp => ({
id: exp.id,
title: exp.title,
section: exp.section,
keywords: `${exp.keywords} work job career`.toLowerCase(),
icon: '<iconify-icon icon="mdi:office-building" width="20"></iconify-icon>',
handler: () => scrollToSection(exp.id)
}));
}
```
## Backend Implementation
### Handler: cv_cmdk.go
```go
// CmdKData returns JSON data for the ninja-keys command palette
func (h *CVHandler) CmdKData(w http.ResponseWriter, r *http.Request) {
lang := r.URL.Query().Get("lang")
if lang == "" {
lang = "en"
}
if lang != "en" && lang != "es" {
lang = "en"
}
cv, err := models.LoadCV(lang)
if err != nil {
http.Error(w, "Failed to load CV data", http.StatusInternalServerError)
return
}
response := CmdKResponse{
Experiences: mapExperiences(cv.Experience),
Projects: mapProjects(cv.Projects),
Courses: mapCourses(cv.Courses),
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "public, max-age=3600")
json.NewEncoder(w).Encode(response)
}
```
### Route Registration
```go
// routes/routes.go
// API routes (must be before "/" to avoid catch-all)
mux.HandleFunc("/api/cmd-k", cvHandler.CmdKData)
```
## ID Convention
IDs follow a consistent pattern matching DOM element IDs for scroll targeting:
| Type | Pattern | Example |
|------|---------|---------|
| Experience | `exp-{companyId}` | `exp-olympic-broadcasting` |
| Project | `proj-{projectId}` | `proj-somos-una-ola` |
| Course | `course-{courseId}` | `course-codecademy-certifications` |
These IDs correspond to HTML element IDs in the page:
```html
<div class="experience-item" id="exp-olympic-broadcasting">...</div>
<div class="project-item" id="proj-somos-una-ola">...</div>
<div class="course-item" id="course-codecademy-certifications">...</div>
```
## Static Actions
In addition to dynamic entries, the command palette includes static actions:
### Navigation
- Jump to Top, Experience, Education, Skills, Projects, Courses, Languages, Awards, Other Info
### Shortcuts
- Toggle CV Length (L key)
- Toggle Icons (I key)
- Toggle Theme (V key)
- Show Shortcuts Help (? key)
- Print CV (Cmd+P)
### Downloads
- Download PDF (Default, Short, Extended versions)
- View/Download Text CV
### Actions
- Open Contact Form
- Show Site Info
- Toggle Zoom Controls
- Switch Language (EN/ES)
- Change Color Theme
### Social Links
- LinkedIn, GitHub, Domestika, Personal Website
## Testing
### Unit Tests (Go)
Located at `internal/handlers/cv_cmdk_test.go`:
```go
func TestCmdKData(t *testing.T) {
// Tests: Default language, English, Spanish, Invalid language fallback
// Validates: Status code, Content-Type, response structure, counts
}
func TestCmdKDataCaching(t *testing.T) {
// Validates Cache-Control header
}
```
Run with:
```bash
go test ./internal/handlers/ -run TestCmdK -v
```
### E2E Tests (Playwright/Bun)
Located at `tests/mjs/71-cmd-k-api-scroll.test.mjs`:
Tests:
1. API returns valid JSON with expected structure
2. Experience scroll navigation works
3. Project scroll navigation works
4. Course scroll navigation works
5. Section scroll navigation works
6. Multiple sequential searches work correctly
Run with:
```bash
HEADLESS=true bun run tests/mjs/71-cmd-k-api-scroll.test.mjs
```
## Performance
- **Cache Duration**: 1 hour (reduces API calls on page refresh)
- **Response Size**: ~2-3 KB (compact JSON)
- **Load Time**: API fetched during page initialization
- **Fallback**: Empty arrays returned on error (graceful degradation)
## Files
| File | Purpose |
|------|---------|
| `internal/handlers/cv_cmdk.go` | API handler |
| `internal/handlers/cv_cmdk_test.go` | Unit tests |
| `internal/routes/routes.go` | Route registration |
| `static/js/ninja-keys-init.js` | Frontend integration |
| `tests/mjs/71-cmd-k-api-scroll.test.mjs` | E2E tests |
+140
View File
@@ -3602,4 +3602,144 @@ Test structured data:
---
### 15. Dynamic Contact Form - HTMX + Hyperscript Pattern
**Problem:** Traditional contact forms either require full page reloads (poor UX) or heavy JavaScript frameworks (React, Vue) for dynamic behavior.
**Solution:** HTMX for server communication + Hyperscript for declarative behavior = dynamic SPA-like experience with zero custom JavaScript.
#### Architecture Overview
```
┌─────────────────────────────────────────────────────────────────┐
│ CONTACT FORM FLOW │
├─────────────────────────────────────────────────────────────────┤
│ │
│ [User Input] → [HTMX POST] → [Go Handler] → [HTML Response] │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ Honeypot + hx-post Validation Success/Error │
│ Timestamp hx-target + Security HTML partial │
│ hx-swap │
│ │
│ [Hyperscript handles UI state based on response content] │
│ │
└─────────────────────────────────────────────────────────────────┘
```
#### HTMX Configuration
```html
<form id="contact-form"
hx-post="/api/contact?lang={{.Lang}}"
hx-target="#contact-response"
hx-swap="innerHTML"
hx-indicator="#contact-spinner"
hx-headers='{"X-Requested-With": "XMLHttpRequest"}'>
```
**What Each Attribute Does:**
- `hx-post`: Send form data to server without page reload
- `hx-target`: Where to inject the server response
- `hx-swap`: How to replace content (innerHTML = replace contents)
- `hx-indicator`: Element to show during request (loading spinner)
- `hx-headers`: Additional headers for AJAX detection
#### Hyperscript for Success Detection
```html
_="on htmx:afterRequest
set responseDiv to document.getElementById('contact-response')
if responseDiv is not null and responseDiv.querySelector('.contact-success') is not null
-- Hide form fields on success
set formFields to me.querySelectorAll('.form-group')
repeat for field in formFields
add .hidden to field
end
add .hidden to me.querySelector('.form-actions')
add .hidden to me.querySelector('.form-note')
-- Auto-close modal after 3 seconds
wait 3s then call document.getElementById('contact-modal').close()
end"
```
**Key Insight:** Success is detected by checking for `.contact-success` element in the response, not HTTP status codes. This allows validation errors to return HTTP 200 (avoiding HTMX console errors) while still distinguishing success from error states.
#### Server Response Pattern
**Validation Error Response (HTTP 200):**
```html
<div class="contact-message contact-error">
<iconify-icon icon="mdi:alert-circle"></iconify-icon>
<div class="contact-message-content">
<strong>Error</strong>
<p>Message is too short (minimum 10 characters)</p>
</div>
</div>
```
**Success Response (HTTP 200):**
```html
<div class="contact-message contact-success">
<iconify-icon icon="mdi:check-circle"></iconify-icon>
<div class="contact-message-content">
<strong>Message Sent!</strong>
<p>Thank you for your message. I'll get back to you soon.</p>
</div>
</div>
```
#### Bot Protection (Zero JavaScript)
```html
<!-- Honeypot field - invisible to users, filled by bots -->
<div style="position: absolute; left: -9999px;" aria-hidden="true">
<input type="text" name="website" tabindex="-1" autocomplete="off">
</div>
<!-- Timing field - set via inline script on page load -->
<input type="hidden" name="form_loaded_at" id="contact-form-loaded-at">
```
```go
// Server-side validation
if data.Website != "" {
return fmt.Errorf("spam detected: honeypot field filled")
}
if elapsed < 2000 { // Less than 2 seconds
return fmt.Errorf("spam detected: form filled too quickly")
}
```
#### Benefits of This Approach
| Aspect | Traditional SPA | HTMX + Hyperscript |
|--------|-----------------|---------------------|
| **JavaScript Size** | ~100KB+ (React/Vue) | ~15KB (HTMX) + ~8KB (Hyperscript) |
| **Build Process** | Webpack, Babel, bundler | None required |
| **Server Rendering** | Complex hydration | Native server templates |
| **Form State** | Complex state management | Declarative behavior |
| **Validation Feedback** | Custom JS handlers | HTML partial swap |
| **SEO** | SSR complexity | Works out of the box |
#### Complete User Experience
1. **User opens modal** → Native `<dialog>` element with `showModal()`
2. **User fills form** → Standard HTML form with browser validation
3. **User submits** → HTMX sends POST, shows spinner
4. **Validation error** → Error HTML swapped into response div (no page reload)
5. **Success** → Success HTML swapped, form fields hidden, modal auto-closes
**Result:** Full SPA-like dynamic form with:
- Zero page reloads
- Inline validation feedback
- Loading states
- Success animations
- Auto-close behavior
**All achieved with ~50 lines of declarative code (HTML attributes + Hyperscript) instead of hundreds of lines of JavaScript.**
---
*This document serves as both a technical reference and a demonstration of modern web development practices that prioritize web standards, performance, progressive enhancement, AI-era SEO, and superior user experience over JavaScript-heavy solutions.*
+8
View File
@@ -55,6 +55,8 @@ http://localhost:1999
|----------|--------|-------------|------------|
| `/?lang={en\|es}` | GET | Full HTML page with CV content | Initial page load |
| `/cv?lang={en\|es}` | GET | HTML partial for HTMX swaps | Language switching |
| `/text?lang={en\|es}` | GET | Plain text CV for terminal/AI | curl, text browsers |
| `/api/cmd-k?lang={en\|es}` | GET | CMD+K command palette data (JSON) | ninja-keys integration |
| `/export/pdf?lang={en\|es}&length={short\|long}&icons={show\|hide}&version={extended\|clean}` | GET | Download PDF resume with parameters | Export functionality |
| `/health` | GET | Health check (JSON) | Monitoring |
| `/static/{path}` | GET | Static files (CSS, JS, images) | Assets |
@@ -77,6 +79,12 @@ curl "http://localhost:1999/cv?lang=en"
# Export PDF (short, clean version)
curl -O -J "http://localhost:1999/export/pdf?lang=en&length=short&version=clean"
# CMD+K command palette data (JSON)
curl -s http://localhost:1999/api/cmd-k | jq '.experiences | length'
# Plain text CV
curl http://localhost:1999/text?lang=en
# Static file with headers
curl -I http://localhost:1999/static/css/main.css
```