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:
@@ -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 |
|
||||
@@ -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.*
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user