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 5b6ad9b..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 samsa 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/samsa.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/samsa.css` - -- [ ] **Step 1: Add three-column results layout CSS** - -Append to end of `samsa.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/samsa.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}} - - {{index $.CategoryIcons .}} - {{.}} - -{{end}} - -{{range .DisabledCategories}} - - {{index $.CategoryIcons .}} - {{.}} - -{{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.

- - - -
- - 🌐 - All - - - πŸ–ΌοΈ - Images - - - πŸ“° - News - - - 🎬 - Videos - - - πŸ—ΊοΈ - Maps - - - πŸ›’ - Shopping - - - 🎡 - Music - - - 🌀️ - Weather - -
-
-
-{{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"}} -
- - - - -
- - - - - - - - - - - - - - - - - - - - - - - -
-
-{{end}} -``` - -- [ ] **Step 2: Add preferences section CSS styles** - -Append to `samsa.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/samsa.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/samsa/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/samsa/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/samsa/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/samsa.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/samsa.css -git commit -m "fix(frontend): improve mobile responsiveness" -``` - ---- - -## Summary - -| Phase | Task | Files | -|-------|------|-------| -| 1 | CSS Layout Framework | `samsa.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 8b68536..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 `samsa_prefs`. Theme applies via `data-theme` attribute on ``. - -**Tech Stack:** Vanilla JS (no framework), existing `samsa.css` custom properties, HTMX for search. - ---- - -## File Map - -| Action | File | -|--------|------| -| Create | `internal/views/static/js/settings.js` | -| Modify | `internal/views/static/css/samsa.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/samsa.css` - -- [ ] **Step 1: Add CSS for popover, triggers, toggles, bottom sheet** - -Append the following to `samsa.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/samsa.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 = 'samsa_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 `