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/CLAUDE.md b/CLAUDE.md
index b7f254e..e136dd7 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
-kafka is a privacy-respecting metasearch engine written in Go. It provides a SearXNG-compatible `/search` API and an HTML frontend (HTMX + Go templates). 9 engines are implemented natively in Go; unlisted engines can be proxied to an upstream metasearch instance. Responses from multiple engines are merged into a single JSON/CSV/RSS/HTML response.
+samsa is a privacy-respecting metasearch engine written in Go. It provides a SearXNG-compatible `/search` API and an HTML frontend (HTMX + Go templates). 9 engines are implemented natively in Go; unlisted engines can be proxied to an upstream metasearch instance. Responses from multiple engines are merged into a single JSON/CSV/RSS/HTML response.
## Build & Run Commands
@@ -22,7 +22,7 @@ go test -run TestWikipedia ./internal/engines/
go test -v ./internal/engines/
# Run the server (requires config.toml)
-go run ./cmd/kafka -config config.toml
+go run ./cmd/samsa -config config.toml
```
There is no Makefile. There is no linter configured.
@@ -43,7 +43,7 @@ There is no Makefile. There is no linter configured.
- `internal/cache` — Valkey/Redis-backed cache with SHA-256 cache keys. No-op if unconfigured.
- `internal/middleware` — Three rate limiters (per-IP sliding window, burst+sustained, global) and CORS. All disabled by default.
- `internal/views` — HTML templates and static files embedded via `//go:embed`. Renders full pages or HTMX fragments. Templates: `base.html`, `index.html`, `results.html`, `results_inner.html`, `result_item.html`.
-- `cmd/kafka` — Entry point. Loads TOML config, seeds env vars for engine code, wires up middleware chain, starts HTTP server.
+- `cmd/samsa` — Entry point. Loads TOML config, seeds env vars for engine code, wires up middleware chain, starts HTTP server.
**Engine interface** (`internal/engines/engine.go`):
```go
@@ -66,7 +66,7 @@ Config is loaded from `config.toml` (see `config.example.toml`). All fields can
## Conventions
-- Module path: `github.com/metamorphosis-dev/kafka`
+- Module path: `github.com/metamorphosis-dev/samsa`
- Tests use shared mock helpers in `internal/engines/http_mock_test.go` (`roundTripperFunc`, `httpResponse`)
- Engine implementations are single files under `internal/engines/` (e.g., `wikipedia.go`, `duckduckgo.go`)
- Response merging de-duplicates by `engine|title|url` key; suggestions/corrections are merged as sets
diff --git a/Dockerfile b/Dockerfile
index e21960f..9f3443f 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -21,7 +21,7 @@ RUN apk add --no-cache ca-certificates tzdata
COPY --from=builder /kafka /usr/local/bin/kafka
COPY config.example.toml /etc/kafka/config.example.toml
-EXPOSE 8080
+EXPOSE 5355
ENTRYPOINT ["kafka"]
CMD ["-config", "/etc/kafka/config.toml"]
diff --git a/README.md b/README.md
index 25c1c29..7427922 100644
--- a/README.md
+++ b/README.md
@@ -1,20 +1,23 @@
-# kafka
+# samsa
+
+*samsa — named for Gregor Samsa, who woke to find himself transformed. You wanted results; you got a metasearch engine.*
A privacy-respecting, open metasearch engine written in Go. SearXNG-compatible API with an HTML frontend, designed to be fast, lightweight, and deployable anywhere.
-**9 engines. No JavaScript. No tracking. One binary.**
+**11 engines. No JavaScript required. No tracking. One binary.**
## Features
- **SearXNG-compatible API** — drop-in replacement for existing integrations
-- **9 search engines** — Wikipedia, arXiv, Crossref, Brave, Qwant, DuckDuckGo, GitHub, Reddit, Bing
-- **HTML frontend** — HTMX + Go templates with instant search, dark mode, responsive design
+- **11 search engines** — Wikipedia, arXiv, Crossref, Brave Search API, Brave (scraping), Qwant, DuckDuckGo, GitHub, Reddit, Bing, Google, YouTube
+- **Stack Overflow** — bonus engine, not enabled by default
+- **HTML frontend** — Go templates + HTMX with instant search, dark mode, responsive design
- **Valkey cache** — optional Redis-compatible caching with configurable TTL
- **Rate limiting** — three layers: per-IP, burst, and global (all disabled by default)
- **CORS** — configurable origins for browser-based clients
-- **OpenSearch** — browsers can add kafka as a search engine from the address bar
+- **OpenSearch** — browsers can add samsa as a search engine from the address bar
- **Graceful degradation** — individual engine failures don't kill the whole search
-- **Docker** — multi-stage build, ~20MB runtime image
+- **Docker** — multi-stage build, static binary, ~20MB runtime image
- **NixOS** — native NixOS module with systemd service
## Quick Start
@@ -22,17 +25,17 @@ A privacy-respecting, open metasearch engine written in Go. SearXNG-compatible A
### Binary
```bash
-git clone https://git.ashisgreat.xyz/penal-colony/gosearch.git
-cd kafka
-go build ./cmd/kafka
-./kafka -config config.toml
+git clone https://git.ashisgreat.xyz/penal-colony/samsa.git
+cd samsa
+go build ./cmd/samsa
+./samsa -config config.toml
```
### Docker Compose
```bash
cp config.example.toml config.toml
-# Edit config.toml — set your Brave API key, etc.
+# Edit config.toml — set your Brave API key, YouTube API key, etc.
docker compose up -d
```
@@ -41,28 +44,28 @@ docker compose up -d
Add to your flake inputs:
```nix
-inputs.kafka.url = "git+https://git.ashisgreat.xyz/penal-colony/gosearch.git";
+inputs.samsa.url = "git+https://git.ashisgreat.xyz/penal-colony/samsa.git";
```
Enable in your configuration:
```nix
-imports = [ inputs.kafka.nixosModules.default ];
+imports = [ inputs.samsa.nixosModules.default ];
-services.kafka = {
+services.samsa = {
enable = true;
openFirewall = true;
baseUrl = "https://search.example.com";
- # config = "/etc/kafka/config.toml"; # default
+ # config = "/etc/samsa/config.toml"; # default
};
```
Write your config:
```bash
-sudo mkdir -p /etc/kafka
-sudo cp config.example.toml /etc/kafka/config.toml
-sudo $EDITOR /etc/kafka/config.toml
+sudo mkdir -p /etc/samsa
+sudo cp config.example.toml /etc/samsa/config.toml
+sudo $EDITOR /etc/samsa/config.toml
```
Deploy:
@@ -76,7 +79,7 @@ sudo nixos-rebuild switch --flake .#
```bash
nix develop
go test ./...
-go run ./cmd/kafka -config config.toml
+go run ./cmd/samsa -config config.toml
```
## Endpoints
@@ -107,7 +110,7 @@ go run ./cmd/kafka -config config.toml
### Example
```bash
-curl "http://localhost:8080/search?q=golang&format=json&engines=github,duckduckgo"
+curl "http://localhost:5355/search?q=golang&format=json&engines=github,duckduckgo"
```
### Response (JSON)
@@ -140,6 +143,8 @@ Copy `config.example.toml` to `config.toml` and edit. All settings can also be o
- **`[server]`** — port, timeout, public base URL for OpenSearch
- **`[upstream]`** — optional upstream metasearch proxy for unported engines
- **`[engines]`** — which engines run locally, engine-specific settings
+- **`[engines.brave]`** — Brave Search API key
+- **`[engines.youtube]`** — YouTube Data API v3 key
- **`[cache]`** — Valkey/Redis address, password, TTL
- **`[cors]`** — allowed origins and methods
- **`[rate_limit]`** — per-IP sliding window (30 req/min default)
@@ -150,13 +155,14 @@ Copy `config.example.toml` to `config.toml` and edit. All settings can also be o
| Variable | Description |
|---|---|
-| `PORT` | Listen port (default: 8080) |
+| `PORT` | Listen port (default: 5355) |
| `BASE_URL` | Public URL for OpenSearch XML |
| `UPSTREAM_SEARXNG_URL` | Upstream instance URL |
| `LOCAL_PORTED_ENGINES` | Comma-separated local engine list |
| `HTTP_TIMEOUT` | Upstream request timeout |
| `BRAVE_API_KEY` | Brave Search API key |
| `BRAVE_ACCESS_TOKEN` | Gate requests with token |
+| `YOUTUBE_API_KEY` | YouTube Data API v3 key |
| `VALKEY_ADDRESS` | Valkey/Redis address |
| `VALKEY_PASSWORD` | Valkey/Redis password |
| `VALKEY_CACHE_TTL` | Cache TTL |
@@ -170,55 +176,64 @@ See `config.example.toml` for the full list including rate limiting and CORS var
| Wikipedia | MediaWiki API | General knowledge |
| arXiv | arXiv API | Academic papers |
| Crossref | Crossref API | Academic metadata |
-| Brave | Brave Search API | General web (requires API key) |
+| Brave Search API | Brave API | General web (requires API key) |
+| Brave | Brave Lite HTML | General web (no key needed) |
| Qwant | Qwant Lite HTML | General web |
| DuckDuckGo | DDG Lite HTML | General web |
| GitHub | GitHub Search API v3 | Code and repositories |
| Reddit | Reddit JSON API | Discussions |
| Bing | Bing RSS | General web |
+| Google | GSA User-Agent scraping | General web (no API key) |
+| YouTube | YouTube Data API v3 | Videos (requires API key) |
+| Stack Overflow | Stack Exchange API | Q&A (registered, not enabled by default) |
Engines not listed in `engines.local_ported` are proxied to an upstream metasearch instance if `upstream.url` is configured.
+### API Keys
+
+Brave Search API and YouTube Data API require keys. If omitted, those engines are silently skipped. Brave Lite (scraping) and Google (GSA UA scraping) work without keys.
+
## Architecture
```
-┌─────────────────────────────────────┐
-│ HTTP Handler │
-│ /search / /opensearch.xml │
-├─────────────────────────────────────┤
-│ Middleware Chain │
-│ Global → Burst → Per-IP → CORS │
-├─────────────────────────────────────┤
-│ Search Service │
-│ Parallel engine execution │
-│ WaitGroup + graceful degradation │
-├─────────────────────────────────────┤
-│ Cache Layer │
-│ Valkey/Redis (optional, no-op if │
-│ unconfigured) │
-├─────────────────────────────────────┤
-│ Engines (×9) │
-│ Each runs in its own goroutine │
-│ Failures → unresponsive_engines │
-└─────────────────────────────────────┘
+┌───────────────────────────────────────┐
+│ HTTP Handler │
+│ /search / /opensearch.xml │
+├───────────────────────────────────────┤
+│ Middleware Chain │
+│ Global → Burst → Per-IP → CORS │
+├───────────────────────────────────────┤
+│ Search Service │
+│ Parallel engine execution │
+│ WaitGroup + graceful degradation │
+├───────────────────────────────────────┤
+│ Cache Layer │
+│ Valkey/Redis (optional; no-op if │
+│ unconfigured) │
+├───────────────────────────────────────┤
+│ Engines (×11 default) │
+│ Each runs in its own goroutine │
+│ Failures → unresponsive_engines │
+└───────────────────────────────────────┘
```
## Docker
-The Dockerfile uses a multi-stage build:
-
-```dockerfile
-# Build stage: golang:1.24-alpine
-# Runtime stage: alpine:3.21 (~20MB)
-# CGO_ENABLED=0 — static binary
-```
+The Dockerfile uses a multi-stage build with a static Go binary on alpine Linux:
```bash
+# Build: golang:1.24-alpine
+# Runtime: alpine:3.21 (~20MB)
+# CGO_ENABLED=0 — fully static
docker compose up -d
```
Includes Valkey 8 with health checks out of the box.
+## Contributing
+
+See [docs/CONTRIBUTING.md](docs/CONTRIBUTING.md) for a walkthrough of adding a new engine. The interface is two methods: `Name()` and `Search(context, request)`.
+
## License
[AGPLv3](https://www.gnu.org/licenses/agpl-3.0.html)
diff --git a/cmd/kafka/main.go b/cmd/samsa/main.go
similarity index 87%
rename from cmd/kafka/main.go
rename to cmd/samsa/main.go
index f691665..199033b 100644
--- a/cmd/kafka/main.go
+++ b/cmd/samsa/main.go
@@ -1,4 +1,4 @@
-// kafka — a privacy-respecting metasearch engine
+// samsa — a privacy-respecting metasearch engine
// Copyright (C) 2026-present metamorphosis-dev
//
// This program is free software: you can redistribute it and/or modify
@@ -25,13 +25,13 @@ import (
"net/http"
"os"
- "github.com/metamorphosis-dev/kafka/internal/autocomplete"
- "github.com/metamorphosis-dev/kafka/internal/cache"
- "github.com/metamorphosis-dev/kafka/internal/config"
- "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/samsa/internal/autocomplete"
+ "github.com/metamorphosis-dev/samsa/internal/cache"
+ "github.com/metamorphosis-dev/samsa/internal/config"
+ "github.com/metamorphosis-dev/samsa/internal/httpapi"
+ "github.com/metamorphosis-dev/samsa/internal/middleware"
+ "github.com/metamorphosis-dev/samsa/internal/search"
+ "github.com/metamorphosis-dev/samsa/internal/views"
)
func main() {
@@ -77,15 +77,20 @@ func main() {
acSvc := autocomplete.NewService(cfg.Upstream.URL, cfg.HTTPTimeout())
- h := httpapi.NewHandler(svc, acSvc.Suggestions, cfg.Server.SourceURL)
+ h := httpapi.NewHandler(svc, acSvc.Suggestions, cfg.Server.SourceURL, searchCache)
mux := http.NewServeMux()
+
+ // HTML template routes
mux.HandleFunc("/", h.Index)
- mux.HandleFunc("/healthz", h.Healthz)
mux.HandleFunc("/search", h.Search)
- mux.HandleFunc("/autocompleter", h.Autocompleter)
mux.HandleFunc("/preferences", h.Preferences)
+
+ // API routes
+ mux.HandleFunc("/healthz", h.Healthz)
+ mux.HandleFunc("/autocompleter", h.Autocompleter)
mux.HandleFunc("/opensearch.xml", h.OpenSearch(cfg.Server.BaseURL))
+ mux.HandleFunc("/favicon/", h.Favicon)
// Serve embedded static files (CSS, JS, images).
staticFS, err := views.StaticFS()
@@ -123,7 +128,7 @@ func main() {
}, logger)(handler)
addr := fmt.Sprintf(":%d", cfg.Server.Port)
- logger.Info("kafka starting",
+ logger.Info("samsa starting",
"addr", addr,
"cache", searchCache.Enabled(),
"rate_limit", cfg.RateLimit.Requests > 0,
diff --git a/config.example.toml b/config.example.toml
index 042bb63..7fed53e 100644
--- a/config.example.toml
+++ b/config.example.toml
@@ -1,22 +1,22 @@
-# kafka configuration
+# samsa configuration
# Copy to config.toml and adjust as needed.
# Environment variables are used as fallbacks when a config field is empty/unset.
[server]
# Listen port (env: PORT)
-port = 8080
+port = 5355
# HTTP timeout for engine and upstream calls (env: HTTP_TIMEOUT)
http_timeout = "10s"
# Public base URL for OpenSearch XML (env: BASE_URL)
-# Set this so browsers can add kafka as a search engine.
+# Set this so browsers can add samsa as a search engine.
# Example: "https://search.example.com"
base_url = ""
# Link to the source code (shown in footer as "Source" link)
-# Defaults to the upstream kafka repo if not set.
-# Example: "https://git.example.com/my-kafka-fork"
+# Defaults to the upstream samsa repo if not set.
+# Example: "https://git.example.com/my-samsa-fork"
source_url = ""
[upstream]
@@ -27,7 +27,8 @@ url = ""
[engines]
# Comma-separated list of engines to execute locally in Go (env: LOCAL_PORTED_ENGINES)
# Engines not listed here will be proxied to the upstream instance.
-local_ported = ["wikipedia", "arxiv", "crossref", "braveapi", "qwant", "duckduckgo", "github", "reddit", "bing", "google", "youtube"]
+# Include bing_images, ddg_images, qwant_images for image search when [upstream].url is empty.
+local_ported = ["wikipedia", "wikidata", "arxiv", "crossref", "braveapi", "qwant", "duckduckgo", "github", "reddit", "bing", "google", "youtube", "bing_images", "ddg_images", "qwant_images"]
[engines.brave]
# Brave Search API key (env: BRAVE_API_KEY)
@@ -56,6 +57,12 @@ db = 0
# Cache TTL for search results (env: VALKEY_CACHE_TTL)
default_ttl = "5m"
+[cache.ttl_overrides]
+# Per-engine TTL overrides (uncomment to use):
+# wikipedia = "48h"
+# reddit = "15m"
+# braveapi = "2h"
+
[cors]
# CORS configuration for browser-based clients.
# Allowed origins: use "*" for all, or specific domains (env: CORS_ALLOWED_ORIGINS)
diff --git a/docker-compose.yml b/docker-compose.yml
index 98eae96..538713f 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -8,7 +8,7 @@ services:
kafka:
build: .
ports:
- - "8080:8080"
+ - "5355:5355"
volumes:
- ./config.toml:/etc/kafka/config.toml:ro
depends_on:
diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md
new file mode 100644
index 0000000..bada2e2
--- /dev/null
+++ b/docs/CONTRIBUTING.md
@@ -0,0 +1,218 @@
+# Contributing — Adding a New Engine
+
+This guide walks through adding a new search engine to samsa. The minimal engine needs only an HTTP client, a query, and a result parser.
+
+---
+
+## 1. Create the engine file
+
+Place it in `internal/engines/`:
+
+```
+internal/engines/
+ myengine.go ← your engine
+ myengine_test.go ← tests (required)
+```
+
+Name the struct after the engine, e.g. `WolframEngine` for "wolfram". The `Name()` method returns the engine key used throughout samsa.
+
+## 2. Implement the Engine interface
+
+```go
+package engines
+
+import (
+ "context"
+ "github.com/metamorphosis-dev/samsa/internal/contracts"
+)
+
+type MyEngine struct {
+ client *http.Client
+}
+
+func (e *MyEngine) Name() string { return "myengine" }
+
+func (e *MyEngine) Search(ctx context.Context, req contracts.SearchRequest) (contracts.SearchResponse, error) {
+ // ...
+}
+```
+
+### The SearchRequest fields you'll use most:
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `Query` | `string` | The search query |
+| `Pageno` | `int` | Current page number (1-based) |
+| `Safesearch` | `int` | 0=off, 1=moderate, 2=strict |
+| `Language` | `string` | ISO language code (e.g. `"en"`) |
+
+### The SearchResponse to return:
+
+```go
+contracts.SearchResponse{
+ Query: req.Query,
+ NumberOfResults: len(results),
+ Results: results, // []MainResult
+ Answers: []map[string]any{},
+ Corrections: []string{},
+ Infoboxes: []map[string]any{},
+ Suggestions: []string{},
+ UnresponsiveEngines: [][2]string{},
+}
+```
+
+### Empty query — return early:
+
+```go
+if strings.TrimSpace(req.Query) == "" {
+ return contracts.SearchResponse{Query: req.Query}, nil
+}
+```
+
+### Engine unavailable / error — graceful degradation:
+
+```go
+// Rate limited or blocked
+return contracts.SearchResponse{
+ Query: req.Query,
+ UnresponsiveEngines: [][2]string{{"myengine", "reason"}},
+ Results: []contracts.MainResult{},
+ // ... empty other fields
+}, nil
+
+// Hard error — return it
+return contracts.SearchResponse{}, fmt.Errorf("myengine upstream error: status %d", resp.StatusCode)
+```
+
+## 3. Build the result
+
+```go
+urlPtr := "https://example.com/result"
+result := contracts.MainResult{
+ Title: "Result Title",
+ Content: "Snippet or description text",
+ URL: &urlPtr, // pointer to string, required
+ Engine: "myengine",
+ Category: "general", // or "it", "science", "videos", "images", "social media"
+ Score: 0, // used for relevance ranking during merge
+ Engines: []string{"myengine"},
+}
+```
+
+### Template field
+
+The template system checks for `"videos"` and `"images"`. Everything else renders via `result_item.html`. Set `Template` only if you have a custom template; omit it for the default result card.
+
+### Category field
+
+Controls which category tab the result appears under and which engines are triggered:
+
+| Category | Engines used |
+|----------|-------------|
+| `general` | google, bing, ddg, brave, braveapi, qwant, wikipedia |
+| `it` | github, stackoverflow |
+| `science` | arxiv, crossref |
+| `videos` | youtube |
+| `images` | bing_images, ddg_images, qwant_images |
+| `social media` | reddit |
+
+## 4. Wire it into the factory
+
+In `internal/engines/factory.go`, add your engine to the map returned by `NewDefaultPortedEngines`:
+
+```go
+"myengine": &MyEngine{client: client},
+```
+
+If your engine needs an API key, read it from config or the environment (see `braveapi` or `youtube` in factory.go for the pattern).
+
+## 5. Register defaults
+
+In `internal/engines/planner.go`:
+
+**Add to `defaultPortedEngines`:**
+```go
+var defaultPortedEngines = []string{
+ // ... existing ...
+ "myengine",
+}
+```
+
+**Add to category mapping in `inferFromCategories`** (if applicable):
+```go
+case "general":
+ set["myengine"] = true
+```
+
+**Update the sort order map** so results maintain consistent ordering:
+```go
+order := map[string]int{
+ // ... existing ...
+ "myengine": N, // pick a slot
+}
+```
+
+## 6. Add tests
+
+At minimum, test:
+- `Name()` returns the correct string
+- Nil engine returns an error
+- Empty query returns zero results
+- Successful API response parses correctly
+- Rate limit / error cases return `UnresponsiveEngines` with a reason
+
+Use `httptest.NewServer` to mock the upstream API. See `arxiv_test.go` or `reddit_test.go` for examples.
+
+## 7. Build and test
+
+```bash
+go build ./...
+go test ./internal/engines/ -run MyEngine -v
+go test ./...
+```
+
+## Example: Adding an RSS-based engine
+
+If the engine provides an RSS feed, the parsing is straightforward:
+
+```go
+type rssItem struct {
+ Title string `xml:"title"`
+ Link string `xml:"link"`
+ Description string `xml:"description"`
+}
+
+type rssFeed struct {
+ Channel struct {
+ Items []rssItem `xml:"item"`
+ } `xml:"channel"`
+}
+
+dec := xml.NewDecoder(resp.Body)
+var feed rssFeed
+dec.Decode(&feed)
+
+for _, item := range feed.Channel.Items {
+ urlPtr := item.Link
+ results = append(results, contracts.MainResult{
+ Title: item.Title,
+ Content: stripHTML(item.Description),
+ URL: &urlPtr,
+ Engine: "myengine",
+ // ...
+ })
+}
+```
+
+## Checklist
+
+- [ ] Engine file created in `internal/engines/`
+- [ ] `Engine` interface implemented (`Name()` + `Search()`)
+- [ ] Empty query handled (return early, no error)
+- [ ] Graceful degradation for errors and rate limits
+- [ ] Results use `Category` to group with related engines
+- [ ] Factory updated with new engine
+- [ ] Planner updated (defaults + category mapping + sort order)
+- [ ] Tests written covering main paths
+- [ ] `go build ./...` succeeds
+- [ ] `go test ./...` passes
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
deleted file mode 100644
index 28b98a1..0000000
--- a/docs/superpowers/plans/2026-03-22-brave-search-frontend-redesign.md
+++ /dev/null
@@ -1,1222 +0,0 @@
-# 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-settings-ui.md b/docs/superpowers/plans/2026-03-22-settings-ui.md
deleted file mode 100644
index cf34df5..0000000
--- a/docs/superpowers/plans/2026-03-22-settings-ui.md
+++ /dev/null
@@ -1,747 +0,0 @@
-# Settings UI 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:** A preferences popover panel (top-right on desktop, bottom sheet on mobile) that lets users set theme, enabled engines, safe search, and default format. All changes auto-save to `localStorage` and apply immediately to the DOM.
-
-**Architecture:** Pure client-side JS + CSS added alongside existing templates. No Go changes. Settings persist via `localStorage` key `kafka_prefs`. Theme applies via `data-theme` attribute on ``.
-
-**Tech Stack:** Vanilla JS (no framework), existing `kafka.css` custom properties, HTMX for search.
-
----
-
-## File Map
-
-| Action | File |
-|--------|------|
-| Create | `internal/views/static/js/settings.js` |
-| Modify | `internal/views/static/css/kafka.css` |
-| Modify | `internal/views/templates/base.html` |
-| Modify | `internal/views/templates/index.html` |
-| Modify | `internal/views/templates/results.html` |
-| Modify | `internal/views/views.go` |
-
-**Key insight on engine preferences:** `ParseSearchRequest` reads `engines` as a CSV form value (`r.FormValue("engines")`). The search forms in `index.html` and `results.html` will get a hidden `#engines-input` field that is kept in sync with localStorage. On submit, the engines preference is sent as a normal form field. HTMX `hx-include="this"` already includes the form element, so the hidden input is automatically included in the request.
-
----
-
-## Task 1: CSS — Popover, toggles, bottom sheet
-
-**Files:**
-- Modify: `internal/views/static/css/kafka.css`
-
-- [ ] **Step 1: Add CSS for popover, triggers, toggles, bottom sheet**
-
-Append the following to `kafka.css`:
-
-```css
-/* ============================================
- Settings Panel
- ============================================ */
-
-/* Header */
-.site-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 0.6rem 1rem;
- background: var(--color-header-background);
- border-bottom: 1px solid var(--color-header-border);
-}
-.site-title {
- font-size: 1rem;
- font-weight: 600;
- color: var(--color-base-font);
-}
-
-/* Gear trigger button */
-.settings-trigger {
- background: none;
- border: none;
- font-size: 1.1rem;
- cursor: pointer;
- padding: 0.3rem 0.5rem;
- border-radius: var(--radius);
- color: var(--color-base-font);
- opacity: 0.7;
- transition: opacity 0.2s, background 0.2s;
- line-height: 1;
-}
-.settings-trigger:hover,
-.settings-trigger[aria-expanded="true"] {
- opacity: 1;
- background: var(--color-sidebar-background);
-}
-
-/* Popover panel */
-.settings-popover {
- position: absolute;
- top: 100%;
- right: 0;
- width: 280px;
- max-height: 420px;
- overflow-y: auto;
- background: var(--color-base-background);
- border: 1px solid var(--color-sidebar-border);
- border-radius: var(--radius);
- box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
- z-index: 200;
- display: none;
- flex-direction: column;
-}
-.settings-popover[data-open="true"] {
- display: flex;
- animation: settings-slide-in 0.2s ease;
-}
-@keyframes settings-slide-in {
- from { opacity: 0; transform: translateY(-8px); }
- to { opacity: 1; transform: translateY(0); }
-}
-
-.settings-popover-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 0.75rem 1rem;
- border-bottom: 1px solid var(--color-sidebar-border);
- font-weight: 600;
- font-size: 0.9rem;
- flex-shrink: 0;
-}
-.settings-popover-close {
- background: none;
- border: none;
- font-size: 1.2rem;
- cursor: pointer;
- color: var(--color-base-font);
- opacity: 0.6;
- padding: 0 0.25rem;
- line-height: 1;
-}
-.settings-popover-close:hover { opacity: 1; }
-
-.settings-popover-body {
- padding: 0.8rem;
- display: flex;
- flex-direction: column;
- gap: 1rem;
-}
-
-.settings-section-title {
- font-size: 0.7rem;
- font-weight: 600;
- text-transform: uppercase;
- letter-spacing: 0.05em;
- color: var(--color-suggestion);
- margin-bottom: 0.5rem;
-}
-
-/* Theme buttons */
-.theme-buttons {
- display: flex;
- gap: 0.4rem;
-}
-.theme-btn {
- flex: 1;
- padding: 0.35rem 0.5rem;
- border: 1px solid var(--color-sidebar-border);
- border-radius: var(--radius);
- background: var(--color-btn-background);
- color: var(--color-base-font);
- cursor: pointer;
- font-size: 0.75rem;
- text-align: center;
- transition: background 0.15s, border-color 0.15s;
-}
-.theme-btn:hover { background: var(--color-btn-hover); }
-.theme-btn.active {
- background: var(--color-link);
- color: #fff;
- border-color: var(--color-link);
-}
-
-/* Engine toggles — 2-column grid */
-.engine-grid {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 0.4rem;
-}
-.engine-toggle {
- display: flex;
- align-items: center;
- gap: 0.4rem;
- padding: 0.3rem 0.5rem;
- border-radius: var(--radius);
- background: var(--color-sidebar-background);
- font-size: 0.78rem;
- cursor: pointer;
-}
-.engine-toggle input[type="checkbox"] {
- width: 15px;
- height: 15px;
- margin: 0;
- cursor: pointer;
- accent-color: var(--color-link);
-}
-.engine-toggle span {
- flex: 1;
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-/* Search defaults */
-.setting-row {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 0.5rem;
- margin-top: 0.4rem;
-}
-.setting-row label {
- font-size: 0.85rem;
- flex: 1;
-}
-.setting-row select {
- width: 110px;
- padding: 0.3rem 0.4rem;
- font-size: 0.8rem;
- border: 1px solid var(--color-sidebar-border);
- border-radius: var(--radius);
- background: var(--color-base-background);
- color: var(--color-base-font);
- cursor: pointer;
-}
-
-/* Mid-search notice */
-.settings-notice {
- font-size: 0.72rem;
- color: var(--color-suggestion);
- margin-top: 0.3rem;
- font-style: italic;
-}
-
-/* Dark theme via data-theme attribute */
-html[data-theme="dark"] {
- --color-base: #222;
- --color-base-font: #dcdcdc;
- --color-base-background: #2b2b2b;
- --color-header-background: #333;
- --color-header-border: #444;
- --color-search-border: #555;
- --color-search-focus: #5dade2;
- --color-result-url: #8ab4f8;
- --color-result-url-visited: #b39ddb;
- --color-result-content: #b0b0b0;
- --color-result-title: #8ab4f8;
- --color-result-title-visited: #b39ddb;
- --color-result-engine: #999;
- --color-result-border: #3a3a3a;
- --color-link: #5dade2;
- --color-link-visited: #b39ddb;
- --color-sidebar-background: #333;
- --color-sidebar-border: #444;
- --color-infobox-background: #333;
- --color-infobox-border: #444;
- --color-pagination-current: #5dade2;
- --color-pagination-border: #444;
- --color-error: #e74c3c;
- --color-error-background: #3b1a1a;
- --color-suggestion: #999;
- --color-footer: #666;
- --color-btn-background: #333;
- --color-btn-border: #555;
- --color-btn-hover: #444;
-}
-
-/* Mobile: Bottom sheet + FAB trigger */
-@media (max-width: 768px) {
- /* Hide desktop trigger, show FAB */
- .settings-trigger-desktop {
- display: none;
- }
- .settings-trigger-mobile {
- display: block;
- }
- .settings-popover {
- position: fixed;
- top: auto;
- bottom: 0;
- left: 0;
- right: 0;
- width: 100%;
- max-height: 70vh;
- border-radius: var(--radius) var(--radius) 0 0;
- border-bottom: none;
- }
- /* FAB: fixed bottom-right button visible only on mobile */
- .settings-trigger-mobile {
- display: block;
- position: fixed;
- bottom: 1.5rem;
- right: 1.5rem;
- width: 48px;
- height: 48px;
- border-radius: 50%;
- background: var(--color-link);
- color: #fff;
- border: none;
- box-shadow: 0 4px 12px rgba(0,0,0,0.2);
- font-size: 1.2rem;
- z-index: 199;
- opacity: 1;
- }
-}
-```
-
-Note: The existing `:root` and `@media (prefers-color-scheme: dark)` blocks provide the "system" theme. `html[data-theme="dark"]` overrides only apply when the user explicitly picks dark mode. When `theme === 'system'`, the `data-theme` attribute is removed and the browser's `prefers-color-scheme` media query kicks in via the existing CSS.
-
-- [ ] **Step 2: Verify existing tests still pass**
-
-Run: `go test ./...`
-Expected: all pass
-
-- [ ] **Step 3: Commit**
-
-```bash
-git add internal/views/static/css/kafka.css
-git commit -m "feat(settings): add popover, toggle, and bottom-sheet CSS"
-```
-
----
-
-## Task 2: JS — Settings logic
-
-**Files:**
-- Create: `internal/views/static/js/settings.js`
-
-- [ ] **Step 1: Write the settings JS module**
-
-Create `internal/views/static/js/settings.js`:
-
-```javascript
-'use strict';
-
-var ALL_ENGINES = [
- 'wikipedia', 'arxiv', 'crossref', 'braveapi',
- 'qwant', 'duckduckgo', 'github', 'reddit', 'bing'
-];
-
-var DEFAULT_PREFS = {
- theme: 'system',
- engines: ALL_ENGINES.slice(),
- safeSearch: 'moderate',
- format: 'html'
-};
-
-var STORAGE_KEY = 'kafka_prefs';
-
-// ── Persistence ──────────────────────────────────────────────────────────────
-
-function loadPrefs() {
- try {
- var raw = localStorage.getItem(STORAGE_KEY);
- if (!raw) return { theme: DEFAULT_PREFS.theme, engines: DEFAULT_PREFS.engines.slice(), safeSearch: DEFAULT_PREFS.safeSearch, format: DEFAULT_PREFS.format };
- var saved = JSON.parse(raw);
- return { theme: saved.theme || DEFAULT_PREFS.theme, engines: saved.engines || DEFAULT_PREFS.engines.slice(), safeSearch: saved.safeSearch || DEFAULT_PREFS.safeSearch, format: saved.format || DEFAULT_PREFS.format };
- } catch (e) {
- return { theme: DEFAULT_PREFS.theme, engines: DEFAULT_PREFS.engines.slice(), safeSearch: DEFAULT_PREFS.safeSearch, format: DEFAULT_PREFS.format };
- }
-}
-
-function savePrefs(prefs) {
- try {
- localStorage.setItem(STORAGE_KEY, JSON.stringify({ theme: prefs.theme, engines: prefs.engines, safeSearch: prefs.safeSearch, format: prefs.format }));
- } catch (e) { /* quota or private mode */ }
-}
-
-// ── Theme application ────────────────────────────────────────────────────────
-
-function applyTheme(theme) {
- if (theme === 'system') {
- document.documentElement.removeAttribute('data-theme');
- } else {
- document.documentElement.setAttribute('data-theme', theme);
- }
-}
-
-// ── Engine input sync ─────────────────────────────────────────────────────────
-
-function syncEngineInput(prefs) {
- var input = document.getElementById('engines-input');
- if (input) input.value = prefs.engines.join(',');
-}
-
-// ── Panel open / close ────────────────────────────────────────────────────────
-
-function closePanel() {
- var panel = document.getElementById('settings-popover');
- var trigger = document.getElementById('settings-trigger');
- if (!panel) return;
- panel.setAttribute('data-open', 'false');
- if (trigger) trigger.setAttribute('aria-expanded', 'false');
- if (trigger) trigger.focus();
-}
-
-function openPanel() {
- var panel = document.getElementById('settings-popover');
- var trigger = document.getElementById('settings-trigger');
- if (!panel) return;
- panel.setAttribute('data-open', 'true');
- if (trigger) trigger.setAttribute('aria-expanded', 'true');
- var focusable = panel.querySelector('button, input, select');
- if (focusable) focusable.focus();
-}
-
-// ── Escape key ───────────────────────────────────────────────────────────────
-
-document.addEventListener('keydown', function(e) {
- if (e.key !== 'Escape') return;
- var panel = document.getElementById('settings-popover');
- if (!panel || panel.getAttribute('data-open') !== 'true') return;
- closePanel();
-});
-
-// ── Click outside ─────────────────────────────────────────────────────────────
-
-document.addEventListener('click', function(e) {
- var panel = document.getElementById('settings-popover');
- var trigger = document.getElementById('settings-trigger');
- if (!panel || panel.getAttribute('data-open') !== 'true') return;
- if (!panel.contains(e.target) && (!trigger || !trigger.contains(e.target))) {
- closePanel();
- }
-});
-
-// ── Focus trap ────────────────────────────────────────────────────────────────
-
-document.addEventListener('keydown', function(e) {
- if (e.key !== 'Tab') return;
- var panel = document.getElementById('settings-popover');
- if (!panel || panel.getAttribute('data-open') !== 'true') return;
- var focusable = Array.prototype.slice.call(panel.querySelectorAll('button, input, select, [tabindex]:not([tabindex="-1"])'));
- if (!focusable.length) return;
- var first = focusable[0];
- var last = focusable[focusable.length - 1];
- if (e.shiftKey) {
- if (document.activeElement === first) { e.preventDefault(); last.focus(); }
- } else {
- if (document.activeElement === last) { e.preventDefault(); first.focus(); }
- }
-});
-
-// ── Render ────────────────────────────────────────────────────────────────────
-
-function escapeHtml(str) {
- return String(str).replace(/&/g, '&').replace(//g, '>');
-}
-
-function renderPanel(prefs) {
- var panel = document.getElementById('settings-popover');
- if (!panel) return;
- var body = panel.querySelector('.settings-popover-body');
- if (!body) return;
-
- var themeBtns = '';
- ['light', 'dark', 'system'].forEach(function(t) {
- var icons = { light: '\u2600', dark: '\u263D', system: '\u2318' };
- var labels = { light: 'Light', dark: 'Dark', system: 'System' };
- var active = prefs.theme === t ? ' active' : '';
- themeBtns += '';
- });
-
- var engineToggles = '';
- ALL_ENGINES.forEach(function(name) {
- var checked = prefs.engines.indexOf(name) !== -1 ? ' checked' : '';
- engineToggles += '';
- });
-
- var ssOptions = [
- { val: 'moderate', label: 'Moderate' },
- { val: 'strict', label: 'Strict' },
- { val: 'off', label: 'Off' }
- ];
- var fmtOptions = [
- { val: 'html', label: 'HTML' },
- { val: 'json', label: 'JSON' },
- { val: 'csv', label: 'CSV' },
- { val: 'rss', label: 'RSS' }
- ];
- var ssOptionsHtml = '';
- var fmtOptionsHtml = '';
- ssOptions.forEach(function(o) {
- var sel = prefs.safeSearch === o.val ? ' selected' : '';
- ssOptionsHtml += '';
- });
- fmtOptions.forEach(function(o) {
- var sel = prefs.format === o.val ? ' selected' : '';
- fmtOptionsHtml += '';
- });
-
- body.innerHTML =
- '' +
- '
Appearance
' +
- '
' + themeBtns + '
' +
- '
' +
- '' +
- '
Engines
' +
- '
' + engineToggles + '
' +
- '
Engine changes apply to your next search.
' +
- '
' +
- '' +
- '
Search Defaults
' +
- '
' +
- '' +
- '' +
- '
' +
- '
' +
- '' +
- '' +
- '
' +
- '
';
-
- // Theme buttons
- var themeBtnEls = panel.querySelectorAll('.theme-btn');
- for (var i = 0; i < themeBtnEls.length; i++) {
- themeBtnEls[i].addEventListener('click', (function(btn) {
- return function() {
- prefs.theme = btn.getAttribute('data-theme');
- savePrefs(prefs);
- applyTheme(prefs.theme);
- syncEngineInput(prefs);
- renderPanel(prefs);
- };
- })(themeBtnEls[i]));
- }
-
- // Engine checkboxes
- var checkboxes = panel.querySelectorAll('.engine-toggle input[type="checkbox"]');
- for (var j = 0; j < checkboxes.length; j++) {
- checkboxes[j].addEventListener('change', (function(cb) {
- return function() {
- var checked = Array.prototype.slice.call(panel.querySelectorAll('.engine-toggle input[type="checkbox"]:checked')).map(function(el) { return el.value; });
- if (checked.length === 0) { cb.checked = true; return; }
- prefs.engines = checked;
- savePrefs(prefs);
- syncEngineInput(prefs);
- };
- })(checkboxes[j]));
- }
-
- // Safe search
- var ssEl = panel.querySelector('#pref-safesearch');
- if (ssEl) {
- ssEl.addEventListener('change', function() {
- prefs.safeSearch = ssEl.value;
- savePrefs(prefs);
- });
- }
-
- // Format
- var fmtEl = panel.querySelector('#pref-format');
- if (fmtEl) {
- fmtEl.addEventListener('change', function() {
- prefs.format = fmtEl.value;
- savePrefs(prefs);
- });
- }
-
- // Close button
- var closeBtn = panel.querySelector('.settings-popover-close');
- if (closeBtn) closeBtn.addEventListener('click', closePanel);
-}
-
-// ── Init ─────────────────────────────────────────────────────────────────────
-
-function initSettings() {
- var prefs = loadPrefs();
- applyTheme(prefs.theme);
- syncEngineInput(prefs);
-
- var panel = document.getElementById('settings-popover');
- var trigger = document.getElementById('settings-trigger');
- var mobileTrigger = document.getElementById('settings-trigger-mobile');
-
- if (panel) {
- renderPanel(prefs);
-
- function togglePanel() {
- var isOpen = panel.getAttribute('data-open') === 'true';
- if (isOpen) closePanel(); else openPanel();
- }
-
- if (trigger) trigger.addEventListener('click', togglePanel);
- if (mobileTrigger) mobileTrigger.addEventListener('click', togglePanel);
- }
-}
-
-if (document.readyState === 'loading') {
- document.addEventListener('DOMContentLoaded', initSettings);
-} else {
- initSettings();
-}
-```
-
-- [ ] **Step 2: Verify JS syntax**
-
-Run: `node --check internal/views/static/js/settings.js`
-Expected: no output (exit 0)
-
-- [ ] **Step 3: Commit**
-
-```bash
-git add internal/views/static/js/settings.js
-git commit -m "feat(settings): add JS module for localStorage preferences and panel"
-```
-
----
-
-## Task 3: HTML — Gear trigger, panel markup, header in base
-
-**Files:**
-- Modify: `internal/views/templates/base.html`
-- Modify: `internal/views/views.go`
-
-- [ ] **Step 1: Add ShowHeader to PageData**
-
-In `views.go`, add `ShowHeader bool` to `PageData` struct.
-
-- [ ] **Step 2: Set ShowHeader in render functions**
-
-In `RenderIndex` and `RenderSearch`, set `PageData.ShowHeader = true`.
-
-- [ ] **Step 3: Update base.html — add header and settings markup**
-
-In `base.html`, update the `` to:
-
-```html
-
- {{if .ShowHeader}}
-
-
-
- {{end}}
-
- {{template "content" .}}
-
-
-
-
-
-
-```
-
-**Note:** The existing autocomplete `
+
+ {{if .Query}}{{.Query}} — {{end}}samsa
+
+
+
-
+
+
+
@@ -35,135 +1170,8 @@
-
-
-
-
-
-
-
-
{{end}}
diff --git a/internal/views/templates/image_item.html b/internal/views/templates/image_item.html
new file mode 100644
index 0000000..4b0909b
--- /dev/null
+++ b/internal/views/templates/image_item.html
@@ -0,0 +1,15 @@
+{{define "image_item"}}
+
+
+ {{if .Thumbnail}}
+

+ {{else}}
+
🖼️
+ {{end}}
+
+
+ {{.Title}}
+ {{if .Content}}{{.Content}}{{end}}
+
+
+{{end}}
diff --git a/internal/views/templates/index.html b/internal/views/templates/index.html
index 7a241f1..eb02d9b 100644
--- a/internal/views/templates/index.html
+++ b/internal/views/templates/index.html
@@ -1,60 +1,25 @@
{{define "title"}}{{end}}
{{define "content"}}
-
-
+
-
Search the web privately, without tracking or censorship.
-
+
-
-{{end}}
\ No newline at end of file
+{{end}}
diff --git a/internal/views/templates/opensearch.xml b/internal/views/templates/opensearch.xml
index 3168631..151e175 100644
--- a/internal/views/templates/opensearch.xml
+++ b/internal/views/templates/opensearch.xml
@@ -1,12 +1,12 @@
- kafka
+ samsa
A privacy-respecting, open metasearch engine
UTF-8
UTF-8
- kafka — Privacy-respecting metasearch
+ samsa — Privacy-respecting metasearch
/static/img/favicon.svg
- https://git.ashisgreat.xyz/penal-colony/kafka
+ https://git.ashisgreat.xyz/penal-colony/samsa
diff --git a/internal/views/templates/preferences.html b/internal/views/templates/preferences.html
index 394c27f..1fbbd48 100644
--- a/internal/views/templates/preferences.html
+++ b/internal/views/templates/preferences.html
@@ -1,191 +1,112 @@
{{define "title"}}Preferences{{end}}
{{define "content"}}
-
-
-
+
+
Preferences
-
-
-
-
- Search
+
+
+
-{{end}}
\ No newline at end of file
+{{end}}
diff --git a/internal/views/templates/result_item.html b/internal/views/templates/result_item.html
index cf1c33b..5cc10d7 100644
--- a/internal/views/templates/result_item.html
+++ b/internal/views/templates/result_item.html
@@ -1,15 +1,17 @@
{{define "result_item"}}
-
+
-

+ {{if .FaviconIconURL}}
+

+ {{end}}
{{.URL}}
-
{{.Engine}}
+
{{.Engine}}
{{if .Content}}
- {{.Content}}
+ {{.SafeContent}}
{{end}}
{{end}}
diff --git a/internal/views/templates/results.html b/internal/views/templates/results.html
index 74d5f93..f601a03 100644
--- a/internal/views/templates/results.html
+++ b/internal/views/templates/results.html
@@ -1,58 +1,43 @@
{{define "title"}}{{if .Query}}{{.Query}} — {{end}}{{end}}
{{define "content"}}
-
-
-
-
-