diff --git a/.forgejo/workflows/test.yml b/.forgejo/workflows/test.yml
index bd05693..5f7efb4 100644
--- a/.forgejo/workflows/test.yml
+++ b/.forgejo/workflows/test.yml
@@ -11,12 +11,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
- uses: https://github.com/actions/checkout@v4
+ uses: https://github.com/actions/checkout@v5
- name: Set up Go
uses: https://github.com/actions/setup-go@v5
with:
go-version-file: go.mod
+ - name: Clean vendor
+ run: rm -rf vendor
+
- name: Test
run: go test -race -v ./...
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..47cc920
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,25 @@
+name: Tests
+
+on:
+ pull_request:
+ branches: [main]
+ push:
+ branches: [main]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v5
+
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+
+ - name: Clean vendor
+ run: rm -rf vendor
+
+ - name: Test
+ run: go test -race -v ./...
diff --git a/.gitignore b/.gitignore
index a5388c7..6cea500 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,11 @@
node_modules/
.agent/
+internal/spa/dist/
+frontend/node_modules/
+frontend/dist/
+frontend/bun.lock
+frontend/bun.lockb
+frontend/package-lock.json
*.exe
*.exe~
*.dll
diff --git a/README.md b/README.md
index c03019e..25c1c29 100644
--- a/README.md
+++ b/README.md
@@ -221,4 +221,4 @@ Includes Valkey 8 with health checks out of the box.
## License
-MIT
+[AGPLv3](https://www.gnu.org/licenses/agpl-3.0.html)
diff --git a/cmd/kafka/main.go b/cmd/kafka/main.go
index cdc81b5..29ab620 100644
--- a/cmd/kafka/main.go
+++ b/cmd/kafka/main.go
@@ -19,7 +19,6 @@ package main
import (
"flag"
"fmt"
- "io/fs"
"log"
"log/slog"
"net/http"
@@ -31,7 +30,7 @@ import (
"github.com/metamorphosis-dev/kafka/internal/httpapi"
"github.com/metamorphosis-dev/kafka/internal/middleware"
"github.com/metamorphosis-dev/kafka/internal/search"
- "github.com/metamorphosis-dev/kafka/internal/views"
+ "github.com/metamorphosis-dev/kafka/internal/spa"
)
func main() {
@@ -80,22 +79,20 @@ func main() {
h := httpapi.NewHandler(svc, acSvc.Suggestions, cfg.Server.SourceURL)
mux := http.NewServeMux()
- mux.HandleFunc("/", h.Index)
+
+ // API routes - handled by Go
mux.HandleFunc("/healthz", h.Healthz)
mux.HandleFunc("/search", h.Search)
mux.HandleFunc("/autocompleter", h.Autocompleter)
mux.HandleFunc("/opensearch.xml", h.OpenSearch(cfg.Server.BaseURL))
- // Serve embedded static files (CSS, JS, images).
- staticFS, err := views.StaticFS()
- if err != nil {
- log.Fatalf("failed to load static files: %v", err)
- }
- var subFS fs.FS = staticFS
- mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(subFS))))
+ // SPA handler - serves React app for all other routes
+ spaHandler := spa.NewHandler()
+ mux.Handle("/", spaHandler)
- // Apply middleware: global rate limit → burst rate limit → per-IP rate limit → CORS → handler.
+ // Apply middleware: global rate limit → burst rate limit → per-IP rate limit → CORS → security headers → handler.
var handler http.Handler = mux
+ handler = middleware.SecurityHeaders(middleware.SecurityHeadersConfig{})(handler)
handler = middleware.CORS(middleware.CORSConfig{
AllowedOrigins: cfg.CORS.AllowedOrigins,
AllowedMethods: cfg.CORS.AllowedMethods,
@@ -107,6 +104,7 @@ func main() {
Requests: cfg.RateLimit.Requests,
Window: cfg.RateLimitWindow(),
CleanupInterval: cfg.RateLimitCleanupInterval(),
+ TrustedProxies: cfg.RateLimit.TrustedProxies,
}, logger)(handler)
handler = middleware.GlobalRateLimit(middleware.GlobalRateLimitConfig{
Requests: cfg.GlobalRateLimit.Requests,
diff --git a/docs/superpowers/plans/2026-03-22-brave-search-frontend-redesign.md b/docs/superpowers/plans/2026-03-22-brave-search-frontend-redesign.md
new file mode 100644
index 0000000..28b98a1
--- /dev/null
+++ b/docs/superpowers/plans/2026-03-22-brave-search-frontend-redesign.md
@@ -0,0 +1,1222 @@
+# Brave Search Frontend Redesign — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Redesign the kafka frontend to match Brave Search's layout: three-column results page, category tiles on homepage, and a hybrid preferences system with full-page `/preferences` route.
+
+**Architecture:** CSS Grid for page-level layouts (three-column results, two-column preferences). JavaScript popover for quick settings (theme + engines only). Server-rendered full preferences page with localStorage persistence. Category tiles are static links with category query params.
+
+**Tech Stack:** Go (Go templates), CSS Grid/Flexbox, Vanilla JavaScript (HTMX for search), localStorage for preferences
+
+---
+
+## File Map
+
+| File | Responsibility |
+|------|----------------|
+| `internal/views/static/css/kafka.css` | Add layout grids, category tiles, sidebar styles, mobile breakpoints |
+| `internal/views/templates/index.html` | Add category tiles below search box |
+| `internal/views/templates/results.html` | Add left sidebar, restructure for three-column grid |
+| `internal/views/templates/preferences.html` | **New** — full preferences page with nav |
+| `internal/views/templates/base.html` | No structural changes needed |
+| `internal/views/static/js/settings.js` | Reduce popover to theme + engines; add preferences page JS |
+| `internal/httpapi/handlers.go` | Add `GET /preferences` and `POST /preferences` handlers |
+| `internal/views/views.go` | Add `RenderPreferences` and `tmplPreferences` template |
+
+---
+
+## PHASE 1: CSS Layout Framework
+
+### Task 1: Add CSS Grid Layouts and Breakpoints
+
+**Files:**
+- Modify: `internal/views/static/css/kafka.css`
+
+- [ ] **Step 1: Add three-column results layout CSS**
+
+Append to end of `kafka.css`, before the `@media print` block:
+
+```css
+/* ============================================================
+ Three-Column Results Layout
+ ============================================================ */
+
+.results-layout {
+ display: grid;
+ grid-template-columns: 200px 1fr 240px;
+ gap: 2rem;
+ align-items: start;
+}
+
+.results-layout .left-sidebar {
+ position: sticky;
+ top: calc(var(--header-height) + 1.5rem);
+ max-height: calc(100vh - var(--header-height) - 3rem);
+ overflow-y: auto;
+}
+
+.results-layout .right-sidebar {
+ position: sticky;
+ top: calc(var(--header-height) + 1.5rem);
+ max-height: calc(100vh - var(--header-height) - 3rem);
+ overflow-y: auto;
+}
+
+.results-layout .results-column {
+ min-width: 0;
+}
+```
+
+- [ ] **Step 2: Add mobile breakpoints**
+
+```css
+/* Tablet: hide left sidebar, two columns */
+@media (min-width: 769px) and (max-width: 1024px) {
+ .results-layout {
+ grid-template-columns: 1fr 220px;
+ }
+ .results-layout .left-sidebar {
+ display: none;
+ }
+}
+
+/* Mobile: single column, no sidebars */
+@media (max-width: 768px) {
+ .results-layout {
+ grid-template-columns: 1fr;
+ }
+ .results-layout .left-sidebar,
+ .results-layout .right-sidebar {
+ display: none;
+ }
+}
+```
+
+- [ ] **Step 3: Add preferences page layout CSS**
+
+```css
+/* ============================================================
+ Preferences Page Layout
+ ============================================================ */
+
+.preferences-layout {
+ display: grid;
+ grid-template-columns: 200px 1fr;
+ gap: 2rem;
+ align-items: start;
+ padding: 2rem 0;
+}
+
+.preferences-nav {
+ position: sticky;
+ top: calc(var(--header-height) + 1.5rem);
+}
+
+.preferences-nav-item {
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+ padding: 0.6rem 0.75rem;
+ border-radius: var(--radius-sm);
+ color: var(--text-secondary);
+ text-decoration: none;
+ font-size: 0.9rem;
+ transition: background 0.15s, color 0.15s;
+ cursor: pointer;
+}
+
+.preferences-nav-item:hover {
+ background: var(--bg-tertiary);
+ color: var(--text-primary);
+}
+
+.preferences-nav-item.active {
+ background: var(--accent-soft);
+ color: var(--accent);
+ font-weight: 500;
+}
+
+.preferences-content {
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ padding: 1.5rem;
+}
+
+@media (max-width: 768px) {
+ .preferences-layout {
+ grid-template-columns: 1fr;
+ }
+ .preferences-nav {
+ position: static;
+ display: flex;
+ overflow-x: auto;
+ gap: 0.5rem;
+ padding-bottom: 0.5rem;
+ }
+ .preferences-nav-item {
+ white-space: nowrap;
+ }
+}
+```
+
+- [ ] **Step 4: Add category tiles CSS**
+
+```css
+/* ============================================================
+ Category Tiles
+ ============================================================ */
+
+.category-tiles {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
+ gap: 1rem;
+ margin-top: 2rem;
+}
+
+.category-tile {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 1rem 0.5rem;
+ border-radius: var(--radius-md);
+ text-decoration: none;
+ color: var(--text-secondary);
+ font-size: 0.85rem;
+ transition: background 0.15s, color 0.15s, transform 0.15s, box-shadow 0.15s;
+}
+
+.category-tile:hover {
+ background: var(--bg-tertiary);
+ color: var(--text-primary);
+ transform: translateY(-2px);
+ box-shadow: var(--shadow-sm);
+}
+
+.category-tile-icon {
+ font-size: 1.5rem;
+ line-height: 1;
+}
+
+.category-tile.disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ pointer-events: none;
+}
+
+@media (max-width: 768px) {
+ .category-tiles {
+ grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
+ gap: 0.75rem;
+ }
+ .category-tile {
+ padding: 0.75rem 0.25rem;
+ font-size: 0.75rem;
+ }
+ .category-tile-icon {
+ font-size: 1.25rem;
+ }
+}
+```
+
+- [ ] **Step 5: Add left sidebar navigation styles**
+
+```css
+/* ============================================================
+ Left Sidebar (Results Page)
+ ============================================================ */
+
+.left-sidebar {
+ padding: 0;
+}
+
+.sidebar-nav {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.sidebar-nav-title {
+ font-size: 0.7rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--text-muted);
+ padding: 0.5rem 0.75rem;
+ margin-top: 0.5rem;
+}
+
+.sidebar-nav-item {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem 0.75rem;
+ border-radius: var(--radius-sm);
+ color: var(--text-secondary);
+ text-decoration: none;
+ font-size: 0.875rem;
+ transition: background 0.15s, color 0.15s;
+}
+
+.sidebar-nav-item:hover {
+ background: var(--bg-tertiary);
+ color: var(--text-primary);
+}
+
+.sidebar-nav-item.active {
+ background: var(--accent-soft);
+ color: var(--accent);
+ font-weight: 500;
+}
+
+.sidebar-nav-item-icon {
+ font-size: 1rem;
+ width: 20px;
+ text-align: center;
+}
+
+.sidebar-filters {
+ margin-top: 1rem;
+ padding-top: 1rem;
+ border-top: 1px solid var(--border);
+}
+
+.sidebar-filter-group {
+ margin-bottom: 0.75rem;
+}
+
+.sidebar-filter-label {
+ font-size: 0.75rem;
+ font-weight: 500;
+ color: var(--text-muted);
+ padding: 0 0.75rem;
+ margin-bottom: 0.25rem;
+}
+
+.sidebar-filter-option {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.35rem 0.75rem;
+ font-size: 0.8rem;
+ color: var(--text-secondary);
+ cursor: pointer;
+ border-radius: var(--radius-sm);
+ transition: background 0.15s;
+}
+
+.sidebar-filter-option:hover {
+ background: var(--bg-tertiary);
+}
+
+.sidebar-filter-option input[type="radio"] {
+ accent-color: var(--accent);
+}
+
+/* Mobile filter chips */
+.mobile-filter-chips {
+ display: none;
+ overflow-x: auto;
+ gap: 0.5rem;
+ padding: 0.75rem 0;
+ -webkit-overflow-scrolling: touch;
+}
+
+.mobile-filter-chips::-webkit-scrollbar {
+ display: none;
+}
+
+.mobile-filter-chip {
+ display: inline-flex;
+ align-items: center;
+ padding: 0.4rem 0.75rem;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-full);
+ font-size: 0.8rem;
+ color: var(--text-secondary);
+ white-space: nowrap;
+ text-decoration: none;
+ transition: background 0.15s, border-color 0.15s;
+}
+
+.mobile-filter-chip:hover,
+.mobile-filter-chip.active {
+ background: var(--accent-soft);
+ border-color: var(--accent);
+ color: var(--accent);
+}
+
+@media (max-width: 768px) {
+ .mobile-filter-chips {
+ display: flex;
+ }
+}
+```
+
+- [ ] **Step 6: Verify Go compilation**
+
+Run: `go build ./...`
+Expected: No errors
+
+Note: CSS is embedded as static files and not processed by the Go compiler. CSS changes must be tested manually in a browser.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add internal/views/static/css/kafka.css
+git commit -m "feat(frontend): add CSS layout framework for three-column results and preferences page"
+```
+
+---
+
+## PHASE 2: Results Page Three-Column Layout
+
+### Task 2: Restructure Results Template
+
+**Files:**
+- Modify: `internal/views/templates/results.html`
+- Modify: `internal/views/views.go`
+
+- [ ] **Step 1: Read current results.html to understand exact content**
+
+Current structure has `.results-layout` grid with `.search-compact` spanning full width, `.results-column`, and `.sidebar`. Need to add left sidebar and restructure grid.
+
+- [ ] **Step 2: Replace results.html content**
+
+Replace the entire file content:
+
+```html
+{{define "title"}}{{if .Query}}{{.Query}} — {{end}}{{end}}
+{{define "content"}}
+
+
+
+
+
+
+
+
+
+
+
+
All
+ {{range .Categories}}
+
{{.}}
+ {{end}}
+
+
+
+ {{template "results_inner" .}}
+
+
+
+
+
+{{end}}
+```
+
+- [ ] **Step 3: Add FilterOption struct and update PageData struct**
+
+Add `FilterOption` struct at package level in `views.go` (near `PageNumber` struct):
+
+```go
+// FilterOption represents a filter radio option for the sidebar.
+type FilterOption struct {
+ Label string
+ Value string
+}
+```
+
+Then update `PageData` struct to include new fields:
+
+```go
+type PageData struct {
+ // ... existing fields (SourceURL, Query, Pageno, etc.) ...
+
+ // New fields for three-column layout
+ Categories []string
+ CategoryIcons map[string]string
+ DisabledCategories []string
+ ActiveCategory string
+ TimeFilters []FilterOption
+ TypeFilters []FilterOption
+ ActiveTime string
+ ActiveType string
+}
+```
+
+- [ ] **Step 4: Update FromResponse signature and body**
+
+Update `FromResponse` signature to accept filter params and set defaults:
+
+```go
+func FromResponse(resp contracts.SearchResponse, query string, pageno int, activeCategory, activeTime, activeType string) PageData {
+ // Set defaults
+ if activeCategory == "" {
+ activeCategory = "all"
+ }
+
+ pd := PageData{
+ // ... existing initialization (NumberOfResults, Results, etc.) ...
+
+ // New: categories with icons
+ Categories: []string{"all", "news", "images", "videos", "maps"},
+ DisabledCategories: []string{"shopping", "music", "weather"},
+ CategoryIcons: map[string]string{
+ "all": "🌐",
+ "news": "📰",
+ "images": "🖼️",
+ "videos": "🎬",
+ "maps": "🗺️",
+ "shopping": "🛒",
+ "music": "🎵",
+ "weather": "🌤️",
+ },
+ ActiveCategory: activeCategory,
+
+ // Time filters
+ TimeFilters: []FilterOption{
+ {Label: "Any time", Value: ""},
+ {Label: "Past hour", Value: "h"},
+ {Label: "Past 24 hours", Value: "d"},
+ {Label: "Past week", Value: "w"},
+ {Label: "Past month", Value: "m"},
+ {Label: "Past year", Value: "y"},
+ },
+ ActiveTime: activeTime,
+
+ // Type filters
+ TypeFilters: []FilterOption{
+ {Label: "All results", Value: ""},
+ {Label: "News", Value: "news"},
+ {Label: "Videos", Value: "video"},
+ {Label: "Images", Value: "image"},
+ },
+ ActiveType: activeType,
+ }
+ // ... rest of function ...
+}
+```
+
+Update the `Search` handler in `handlers.go` to pass filter params:
+
+```go
+pd := views.FromResponse(resp, req.Query, req.Pageno,
+ r.FormValue("category"), r.FormValue("time"), r.FormValue("type"))
+```
+
+- [ ] **Step 5: Update results.html sidebar to show disabled state**
+
+Update the sidebar category loop to conditionally apply `disabled` class:
+
+```html
+{{range .Categories}}
+
+{{end}}
+
+{{range .DisabledCategories}}
+
+{{end}}
+```
+
+- [ ] **Step 6: Test compilation**
+
+Run: `go build ./...`
+Expected: No errors
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add internal/views/views.go internal/views/templates/results.html
+git commit -m "feat(frontend): add three-column results layout with left sidebar navigation"
+```
+
+---
+
+## PHASE 3: Homepage Category Tiles
+
+### Task 3: Add Category Tiles to Homepage
+
+**Files:**
+- Modify: `internal/views/templates/index.html`
+
+- [ ] **Step 1: Read current index.html**
+
+- [ ] **Step 2: Replace index.html with tiles**
+
+```html
+{{define "title"}}{{end}}
+{{define "content"}}
+
+
+
Search the web privately, without tracking or censorship.
+
+
+
+
+
+
+{{end}}
+```
+
+- [ ] **Step 3: Test compilation**
+
+Run: `go build ./...`
+Expected: No errors
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add internal/views/templates/index.html
+git commit -m "feat(frontend): add category tiles to homepage"
+```
+
+---
+
+## PHASE 4: Preferences Page
+
+### Task 4: Create Preferences Template
+
+**Files:**
+- Create: `internal/views/templates/preferences.html`
+
+- [ ] **Step 1: Create preferences.html**
+
+```html
+{{define "title"}}Preferences{{end}}
+{{define "content"}}
+
+
+
+
+
+
+
+
+ Search
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Privacy
+
+
+
+
Block trackers and scripts that follow you across the web.
+
+
+
+
+
+
+
Ask websites not to track you.
+
+
+
+
+
+
+
+ Tabs
+
+
+
+
Choose what happens when you open a new tab.
+
+
+
+
+
+
+
+ Appearance
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Content
+
+
+
+
Hide explicit content from search results (SafeSearch).
+
+
+
+
+
+
+
Automatically play video content when visible.
+
+
+
+
+
+
+
+ Languages
+
+
+
+
+
+
+
+
+
+
+
+
+ Regional
+
+
+
+
+
+
+
+
+
+
+
+{{end}}
+```
+
+- [ ] **Step 2: Add preferences section CSS styles**
+
+Append to `kafka.css`:
+
+```css
+/* ============================================================
+ Preferences Page Styles
+ ============================================================ */
+
+.pref-section {
+ margin-bottom: 2rem;
+}
+
+.pref-section:last-child {
+ margin-bottom: 0;
+}
+
+.pref-section-title {
+ font-size: 1rem;
+ font-weight: 600;
+ color: var(--text-primary);
+ margin-bottom: 1rem;
+ padding-bottom: 0.5rem;
+ border-bottom: 1px solid var(--border);
+}
+
+.pref-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ padding: 0.75rem 0;
+ border-bottom: 1px solid var(--border);
+}
+
+.pref-row:last-child {
+ border-bottom: none;
+}
+
+.pref-row label {
+ font-size: 0.9rem;
+ color: var(--text-primary);
+}
+
+.pref-row-info {
+ flex: 1;
+}
+
+.pref-row-info label {
+ font-weight: 500;
+}
+
+.pref-desc {
+ font-size: 0.8rem;
+ color: var(--text-muted);
+ margin-top: 0.25rem;
+}
+
+.pref-row select {
+ padding: 0.5rem 0.75rem;
+ font-size: 0.85rem;
+ font-family: inherit;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ background: var(--bg);
+ color: var(--text-primary);
+ cursor: pointer;
+ min-width: 150px;
+}
+
+.pref-row select:focus {
+ outline: none;
+ border-color: var(--accent);
+}
+
+.pref-row input[type="checkbox"] {
+ width: 18px;
+ height: 18px;
+ accent-color: var(--accent);
+ cursor: pointer;
+ flex-shrink: 0;
+}
+
+.pref-row input[type="checkbox"]:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+```
+
+- [ ] **Step 3: Register preferences template in views.go**
+
+Add `tmplPreferences` variable and initialize it in `init()`. Also add `RenderPreferences` function:
+
+```go
+// In views.go, add to var block:
+var (
+ tmplFull *template.Template
+ tmplIndex *template.Template
+ tmplFragment *template.Template
+ tmplPreferences *template.Template
+)
+
+// In init(), after existing template parsing, add:
+tmplPreferences = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS,
+ "base.html", "preferences.html",
+))
+
+// Add RenderPreferences function:
+func RenderPreferences(w http.ResponseWriter, sourceURL string) error {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ return tmplPreferences.ExecuteTemplate(w, "base", PageData{ShowHeader: true, SourceURL: sourceURL})
+}
+```
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add internal/views/templates/preferences.html internal/views/static/css/kafka.css internal/views/views.go
+git commit -m "feat(frontend): add preferences page template and styles"
+```
+
+---
+
+### Task 5: Add Preferences Route
+
+**Files:**
+- Modify: `internal/httpapi/handlers.go`
+- Modify: `cmd/kafka/main.go`
+
+- [ ] **Step 1: Add GET and POST handlers for /preferences**
+
+Add to `handlers.go`:
+
+```go
+// Preferences renders the preferences page.
+func (h *Handler) Preferences(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/preferences" {
+ http.NotFound(w, r)
+ return
+ }
+ if err := views.RenderPreferences(w, h.sourceURL); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ }
+}
+
+// PreferencesPOST handles form submission from the preferences page.
+// NOTE: This is a no-op. All preferences are stored in localStorage on the client
+// via JavaScript. This handler exists only for form submission completeness (e.g.,
+// if a form POSTs without JS). The JavaScript in settings.js handles all saves.
+func (h *Handler) PreferencesPOST(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/preferences" {
+ http.NotFound(w, r)
+ return
+ }
+ http.Redirect(w, r, "/preferences", http.StatusFound)
+}
+```
+
+- [ ] **Step 2: Register the route in main**
+
+Find where routes are registered (likely in `cmd/kafka/main.go`) and add:
+
+```go
+mux.HandleFunc("GET /preferences", handler.Preferences)
+mux.HandleFunc("POST /preferences", handler.PreferencesPOST)
+```
+
+- [ ] **Step 3: Test compilation**
+
+Run: `go build ./...`
+Expected: No errors
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add internal/httpapi/handlers.go cmd/kafka/main.go
+git commit -m "feat: add GET and POST /preferences route"
+```
+
+---
+
+### Task 6: Update Settings JavaScript
+
+**Files:**
+- Modify: `internal/views/static/js/settings.js`
+
+- [ ] **Step 1: Reduce popover to theme + engines only**
+
+Update the `renderPanel` function to remove SafeSearch and Format options. Keep only theme buttons and engine toggles.
+
+- [ ] **Step 2: Add preferences page navigation JavaScript**
+
+Add to end of `settings.js`:
+
+```javascript
+// Preferences page navigation
+function initPreferences() {
+ var nav = document.getElementById('preferences-nav');
+ if (!nav) return;
+
+ var sections = document.querySelectorAll('.pref-section');
+ var navItems = nav.querySelectorAll('.preferences-nav-item');
+
+ function showSection(id) {
+ sections.forEach(function(sec) {
+ sec.style.display = sec.id === 'section-' + id ? 'block' : 'none';
+ });
+ navItems.forEach(function(item) {
+ item.classList.toggle('active', item.getAttribute('data-section') === id);
+ });
+ }
+
+ navItems.forEach(function(item) {
+ item.addEventListener('click', function() {
+ showSection(item.getAttribute('data-section'));
+ });
+ });
+
+ // Load saved preferences
+ var prefs = loadPrefs();
+ var themeEl = document.getElementById('pref-theme');
+ if (themeEl) themeEl.value = prefs.theme || 'system';
+
+ var ssEl = document.getElementById('pref-safesearch');
+ if (ssEl) ssEl.value = prefs.safeSearch || 'moderate';
+
+ var fmtEl = document.getElementById('pref-format');
+ if (fmtEl) fmtEl.value = prefs.format || 'html';
+
+ // Save handlers
+ if (themeEl) {
+ themeEl.addEventListener('change', function() {
+ prefs.theme = themeEl.value;
+ savePrefs(prefs);
+ applyTheme(prefs.theme);
+ });
+ }
+
+ if (ssEl) {
+ ssEl.addEventListener('change', function() {
+ prefs.safeSearch = ssEl.value;
+ savePrefs(prefs);
+ });
+ }
+
+ if (fmtEl) {
+ fmtEl.addEventListener('change', function() {
+ prefs.format = fmtEl.value;
+ savePrefs(prefs);
+ });
+ }
+
+ // Show first section by default
+ showSection('search');
+}
+
+document.addEventListener('DOMContentLoaded', initPreferences);
+```
+
+- [ ] **Step 3: Test with browser**
+
+Manual verification needed — cannot test browser JS with `go build`
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add internal/views/static/js/settings.js
+git commit -m "feat(frontend): reduce popover to theme+engines, add preferences page JS"
+```
+
+---
+
+## PHASE 5: Polish and Mobile Responsiveness
+
+### Task 7: Mobile Filter Chips Integration
+
+**Files:**
+- Modify: `internal/views/templates/results.html`
+
+- [ ] **Step 1: Ensure mobile filter chips have working category links**
+
+The current results.html has mobile filter chips with category links. These should preserve existing query params for pagination/HTMX navigation.
+
+- [ ] **Step 2: Add filter form submission via HTMX**
+
+Update the filter radio buttons to submit via HTMX when changed.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add internal/views/templates/results.html
+git commit -m "fix(frontend): add HTMX filter submission"
+```
+
+---
+
+### Task 8: Final Mobile Responsiveness Audit
+
+**Files:**
+- Review: `internal/views/static/css/kafka.css`
+
+- [ ] **Step 1: Test all breakpoints manually**
+
+- [ ] **Step 2: Fix any layout issues found**
+
+- [ ] **Step 3: Commit any fixes**
+
+```bash
+git add internal/views/static/css/kafka.css
+git commit -m "fix(frontend): improve mobile responsiveness"
+```
+
+---
+
+## Summary
+
+| Phase | Task | Files |
+|-------|------|-------|
+| 1 | CSS Layout Framework | `kafka.css` |
+| 2 | Results Three-Column | `results.html`, `views.go` |
+| 3 | Homepage Tiles | `index.html` |
+| 4 | Preferences Page | `preferences.html` (new), `handlers.go`, `settings.js` |
+| 5 | Polish | Various |
+
+**Total: 8 tasks across 5 phases**
+
+Run `go test ./...` after each phase to verify nothing is broken.
diff --git a/docs/superpowers/plans/2026-03-22-frontend-replacement.md b/docs/superpowers/plans/2026-03-22-frontend-replacement.md
new file mode 100644
index 0000000..1cb475e
--- /dev/null
+++ b/docs/superpowers/plans/2026-03-22-frontend-replacement.md
@@ -0,0 +1,358 @@
+# Frontend Replacement Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Replace the Go template-based frontend with the search-zen-50 React SPA, embedded in the Go binary as a single deployment.
+
+**Architecture:** Build React app → embed in Go binary via `//go:embed` → serve via Go HTTP server with SPA fallback routing. React calls `/search?format=json` and `/autocompleter?q=` APIs.
+
+**Tech Stack:** Go (embed), React 18, Vite, TailwindCSS, React Router, @tanstack/react-query
+
+---
+
+## File Map
+
+| File | Action |
+|------|--------|
+| `cmd/kafka/main.go` | Modify - replace template handlers with SPA handler |
+| `internal/spa/spa.go` | Create - embed React build, serve static files, SPA fallback |
+| `internal/spa/dist/` | Build output - React build artifacts (gitignored) |
+| `src/hooks/use-search.ts` | Modify - replace mock with real API calls |
+| `src/lib/mock-data.ts` | Keep types, remove MOCK_RESPONSE usage |
+
+---
+
+## Task 1: Build React App
+
+**Files:**
+- Build: `/tmp/search-zen-50/dist/` (output directory)
+
+- [ ] **Step 1: Install dependencies and build**
+
+```bash
+cd /tmp/search-zen-50 && bun install && bun run build
+```
+
+Expected: `dist/` directory created with `index.html`, `assets/` folder containing JS/CSS bundles
+
+- [ ] **Step 2: Verify dist contents**
+
+```bash
+ls /tmp/search-zen-50/dist/ && ls /tmp/search-zen-50/dist/assets/ | head -10
+```
+
+Expected: `index.html` exists, `assets/` contains `.js` and `.css` files
+
+---
+
+## Task 2: Create SPA Go Package
+
+**Files:**
+- Create: `internal/spa/spa.go`
+
+```go
+package spa
+
+import (
+ "embed"
+ "io/fs"
+ "net/http"
+ "path"
+)
+
+//go:embed all:dist
+var distFS embed.FS
+
+// DistFS returns the embedded dist directory as an fs.FS.
+func DistFS() (fs.FS, error) {
+ return fs.Sub(distFS, "dist")
+}
+
+// NewHandler returns an HTTP handler that:
+// - Serves static files from the embedded dist/ directory
+// - Falls back to index.html for SPA routing (any non-API path)
+func NewHandler() http.Handler {
+ dist, err := DistFS()
+ if err != nil {
+ panic("spa: embedded dist not found: " + err.Error())
+ }
+ return &spaHandler{dist: dist}
+}
+
+type spaHandler struct {
+ dist fs.FS
+}
+
+func (h *spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ // API paths are handled by Go API handlers - this should never be reached
+ // since Go mux dispatches to specific handlers first. But if reached,
+ // pass through to FileServer which will return 404 for unknown paths.
+
+ // Try to serve the requested file first
+ filePath := path.Clean(r.URL.Path)
+ f, err := h.dist.Open(filePath)
+ if err == nil {
+ f.Close()
+ // File exists - serve it via FileServer
+ http.FileServer(http.FS(h.dist)).ServeHTTP(w, r)
+ return
+ }
+
+ // Fallback to index.html for SPA routing
+ indexFile, err := h.dist.Open("index.html")
+ if err != nil {
+ http.Error(w, "index.html not found in embedded files", http.StatusInternalServerError)
+ return
+ }
+ indexFile.Close()
+ http.FileServer(http.FS(h.dist)).ServeHTTP(w, r)
+}
+
+```
+
+---
+
+## Task 3: Wire SPA Handler in main.go
+
+**Files:**
+- Modify: `cmd/kafka/main.go`
+
+- [ ] **Step 1: Replace handlers with SPA**
+
+In `main.go`, find and replace the `mux.HandleFunc` section (lines 82-88) and the static file serving section (lines 90-96).
+
+Old code (lines 82-96):
+```go
+mux := http.NewServeMux()
+mux.HandleFunc("/", h.Index)
+mux.HandleFunc("/healthz", h.Healthz)
+mux.HandleFunc("/search", h.Search)
+mux.HandleFunc("/autocompleter", h.Autocompleter)
+mux.HandleFunc("/preferences", h.Preferences)
+mux.HandleFunc("/opensearch.xml", h.OpenSearch(cfg.Server.BaseURL))
+
+// Serve embedded static files (CSS, JS, images).
+staticFS, err := views.StaticFS()
+if err != nil {
+ log.Fatalf("failed to load static files: %v", err)
+}
+var subFS fs.FS = staticFS
+mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(subFS))))
+```
+
+New code:
+```go
+mux := http.NewServeMux()
+
+// API routes - handled by Go
+mux.HandleFunc("/healthz", h.Healthz)
+mux.HandleFunc("/search", h.Search)
+mux.HandleFunc("/autocompleter", h.Autocompleter)
+mux.HandleFunc("/opensearch.xml", h.OpenSearch(cfg.Server.BaseURL))
+
+// SPA handler - serves React app for all other routes
+spaHandler := spa.NewHandler()
+mux.Handle("/", spaHandler)
+```
+
+- [ ] **Step 2: Add spa import**
+
+Add to imports (after `"github.com/metamorphosis-dev/kafka/internal/search"`):
+```go
+"github.com/metamorphosis-dev/kafka/internal/spa"
+```
+
+- [ ] **Step 3: Remove unused views import if needed**
+
+If `views` is only used for `StaticFS()`, remove the import. The template rendering functions (`RenderIndex`, etc.) won't be needed anymore.
+
+- [ ] **Step 4: Verify build**
+
+```bash
+cd /home/ashie/git/kafka && go build ./cmd/kafka/
+```
+
+Expected: Builds successfully (may fail on embed if dist not found - continue to next task)
+
+---
+
+## Task 4: Wire React to Real API
+
+**Files:**
+- Modify: `src/hooks/use-search.ts` in `/tmp/search-zen-50/`
+
+- [ ] **Step 1: Replace mock search with real API call**
+
+Replace the `search` function in `use-search.ts`:
+
+Old code (lines 23-36):
+```typescript
+const search = useCallback(async (query: string) => {
+ if (!query.trim()) return;
+
+ setState((prev) => ({ ...prev, query, isLoading: true, error: null, hasSearched: true }));
+
+ // Simulate network delay
+ await new Promise((r) => setTimeout(r, 800));
+
+ setState((prev) => ({
+ ...prev,
+ isLoading: false,
+ results: { ...MOCK_RESPONSE, query },
+ }));
+}, []);
+```
+
+New code:
+```typescript
+const search = useCallback(async (query: string) => {
+ if (!query.trim()) return;
+
+ setState((prev) => ({ ...prev, query, isLoading: true, error: null, hasSearched: true }));
+
+ try {
+ const response = await fetch(`/search?format=json&q=${encodeURIComponent(query)}`);
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}`);
+ }
+ const data = await response.json();
+ setState((prev) => ({
+ ...prev,
+ isLoading: false,
+ results: data,
+ }));
+ } catch (err) {
+ setState((prev) => ({
+ ...prev,
+ isLoading: false,
+ error: err instanceof Error ? err.message : "Search failed",
+ }));
+ }
+}, []);
+```
+
+- [ ] **Step 2: Remove mock data import**
+
+Remove the mock import line (should be near line 2):
+```typescript
+import { MOCK_RESPONSE, type SearXNGResponse, type Category } from "@/lib/mock-data";
+```
+
+Replace with:
+```typescript
+import type { SearXNGResponse, Category } from "@/lib/mock-data";
+```
+
+- [ ] **Step 3: Keep the CATEGORIES export**
+
+Ensure `mock-data.ts` still exports `CATEGORIES` and `Category` type. The file should look like:
+
+```typescript
+// Keep these exports - used by CategoryTabs and preferences
+export const CATEGORIES = ["general", "it", "images", "news"] as const;
+export type Category = typeof CATEGORIES[number];
+
+// Keep interfaces
+export interface SearchResult {
+ url: string;
+ title: string;
+ content: string;
+ engine: string;
+ parsed_url: [string, string, string, string, string];
+ engines: string[];
+ positions: number[];
+ score: number;
+ category: string;
+ pretty_url: string;
+ img_src?: string;
+ thumbnail?: string;
+ publishedDate?: string;
+}
+
+export interface SearXNGResponse {
+ query: string;
+ number_of_results: number;
+ results: SearchResult[];
+ answers: string[];
+ corrections: string[];
+ infoboxes: any[];
+ suggestions: string[];
+ unresponsive_engines: string[];
+}
+```
+
+---
+
+## Task 5: Rebuild React and Verify
+
+**Files:**
+- Build: `/tmp/search-zen-50/dist/`
+
+- [ ] **Step 1: Rebuild with changes**
+
+```bash
+cd /tmp/search-zen-50 && bun run build
+```
+
+- [ ] **Step 2: Copy dist to kafka**
+
+```bash
+rm -rf /home/ashie/git/kafka/internal/spa/dist
+cp -r /tmp/search-zen-50/dist /home/ashie/git/kafka/internal/spa/dist
+```
+
+- [ ] **Step 3: Verify Go build**
+
+```bash
+cd /home/ashie/git/kafka && go build ./cmd/kafka/ && echo "Build successful"
+```
+
+Expected: "Build successful"
+
+---
+
+## Task 6: Test the Integration
+
+- [ ] **Step 1: Start the server**
+
+```bash
+cd /home/ashie/git/kafka && ./kafka -config config.toml &
+sleep 2
+```
+
+- [ ] **Step 2: Test homepage**
+
+```bash
+curl -s http://localhost:8080/ | head -20
+```
+
+Expected: HTML with `` from React app
+
+- [ ] **Step 3: Test API**
+
+```bash
+curl -s "http://localhost:8080/search?format=json&q=test" | head -50
+```
+
+Expected: JSON search response
+
+- [ ] **Step 4: Clean up**
+
+```bash
+pkill -f "./kafka" 2>/dev/null; echo "Done"
+```
+
+---
+
+## Dependencies
+
+- Node.js/Bun for building React app
+- Go 1.24+ for embed functionality
+- No new Go dependencies
+
+## Notes
+
+- The `internal/spa/dist/` folder should be gitignored (build artifact)
+- The `internal/spa/dist/` copy is needed for the embed to work at compile time
+- Preferences page is entirely client-side (localStorage) - no backend needed
+- Autocomplete can be added later by modifying `SearchInput.tsx` to call `/autocompleter`
diff --git a/docs/superpowers/specs/2026-03-22-brave-search-frontend-redesign.md b/docs/superpowers/specs/2026-03-22-brave-search-frontend-redesign.md
new file mode 100644
index 0000000..d30ab99
--- /dev/null
+++ b/docs/superpowers/specs/2026-03-22-brave-search-frontend-redesign.md
@@ -0,0 +1,328 @@
+# Brave Search Frontend Redesign — Design Specification
+
+## Overview
+
+Redesign the kafka frontend to match Brave Search's clean, functional aesthetic with emphasis on layout changes: three-column results page, category tiles on homepage, and a hybrid preferences system with full-page `/preferences` route.
+
+## Design Principles
+
+1. **Brave-like layout** — Three-column results, full-page preferences, homepage tiles
+2. **Preserve existing design tokens** — Keep current CSS variables (colors, spacing, radii)
+3. **CSS Grid for layout** — Three-column grid for results, flexible layouts elsewhere
+4. **Hybrid preferences** — Quick popover for common settings (theme + engines), full `/preferences` page for all options
+5. **Minimal HTML changes** — Restructure templates where needed for layout, reuse existing partials
+6. **localStorage-only preferences** — No server-side persistence; all preferences stored in browser localStorage
+
+---
+
+## 1. Homepage Redesign
+
+### Current State
+- Centered hero with logo, tagline, and search box
+- No visual categorization of search types
+
+### New Layout
+```
+┌─────────────────────────────────────────────────────────────┐
+│ [Logo] [⚙ Preferences]│
+├─────────────────────────────────────────────────────────────┤
+│ │
+│ [🔍 Search Box] │
+│ │
+│ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │
+│ │ News │ │ Images │ │ Videos │ │ Maps │ ... │
+│ └────────┘ └────────┘ └────────┘ └────────┘ │
+│ │
+│ "Search the web privately..." │
+│ │
+└─────────────────────────────────────────────────────────────┘
+```
+
+### Implementation
+- **File:** `internal/views/templates/index.html`
+- **Structure:** Search hero + category tiles grid
+- **Tiles:** Static links to `/search?q=...` with category parameter (e.g., `&category=images`)
+- **Styling:** Grid of icon+label cards below search box, subtle hover effects
+
+### Category Tiles
+| Category | Icon | Notes |
+|----------|------|-------|
+| All | 🌐 | Default, no category param |
+| News | 📰 | |
+| Images | 🖼️ | |
+| Videos | 🎬 | |
+| Maps | 🗺️ | |
+| Shopping | 🛒 | Future: connect to shopping engine |
+| Music | 🎵 | Future: connect to music engine |
+| Weather | 🌤️ | Future: connect to weather API |
+| Sports | ⚽ | Future |
+| Cryptocurrency | ₿ | Future |
+
+Categories marked "Future" are included in the UI but may not have backend support yet. Category tiles that lack backend support display grayed out with a "Coming soon" tooltip.
+
+---
+
+## 2. Results Page — Three-Column Layout
+
+### Current State
+- Two columns: compact search bar spanning top, main results + right sidebar
+
+### New Layout
+```
+┌─────────────────────────────────────────────────────────────┐
+│ [Logo] [⚙ Preferences]│
+├─────────────────────────────────────────────────────────────┤
+│ ┌─────────┐ ┌────────────────────────────┐ ┌──────────┐│
+│ │ Nav │ │ 🔍 [ Search Input ] │ │ Related ││
+│ │ ─────── │ └────────────────────────────┘ │ Searches ││
+│ │ All │ About 1,240 results (0.42s) │ ││
+│ │ Images │ ┌──────────────────────────┐ │ │ ─────── ││
+│ │ Videos │ │ Result Card │ │ │ Suggestions│
+│ │ News │ │ Title, URL, Description │ │ │ ││
+│ │ Maps │ └──────────────────────────┘ │ └──────────┘│
+│ │ Shopping│ ┌──────────────────────────┐ │ │
+│ │ ... │ │ Result Card │ │ │
+│ │ │ │ ... │ │ │
+│ │ ─────── │ └──────────────────────────┘ │ │
+│ │ Filters │ ... │ │
+│ │ Time │ │ │
+│ │ Type │ [Pagination] │ │
+│ └─────────┘ │ │
+└─────────────────────────────────────────────────────────────┘
+```
+
+### Implementation
+- **Files:** `internal/views/templates/results.html`, `internal/views/templates/base.html`
+- **Left Sidebar (desktop, sticky):**
+ - Category navigation links (All, Images, Videos, News, Maps, Shopping, Music, Weather)
+ - Filters section (Time range, Result type) — collapsible
+ - Hidden on mobile (< 768px)
+
+- **Center Column:**
+ - Compact search bar
+ - Results count meta: "About {n} results ({time}s)"
+ - Result cards (unchanged markup)
+ - Pagination
+
+- **Right Sidebar:**
+ - Related searches (existing suggestions)
+ - Additional panels as needed
+
+### Filters
+**Time Range Options:**
+| Label | Query Param |
+|-------|-------------|
+| Any time | (none) |
+| Past hour | `&time=h` |
+| Past 24 hours | `&time=d` |
+| Past week | `&time=w` |
+| Past month | `&time=m` |
+| Past year | `&time=y` |
+
+**Result Type Options:**
+| Label | Query Param |
+|-------|-------------|
+| All results | (none) |
+| News | `&type=news` |
+| Videos | `&type=video` |
+| Images | `&type=image` |
+
+Filter state persists in URL query params and is preserved across HTMX navigation via `hx-include`.
+
+### Mobile Behavior
+| Breakpoint | Layout |
+|------------|--------|
+| < 768px | Single column, no left sidebar |
+| 768px - 1024px | Two columns (center + right sidebar), no left nav |
+| > 1024px | Full three columns |
+
+On mobile (< 768px):
+- Category filters accessible via a horizontal scrollable chip row above results
+- Both sidebars hidden
+- Search bar full-width
+
+---
+
+## 3. Preferences Page — Full-Page Hybrid
+
+### Current State
+- Popover triggered by gear icon in header
+- JavaScript-rendered from localStorage
+- Sections: Appearance, Engines, Search Defaults
+
+### New Layout
+```
+┌─────────────────────────────────────────────────────────────┐
+│ [Logo] [⚙ Preferences]│
+├─────────────────────────────────────────────────────────────┤
+│ ┌────────────────┐ ┌─────────────────────────────────────┐│
+│ │ Nav │ │ Content ││
+│ │ ───────────── │ │ ││
+│ │ Search │ │ [Section Content] ││
+│ │ Privacy │ │ ││
+│ │ Tabs │ │ ││
+│ │ Appearance │ │ ││
+│ │ Sidebar │ │ ││
+│ │ Content │ │ ││
+│ │ Languages │ │ ││
+│ │ Regional │ │ ││
+│ └────────────────┘ └─────────────────────────────────────┘│
+└─────────────────────────────────────────────────────────────┘
+```
+
+### Sections (Brave-style)
+1. **Search** — Default engine, safe search, language
+2. **Privacy** — Tracking protection toggle (UI only, always on), request DNT header toggle
+3. **Tabs** — New tab behavior (placeholder section)
+4. **Appearance** — Theme (Light/Dark/System), results font size
+5. **Sidebar** — Sidebar visibility toggle
+6. **Content** — Filter explicit results (SafeSearch), auto-play media toggle
+7. **Languages** — UI language (English only for now), search language
+8. **Regional** — Region/Country, timezone (placeholder)
+
+### Implementation
+- **Route:** Add `GET /preferences` and `POST /preferences` to `internal/httpapi/`
+- **Template:** `internal/views/templates/preferences.html`
+- **Storage:** localStorage-only. GET handler renders page shell, JavaScript populates form values from localStorage. POST handler receives form data, writes to localStorage, re-renders page.
+- **Quick Settings Popover:** Keep existing popover for **theme toggle and engine toggles only** (lightweight, localStorage). SafeSearch and Format settings move exclusively to full preferences page.
+- **Styling:** Match existing design tokens, section headers, form controls
+
+### Preferences Nav (Mobile)
+- Horizontal scrollable nav on mobile (< 768px)
+- Active section highlighted
+
+---
+
+## 4. Component Changes
+
+### Header
+- Logo + site name (unchanged)
+- Preferences button (unchanged)
+
+### Search Box
+- Homepage: Larger, prominent, centered
+- Results page: Compact, full-width within center column
+
+### Result Cards
+- Keep existing structure
+- Consider subtle styling improvements (spacing, typography)
+
+### Category Tiles (Homepage)
+- Icon + label per category
+- Hover: slight scale + shadow
+
+### Left Sidebar (Results Page)
+- Sticky positioning (`position: sticky; top: calc(var(--header-height) + 1rem)`)
+- Category links with active state indicator
+- Collapsible filter sections
+
+### Preferences Nav
+- Vertical nav with section icons
+- Active state indicator
+- Mobile: horizontal scroll
+
+---
+
+## 5. CSS Architecture
+
+### Existing (Retain)
+- CSS custom properties (design tokens)
+- Component-level styles
+- Dark mode via `[data-theme="dark"]`
+
+### New
+
+**Layout Grid for three-column results:**
+```css
+.results-layout {
+ display: grid;
+ grid-template-columns: 200px 1fr 240px;
+ gap: 2rem;
+ align-items: start;
+}
+```
+
+**Sticky Left Sidebar:**
+```css
+.results-layout .left-sidebar {
+ position: sticky;
+ top: calc(var(--header-height) + 1.5rem);
+ max-height: calc(100vh - var(--header-height) - 3rem);
+ overflow-y: auto;
+}
+```
+
+**Preferences page layout:**
+```css
+.preferences-layout {
+ display: grid;
+ grid-template-columns: 200px 1fr;
+ gap: 2rem;
+}
+```
+
+**Category tiles grid:**
+```css
+.category-tiles {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
+ gap: 1rem;
+}
+```
+
+**Mobile breakpoints:**
+```css
+@media (max-width: 768px) {
+ .results-layout {
+ grid-template-columns: 1fr;
+ }
+ .results-layout .left-sidebar,
+ .results-layout .right-sidebar {
+ display: none;
+ }
+}
+
+@media (min-width: 769px) and (max-width: 1024px) {
+ .results-layout {
+ grid-template-columns: 1fr 220px;
+ }
+ .results-layout .left-sidebar {
+ display: none;
+ }
+}
+```
+
+---
+
+## 6. Files to Modify
+
+| File | Change |
+|------|--------|
+| `internal/views/templates/index.html` | Add category tiles |
+| `internal/views/templates/results.html` | Add left sidebar, restructure for three columns |
+| `internal/views/templates/base.html` | Minimal changes (no structural changes needed) |
+| `internal/views/templates/preferences.html` | **New** — full preferences page |
+| `internal/views/static/css/kafka.css` | Add layout grids, category tiles, sidebar styles, sticky positioning, mobile breakpoints |
+| `internal/views/static/js/settings.js` | Reduce popover to theme + engines; add preferences page JS |
+| `internal/httpapi/httpapi.go` | Add `/preferences` route (GET + POST) |
+| `internal/views/views.go` | Add preferences template rendering |
+
+---
+
+## 7. Priority Order
+
+1. **Phase 1:** CSS layout framework (three-column grid, new variables, breakpoints)
+2. **Phase 2:** Results page three-column layout
+3. **Phase 3:** Homepage category tiles
+4. **Phase 4:** Preferences page (quick popover first, then full page)
+5. **Phase 5:** Polish and mobile responsiveness
+
+---
+
+## Out of Scope
+
+- Backend search logic changes
+- New engine implementations (category tiles for future engines are UI placeholders only)
+- Caching or performance improvements
+- User authentication/account system
+- Server-side preference storage
diff --git a/docs/superpowers/specs/2026-03-22-frontend-replacement-design.md b/docs/superpowers/specs/2026-03-22-frontend-replacement-design.md
new file mode 100644
index 0000000..65c294a
--- /dev/null
+++ b/docs/superpowers/specs/2026-03-22-frontend-replacement-design.md
@@ -0,0 +1,74 @@
+# Frontend Replacement: search-zen-50 Integration
+
+## Status
+Approved
+
+## Overview
+
+Replace the current Go template-based frontend (HTMX + Go templates) with the search-zen-50 React SPA. The React app is built statically and embedded into the Go binary, serving as a single binary deployment.
+
+## Architecture
+
+- **Build**: React/Vite app builds to `dist/` directory
+- **Embed**: Go's `//go:embed` embeds the dist folder into the binary
+- **Serve**: Go HTTP server serves static files and handles API routes
+- **SPA routing**: Non-API routes serve `index.html` for React Router
+
+## Changes
+
+### Go Side
+
+1. **Create `internal/spa/spa.go`**
+ - Embeds the React build (`dist/`) using `//go:embed`
+ - Serves static files (JS, CSS, images)
+ - Handles SPA fallback: serves `index.html` for all non-API routes
+ - Provides `SPAHandler` that wraps API routes
+
+2. **Modify `cmd/kafka/main.go`**
+ - Import the embedded SPA files
+ - Route `/`, `/preferences`, and unknown routes to SPA handler
+ - Keep existing API routes: `/search`, `/autocompleter`, `/healthz`, `/opensearch.xml`
+
+### React Side
+
+1. **Modify `use-search.ts`**
+ - Replace mock data with real API call: `fetch("/search?format=json&q=${encodeURIComponent(query)}")`
+ - Map response to existing `SearXNGResponse` type (already matches)
+
+2. **Add autocomplete** (optional enhancement)
+ - Call `/autocompleter?q=${encodeURIComponent(query)}`
+ - Display suggestions while typing
+
+3. **Keep unchanged**
+ - All UI components
+ - Preferences page (localStorage-based)
+ - Routing (React Router)
+
+## Data Flow
+
+```
+Browser → GET / → Go serves embedded index.html
+Browser → GET /search?format=json&q=... → Go search handler → JSON
+Browser → React renders results via use-search hook
+```
+
+## API Compatibility
+
+The existing kafka API (`/search?format=json`) already matches the expected `SearXNGResponse` interface in the React code:
+- `query: string`
+- `number_of_results: number`
+- `results: SearchResult[]`
+- `suggestions: string[]`
+- `unresponsive_engines: string[][]`
+
+## File Changes
+
+- **New**: `internal/spa/spa.go`
+- **Modified**: `cmd/kafka/main.go` (wire SPA handler)
+- **Modified**: `src/hooks/use-search.ts` (use real API)
+- **Build step**: `npm run build` or `bun run build` in search-zen-50
+
+## Dependencies
+
+- React app uses `@tanstack/react-query` for API calls (already in package.json)
+- No new Go dependencies needed
diff --git a/flake.nix b/flake.nix
index d143495..3552728 100644
--- a/flake.nix
+++ b/flake.nix
@@ -21,13 +21,16 @@
version = "0.1.0";
src = ./.;
- vendorHash = "sha256-NbAa4QM/TI3BTuZs4glx9k3ZjSl2/2LQfKlQ7izR8Ho=";
+ vendorHash = "sha256-8wlKD+33s97oorCJTfHKAgE2Xp1HKXV+bSr6z29KrKM=";
# Run: nix build .#packages.x86_64-linux.default
- # It will fail with the correct hash. Replace it here.
+ # It will fail with the correct hash. Replace vendorHash with it.
# Embed the templates and static files at build time.
ldflags = [ "-s" "-w" ];
+ # Remove stale vendor directory before buildGoModule deletes it.
+ preConfigure = "rm -rf vendor || true";
+
nativeCheckInputs = with pkgs; [ ];
# Tests require network; they run in CI instead.
diff --git a/frontend/.gitignore b/frontend/.gitignore
new file mode 100644
index 0000000..a547bf3
--- /dev/null
+++ b/frontend/.gitignore
@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/frontend/README.md b/frontend/README.md
new file mode 100644
index 0000000..a125fd6
--- /dev/null
+++ b/frontend/README.md
@@ -0,0 +1,3 @@
+# Welcome to your Lovable project
+
+TODO: Document your project here
diff --git a/frontend/components.json b/frontend/components.json
new file mode 100644
index 0000000..62e1011
--- /dev/null
+++ b/frontend/components.json
@@ -0,0 +1,20 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "default",
+ "rsc": false,
+ "tsx": true,
+ "tailwind": {
+ "config": "tailwind.config.ts",
+ "css": "src/index.css",
+ "baseColor": "slate",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ }
+}
diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js
new file mode 100644
index 0000000..40f72cc
--- /dev/null
+++ b/frontend/eslint.config.js
@@ -0,0 +1,26 @@
+import js from "@eslint/js";
+import globals from "globals";
+import reactHooks from "eslint-plugin-react-hooks";
+import reactRefresh from "eslint-plugin-react-refresh";
+import tseslint from "typescript-eslint";
+
+export default tseslint.config(
+ { ignores: ["dist"] },
+ {
+ extends: [js.configs.recommended, ...tseslint.configs.recommended],
+ files: ["**/*.{ts,tsx}"],
+ languageOptions: {
+ ecmaVersion: 2020,
+ globals: globals.browser,
+ },
+ plugins: {
+ "react-hooks": reactHooks,
+ "react-refresh": reactRefresh,
+ },
+ rules: {
+ ...reactHooks.configs.recommended.rules,
+ "react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
+ "@typescript-eslint/no-unused-vars": "off",
+ },
+ },
+);
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..c1ff5ee
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+ kafka — Private Meta-Search
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..e90cada
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,90 @@
+{
+ "name": "vite_react_shadcn_ts",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "build:dev": "vite build --mode development",
+ "lint": "eslint .",
+ "preview": "vite preview",
+ "test": "vitest run",
+ "test:watch": "vitest"
+ },
+ "dependencies": {
+ "@hookform/resolvers": "^3.10.0",
+ "@radix-ui/react-accordion": "^1.2.11",
+ "@radix-ui/react-alert-dialog": "^1.1.14",
+ "@radix-ui/react-aspect-ratio": "^1.1.7",
+ "@radix-ui/react-avatar": "^1.1.10",
+ "@radix-ui/react-checkbox": "^1.3.2",
+ "@radix-ui/react-collapsible": "^1.1.11",
+ "@radix-ui/react-context-menu": "^2.2.15",
+ "@radix-ui/react-dialog": "^1.1.14",
+ "@radix-ui/react-dropdown-menu": "^2.1.15",
+ "@radix-ui/react-hover-card": "^1.1.14",
+ "@radix-ui/react-label": "^2.1.7",
+ "@radix-ui/react-menubar": "^1.1.15",
+ "@radix-ui/react-navigation-menu": "^1.2.13",
+ "@radix-ui/react-popover": "^1.1.14",
+ "@radix-ui/react-progress": "^1.1.7",
+ "@radix-ui/react-radio-group": "^1.3.7",
+ "@radix-ui/react-scroll-area": "^1.2.9",
+ "@radix-ui/react-select": "^2.2.5",
+ "@radix-ui/react-separator": "^1.1.7",
+ "@radix-ui/react-slider": "^1.3.5",
+ "@radix-ui/react-slot": "^1.2.3",
+ "@radix-ui/react-switch": "^1.2.5",
+ "@radix-ui/react-tabs": "^1.1.12",
+ "@radix-ui/react-toast": "^1.2.14",
+ "@radix-ui/react-toggle": "^1.1.9",
+ "@radix-ui/react-toggle-group": "^1.1.10",
+ "@radix-ui/react-tooltip": "^1.2.7",
+ "@tanstack/react-query": "^5.83.0",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "cmdk": "^1.1.1",
+ "date-fns": "^3.6.0",
+ "embla-carousel-react": "^8.6.0",
+ "input-otp": "^1.4.2",
+ "lucide-react": "^0.462.0",
+ "next-themes": "^0.3.0",
+ "react": "^18.3.1",
+ "react-day-picker": "^8.10.1",
+ "react-dom": "^18.3.1",
+ "react-hook-form": "^7.61.1",
+ "react-resizable-panels": "^2.1.9",
+ "react-router-dom": "^6.30.1",
+ "recharts": "^2.15.4",
+ "sonner": "^1.7.4",
+ "tailwind-merge": "^2.6.0",
+ "tailwindcss-animate": "^1.0.7",
+ "vaul": "^0.9.9",
+ "zod": "^3.25.76"
+ },
+ "devDependencies": {
+ "@eslint/js": "^9.32.0",
+ "@playwright/test": "^1.57.0",
+ "@tailwindcss/typography": "^0.5.16",
+ "@testing-library/jest-dom": "^6.6.0",
+ "@testing-library/react": "^16.0.0",
+ "@types/node": "^22.16.5",
+ "@types/react": "^18.3.23",
+ "@types/react-dom": "^18.3.7",
+ "@vitejs/plugin-react-swc": "^3.11.0",
+ "autoprefixer": "^10.4.21",
+ "eslint": "^9.32.0",
+ "eslint-plugin-react-hooks": "^5.2.0",
+ "eslint-plugin-react-refresh": "^0.4.20",
+ "globals": "^15.15.0",
+ "jsdom": "^20.0.3",
+ "lovable-tagger": "^1.1.13",
+ "postcss": "^8.5.6",
+ "tailwindcss": "^3.4.17",
+ "typescript": "^5.8.3",
+ "typescript-eslint": "^8.38.0",
+ "vite": "^5.4.19",
+ "vitest": "^3.2.4"
+ }
+}
diff --git a/frontend/playwright-fixture.ts b/frontend/playwright-fixture.ts
new file mode 100644
index 0000000..7d471c1
--- /dev/null
+++ b/frontend/playwright-fixture.ts
@@ -0,0 +1,3 @@
+// Re-export the base fixture from the package
+// Override or extend test/expect here if needed
+export { test, expect } from "lovable-agent-playwright-config/fixture";
diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts
new file mode 100644
index 0000000..ec19e95
--- /dev/null
+++ b/frontend/playwright.config.ts
@@ -0,0 +1,10 @@
+import { createLovableConfig } from "lovable-agent-playwright-config/config";
+
+export default createLovableConfig({
+ // Add your custom playwright configuration overrides here
+ // Example:
+ // timeout: 60000,
+ // use: {
+ // baseURL: 'http://localhost:3000',
+ // },
+});
diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js
new file mode 100644
index 0000000..2aa7205
--- /dev/null
+++ b/frontend/postcss.config.js
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+};
diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico
new file mode 100644
index 0000000..3c01d69
Binary files /dev/null and b/frontend/public/favicon.ico differ
diff --git a/frontend/public/placeholder.svg b/frontend/public/placeholder.svg
new file mode 100644
index 0000000..ea950de
--- /dev/null
+++ b/frontend/public/placeholder.svg
@@ -0,0 +1,40 @@
+
diff --git a/frontend/public/robots.txt b/frontend/public/robots.txt
new file mode 100644
index 0000000..6018e70
--- /dev/null
+++ b/frontend/public/robots.txt
@@ -0,0 +1,14 @@
+User-agent: Googlebot
+Allow: /
+
+User-agent: Bingbot
+Allow: /
+
+User-agent: Twitterbot
+Allow: /
+
+User-agent: facebookexternalhit
+Allow: /
+
+User-agent: *
+Allow: /
diff --git a/frontend/src/App.css b/frontend/src/App.css
new file mode 100644
index 0000000..b9d355d
--- /dev/null
+++ b/frontend/src/App.css
@@ -0,0 +1,42 @@
+#root {
+ max-width: 1280px;
+ margin: 0 auto;
+ padding: 2rem;
+ text-align: center;
+}
+
+.logo {
+ height: 6em;
+ padding: 1.5em;
+ will-change: filter;
+ transition: filter 300ms;
+}
+.logo:hover {
+ filter: drop-shadow(0 0 2em #646cffaa);
+}
+.logo.react:hover {
+ filter: drop-shadow(0 0 2em #61dafbaa);
+}
+
+@keyframes logo-spin {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ a:nth-of-type(2) .logo {
+ animation: logo-spin infinite 20s linear;
+ }
+}
+
+.card {
+ padding: 2em;
+}
+
+.read-the-docs {
+ color: #888;
+}
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
new file mode 100644
index 0000000..f1ed102
--- /dev/null
+++ b/frontend/src/App.tsx
@@ -0,0 +1,31 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { BrowserRouter, Route, Routes } from "react-router-dom";
+import { Toaster as Sonner } from "@/components/ui/sonner";
+import { Toaster } from "@/components/ui/toaster";
+import { TooltipProvider } from "@/components/ui/tooltip";
+import { PreferencesProvider } from "@/contexts/PreferencesContext";
+import Index from "./pages/Index.tsx";
+import Preferences from "./pages/Preferences.tsx";
+import NotFound from "./pages/NotFound.tsx";
+
+const queryClient = new QueryClient();
+
+const App = () => (
+
+
+
+
+
+
+
+ } />
+ } />
+ } />
+
+
+
+
+
+);
+
+export default App;
diff --git a/frontend/src/components/CategoryTabs.tsx b/frontend/src/components/CategoryTabs.tsx
new file mode 100644
index 0000000..1be1eda
--- /dev/null
+++ b/frontend/src/components/CategoryTabs.tsx
@@ -0,0 +1,39 @@
+import { Globe, Code, Image, Newspaper } from "lucide-react";
+import { CATEGORIES, type Category } from "@/lib/mock-data";
+
+const CATEGORY_META: Record = {
+ general: { label: "General", icon: Globe },
+ it: { label: "IT", icon: Code },
+ images: { label: "Images", icon: Image },
+ news: { label: "News", icon: Newspaper },
+};
+
+interface CategoryTabsProps {
+ active: Category;
+ onChange: (c: Category) => void;
+}
+
+export function CategoryTabs({ active, onChange }: CategoryTabsProps) {
+ return (
+
+ {CATEGORIES.map((cat) => {
+ const { label, icon: Icon } = CATEGORY_META[cat];
+ const isActive = cat === active;
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/frontend/src/components/NavLink.tsx b/frontend/src/components/NavLink.tsx
new file mode 100644
index 0000000..a561a95
--- /dev/null
+++ b/frontend/src/components/NavLink.tsx
@@ -0,0 +1,28 @@
+import { NavLink as RouterNavLink, NavLinkProps } from "react-router-dom";
+import { forwardRef } from "react";
+import { cn } from "@/lib/utils";
+
+interface NavLinkCompatProps extends Omit {
+ className?: string;
+ activeClassName?: string;
+ pendingClassName?: string;
+}
+
+const NavLink = forwardRef(
+ ({ className, activeClassName, pendingClassName, to, ...props }, ref) => {
+ return (
+
+ cn(className, isActive && activeClassName, isPending && pendingClassName)
+ }
+ {...props}
+ />
+ );
+ },
+);
+
+NavLink.displayName = "NavLink";
+
+export { NavLink };
diff --git a/frontend/src/components/ResultCard.tsx b/frontend/src/components/ResultCard.tsx
new file mode 100644
index 0000000..62209eb
--- /dev/null
+++ b/frontend/src/components/ResultCard.tsx
@@ -0,0 +1,40 @@
+import type { SearchResult } from "@/lib/mock-data";
+
+interface ResultCardProps {
+ result: SearchResult;
+}
+
+export function ResultCard({ result }: ResultCardProps) {
+ const domain = result.parsed_url[1];
+ const faviconUrl = `https://www.google.com/s2/favicons?domain=${domain}&sz=32`;
+
+ return (
+
+
+

+
{result.pretty_url}
+ {result.engines.length > 1 && (
+
+ {result.engines.length} engines
+
+ )}
+
+
+ {result.title}
+
+
+ {result.content}
+
+ {result.publishedDate && (
+
+ {new Date(result.publishedDate).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" })}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/ResultSkeleton.tsx b/frontend/src/components/ResultSkeleton.tsx
new file mode 100644
index 0000000..0119f4c
--- /dev/null
+++ b/frontend/src/components/ResultSkeleton.tsx
@@ -0,0 +1,19 @@
+export function ResultSkeleton() {
+ return (
+
+ {Array.from({ length: 5 }).map((_, i) => (
+
+ ))}
+
+ );
+}
diff --git a/frontend/src/components/SearchInput.tsx b/frontend/src/components/SearchInput.tsx
new file mode 100644
index 0000000..9d1cfe5
--- /dev/null
+++ b/frontend/src/components/SearchInput.tsx
@@ -0,0 +1,43 @@
+import { Search } from "lucide-react";
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
+import { FormEvent, useRef, useEffect } from "react";
+
+interface SearchInputProps {
+ query: string;
+ onQueryChange: (q: string) => void;
+ onSearch: (q: string) => void;
+ compact?: boolean;
+ autoFocus?: boolean;
+}
+
+export function SearchInput({ query, onQueryChange, onSearch, compact, autoFocus }: SearchInputProps) {
+ const inputRef = useRef(null);
+
+ useEffect(() => {
+ if (autoFocus) inputRef.current?.focus();
+ }, [autoFocus]);
+
+ const handleSubmit = (e: FormEvent) => {
+ e.preventDefault();
+ onSearch(query);
+ };
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/ui/accordion.tsx b/frontend/src/components/ui/accordion.tsx
new file mode 100644
index 0000000..1e7878c
--- /dev/null
+++ b/frontend/src/components/ui/accordion.tsx
@@ -0,0 +1,52 @@
+import * as React from "react";
+import * as AccordionPrimitive from "@radix-ui/react-accordion";
+import { ChevronDown } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+
+const Accordion = AccordionPrimitive.Root;
+
+const AccordionItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AccordionItem.displayName = "AccordionItem";
+
+const AccordionTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+ svg]:rotate-180",
+ className,
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+));
+AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
+
+const AccordionContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+ {children}
+
+));
+
+AccordionContent.displayName = AccordionPrimitive.Content.displayName;
+
+export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
diff --git a/frontend/src/components/ui/alert-dialog.tsx b/frontend/src/components/ui/alert-dialog.tsx
new file mode 100644
index 0000000..6dfbfb4
--- /dev/null
+++ b/frontend/src/components/ui/alert-dialog.tsx
@@ -0,0 +1,104 @@
+import * as React from "react";
+import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
+
+import { cn } from "@/lib/utils";
+import { buttonVariants } from "@/components/ui/button";
+
+const AlertDialog = AlertDialogPrimitive.Root;
+
+const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
+
+const AlertDialogPortal = AlertDialogPrimitive.Portal;
+
+const AlertDialogOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
+
+const AlertDialogContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+));
+AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
+
+const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes) => (
+
+);
+AlertDialogHeader.displayName = "AlertDialogHeader";
+
+const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes) => (
+
+);
+AlertDialogFooter.displayName = "AlertDialogFooter";
+
+const AlertDialogTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
+
+const AlertDialogDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
+
+const AlertDialogAction = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
+
+const AlertDialogCancel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
+
+export {
+ AlertDialog,
+ AlertDialogPortal,
+ AlertDialogOverlay,
+ AlertDialogTrigger,
+ AlertDialogContent,
+ AlertDialogHeader,
+ AlertDialogFooter,
+ AlertDialogTitle,
+ AlertDialogDescription,
+ AlertDialogAction,
+ AlertDialogCancel,
+};
diff --git a/frontend/src/components/ui/alert.tsx b/frontend/src/components/ui/alert.tsx
new file mode 100644
index 0000000..2efc3c8
--- /dev/null
+++ b/frontend/src/components/ui/alert.tsx
@@ -0,0 +1,43 @@
+import * as React from "react";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+
+const alertVariants = cva(
+ "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
+ {
+ variants: {
+ variant: {
+ default: "bg-background text-foreground",
+ destructive: "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+);
+
+const Alert = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes & VariantProps
+>(({ className, variant, ...props }, ref) => (
+
+));
+Alert.displayName = "Alert";
+
+const AlertTitle = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+AlertTitle.displayName = "AlertTitle";
+
+const AlertDescription = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+AlertDescription.displayName = "AlertDescription";
+
+export { Alert, AlertTitle, AlertDescription };
diff --git a/frontend/src/components/ui/aspect-ratio.tsx b/frontend/src/components/ui/aspect-ratio.tsx
new file mode 100644
index 0000000..c9e6f4b
--- /dev/null
+++ b/frontend/src/components/ui/aspect-ratio.tsx
@@ -0,0 +1,5 @@
+import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
+
+const AspectRatio = AspectRatioPrimitive.Root;
+
+export { AspectRatio };
diff --git a/frontend/src/components/ui/avatar.tsx b/frontend/src/components/ui/avatar.tsx
new file mode 100644
index 0000000..68d21bb
--- /dev/null
+++ b/frontend/src/components/ui/avatar.tsx
@@ -0,0 +1,38 @@
+import * as React from "react";
+import * as AvatarPrimitive from "@radix-ui/react-avatar";
+
+import { cn } from "@/lib/utils";
+
+const Avatar = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+Avatar.displayName = AvatarPrimitive.Root.displayName;
+
+const AvatarImage = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AvatarImage.displayName = AvatarPrimitive.Image.displayName;
+
+const AvatarFallback = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
+
+export { Avatar, AvatarImage, AvatarFallback };
diff --git a/frontend/src/components/ui/badge.tsx b/frontend/src/components/ui/badge.tsx
new file mode 100644
index 0000000..0853c44
--- /dev/null
+++ b/frontend/src/components/ui/badge.tsx
@@ -0,0 +1,29 @@
+import * as React from "react";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+
+const badgeVariants = cva(
+ "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
+ {
+ variants: {
+ variant: {
+ default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
+ secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
+ outline: "text-foreground",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+);
+
+export interface BadgeProps extends React.HTMLAttributes, VariantProps {}
+
+function Badge({ className, variant, ...props }: BadgeProps) {
+ return ;
+}
+
+export { Badge, badgeVariants };
diff --git a/frontend/src/components/ui/breadcrumb.tsx b/frontend/src/components/ui/breadcrumb.tsx
new file mode 100644
index 0000000..ca91ff5
--- /dev/null
+++ b/frontend/src/components/ui/breadcrumb.tsx
@@ -0,0 +1,90 @@
+import * as React from "react";
+import { Slot } from "@radix-ui/react-slot";
+import { ChevronRight, MoreHorizontal } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+
+const Breadcrumb = React.forwardRef<
+ HTMLElement,
+ React.ComponentPropsWithoutRef<"nav"> & {
+ separator?: React.ReactNode;
+ }
+>(({ ...props }, ref) => );
+Breadcrumb.displayName = "Breadcrumb";
+
+const BreadcrumbList = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+BreadcrumbList.displayName = "BreadcrumbList";
+
+const BreadcrumbItem = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+BreadcrumbItem.displayName = "BreadcrumbItem";
+
+const BreadcrumbLink = React.forwardRef<
+ HTMLAnchorElement,
+ React.ComponentPropsWithoutRef<"a"> & {
+ asChild?: boolean;
+ }
+>(({ asChild, className, ...props }, ref) => {
+ const Comp = asChild ? Slot : "a";
+
+ return ;
+});
+BreadcrumbLink.displayName = "BreadcrumbLink";
+
+const BreadcrumbPage = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+BreadcrumbPage.displayName = "BreadcrumbPage";
+
+const BreadcrumbSeparator = ({ children, className, ...props }: React.ComponentProps<"li">) => (
+ svg]:size-3.5", className)} {...props}>
+ {children ?? }
+
+);
+BreadcrumbSeparator.displayName = "BreadcrumbSeparator";
+
+const BreadcrumbEllipsis = ({ className, ...props }: React.ComponentProps<"span">) => (
+
+
+ More
+
+);
+BreadcrumbEllipsis.displayName = "BreadcrumbElipssis";
+
+export {
+ Breadcrumb,
+ BreadcrumbList,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+ BreadcrumbEllipsis,
+};
diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx
new file mode 100644
index 0000000..cdedd4f
--- /dev/null
+++ b/frontend/src/components/ui/button.tsx
@@ -0,0 +1,47 @@
+import * as React from "react";
+import { Slot } from "@radix-ui/react-slot";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
+ destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
+ outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
+ secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ ghost: "hover:bg-accent hover:text-accent-foreground",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default: "h-10 px-4 py-2",
+ sm: "h-9 rounded-md px-3",
+ lg: "h-11 rounded-md px-8",
+ icon: "h-10 w-10",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ },
+);
+
+export interface ButtonProps
+ extends React.ButtonHTMLAttributes,
+ VariantProps {
+ asChild?: boolean;
+}
+
+const Button = React.forwardRef(
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : "button";
+ return ;
+ },
+);
+Button.displayName = "Button";
+
+export { Button, buttonVariants };
diff --git a/frontend/src/components/ui/calendar.tsx b/frontend/src/components/ui/calendar.tsx
new file mode 100644
index 0000000..900a69e
--- /dev/null
+++ b/frontend/src/components/ui/calendar.tsx
@@ -0,0 +1,54 @@
+import * as React from "react";
+import { ChevronLeft, ChevronRight } from "lucide-react";
+import { DayPicker } from "react-day-picker";
+
+import { cn } from "@/lib/utils";
+import { buttonVariants } from "@/components/ui/button";
+
+export type CalendarProps = React.ComponentProps;
+
+function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) {
+ return (
+ ,
+ IconRight: ({ ..._props }) => ,
+ }}
+ {...props}
+ />
+ );
+}
+Calendar.displayName = "Calendar";
+
+export { Calendar };
diff --git a/frontend/src/components/ui/card.tsx b/frontend/src/components/ui/card.tsx
new file mode 100644
index 0000000..e282748
--- /dev/null
+++ b/frontend/src/components/ui/card.tsx
@@ -0,0 +1,43 @@
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+const Card = React.forwardRef>(({ className, ...props }, ref) => (
+
+));
+Card.displayName = "Card";
+
+const CardHeader = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+CardHeader.displayName = "CardHeader";
+
+const CardTitle = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+CardTitle.displayName = "CardTitle";
+
+const CardDescription = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+CardDescription.displayName = "CardDescription";
+
+const CardContent = React.forwardRef>(
+ ({ className, ...props }, ref) => ,
+);
+CardContent.displayName = "CardContent";
+
+const CardFooter = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+CardFooter.displayName = "CardFooter";
+
+export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
diff --git a/frontend/src/components/ui/carousel.tsx b/frontend/src/components/ui/carousel.tsx
new file mode 100644
index 0000000..3aa0f31
--- /dev/null
+++ b/frontend/src/components/ui/carousel.tsx
@@ -0,0 +1,224 @@
+import * as React from "react";
+import useEmblaCarousel, { type UseEmblaCarouselType } from "embla-carousel-react";
+import { ArrowLeft, ArrowRight } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+import { Button } from "@/components/ui/button";
+
+type CarouselApi = UseEmblaCarouselType[1];
+type UseCarouselParameters = Parameters;
+type CarouselOptions = UseCarouselParameters[0];
+type CarouselPlugin = UseCarouselParameters[1];
+
+type CarouselProps = {
+ opts?: CarouselOptions;
+ plugins?: CarouselPlugin;
+ orientation?: "horizontal" | "vertical";
+ setApi?: (api: CarouselApi) => void;
+};
+
+type CarouselContextProps = {
+ carouselRef: ReturnType[0];
+ api: ReturnType[1];
+ scrollPrev: () => void;
+ scrollNext: () => void;
+ canScrollPrev: boolean;
+ canScrollNext: boolean;
+} & CarouselProps;
+
+const CarouselContext = React.createContext(null);
+
+function useCarousel() {
+ const context = React.useContext(CarouselContext);
+
+ if (!context) {
+ throw new Error("useCarousel must be used within a ");
+ }
+
+ return context;
+}
+
+const Carousel = React.forwardRef & CarouselProps>(
+ ({ orientation = "horizontal", opts, setApi, plugins, className, children, ...props }, ref) => {
+ const [carouselRef, api] = useEmblaCarousel(
+ {
+ ...opts,
+ axis: orientation === "horizontal" ? "x" : "y",
+ },
+ plugins,
+ );
+ const [canScrollPrev, setCanScrollPrev] = React.useState(false);
+ const [canScrollNext, setCanScrollNext] = React.useState(false);
+
+ const onSelect = React.useCallback((api: CarouselApi) => {
+ if (!api) {
+ return;
+ }
+
+ setCanScrollPrev(api.canScrollPrev());
+ setCanScrollNext(api.canScrollNext());
+ }, []);
+
+ const scrollPrev = React.useCallback(() => {
+ api?.scrollPrev();
+ }, [api]);
+
+ const scrollNext = React.useCallback(() => {
+ api?.scrollNext();
+ }, [api]);
+
+ const handleKeyDown = React.useCallback(
+ (event: React.KeyboardEvent) => {
+ if (event.key === "ArrowLeft") {
+ event.preventDefault();
+ scrollPrev();
+ } else if (event.key === "ArrowRight") {
+ event.preventDefault();
+ scrollNext();
+ }
+ },
+ [scrollPrev, scrollNext],
+ );
+
+ React.useEffect(() => {
+ if (!api || !setApi) {
+ return;
+ }
+
+ setApi(api);
+ }, [api, setApi]);
+
+ React.useEffect(() => {
+ if (!api) {
+ return;
+ }
+
+ onSelect(api);
+ api.on("reInit", onSelect);
+ api.on("select", onSelect);
+
+ return () => {
+ api?.off("select", onSelect);
+ };
+ }, [api, onSelect]);
+
+ return (
+
+
+ {children}
+
+
+ );
+ },
+);
+Carousel.displayName = "Carousel";
+
+const CarouselContent = React.forwardRef>(
+ ({ className, ...props }, ref) => {
+ const { carouselRef, orientation } = useCarousel();
+
+ return (
+
+ );
+ },
+);
+CarouselContent.displayName = "CarouselContent";
+
+const CarouselItem = React.forwardRef>(
+ ({ className, ...props }, ref) => {
+ const { orientation } = useCarousel();
+
+ return (
+
+ );
+ },
+);
+CarouselItem.displayName = "CarouselItem";
+
+const CarouselPrevious = React.forwardRef>(
+ ({ className, variant = "outline", size = "icon", ...props }, ref) => {
+ const { orientation, scrollPrev, canScrollPrev } = useCarousel();
+
+ return (
+
+ );
+ },
+);
+CarouselPrevious.displayName = "CarouselPrevious";
+
+const CarouselNext = React.forwardRef>(
+ ({ className, variant = "outline", size = "icon", ...props }, ref) => {
+ const { orientation, scrollNext, canScrollNext } = useCarousel();
+
+ return (
+
+ );
+ },
+);
+CarouselNext.displayName = "CarouselNext";
+
+export { type CarouselApi, Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext };
diff --git a/frontend/src/components/ui/chart.tsx b/frontend/src/components/ui/chart.tsx
new file mode 100644
index 0000000..08d40d9
--- /dev/null
+++ b/frontend/src/components/ui/chart.tsx
@@ -0,0 +1,303 @@
+import * as React from "react";
+import * as RechartsPrimitive from "recharts";
+
+import { cn } from "@/lib/utils";
+
+// Format: { THEME_NAME: CSS_SELECTOR }
+const THEMES = { light: "", dark: ".dark" } as const;
+
+export type ChartConfig = {
+ [k in string]: {
+ label?: React.ReactNode;
+ icon?: React.ComponentType;
+ } & ({ color?: string; theme?: never } | { color?: never; theme: Record });
+};
+
+type ChartContextProps = {
+ config: ChartConfig;
+};
+
+const ChartContext = React.createContext(null);
+
+function useChart() {
+ const context = React.useContext(ChartContext);
+
+ if (!context) {
+ throw new Error("useChart must be used within a ");
+ }
+
+ return context;
+}
+
+const ChartContainer = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<"div"> & {
+ config: ChartConfig;
+ children: React.ComponentProps["children"];
+ }
+>(({ id, className, children, config, ...props }, ref) => {
+ const uniqueId = React.useId();
+ const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
+
+ return (
+
+
+
+ {children}
+
+
+ );
+});
+ChartContainer.displayName = "Chart";
+
+const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
+ const colorConfig = Object.entries(config).filter(([_, config]) => config.theme || config.color);
+
+ if (!colorConfig.length) {
+ return null;
+ }
+
+ return (
+