kafka/docs/superpowers/plans/2026-03-22-settings-ui.md
ashisgreat22 4c52c77460 docs: fix plan issues — mobile FAB, RSS format, init wiring
Reviewed-by: plan-document-reviewer

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 03:00:12 +01:00

23 KiB

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 <html>.

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:

/* ============================================
   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
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:

'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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

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 += '<button class="theme-btn' + active + '" data-theme="' + t + '">' + icons[t] + ' ' + labels[t] + '</button>';
  });

  var engineToggles = '';
  ALL_ENGINES.forEach(function(name) {
    var checked = prefs.engines.indexOf(name) !== -1 ? ' checked' : '';
    engineToggles += '<label class="engine-toggle"><input type="checkbox" value="' + escapeHtml(name) + '"' + checked + '><span>' + escapeHtml(name) + '</span></label>';
  });

  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 += '<option value="' + o.val + '"' + sel + '>' + o.label + '</option>';
  });
  fmtOptions.forEach(function(o) {
    var sel = prefs.format === o.val ? ' selected' : '';
    fmtOptionsHtml += '<option value="' + o.val + '"' + sel + '>' + o.label + '</option>';
  });

  body.innerHTML =
    '<div class="settings-section">' +
      '<div class="settings-section-title">Appearance</div>' +
      '<div class="theme-buttons">' + themeBtns + '</div>' +
    '</div>' +
    '<div class="settings-section">' +
      '<div class="settings-section-title">Engines</div>' +
      '<div class="engine-grid">' + engineToggles + '</div>' +
      '<p class="settings-notice">Engine changes apply to your next search.</p>' +
    '</div>' +
    '<div class="settings-section">' +
      '<div class="settings-section-title">Search Defaults</div>' +
      '<div class="setting-row">' +
        '<label for="pref-safesearch">Safe search</label>' +
        '<select id="pref-safesearch">' + ssOptionsHtml + '</select>' +
      '</div>' +
      '<div class="setting-row">' +
        '<label for="pref-format">Default format</label>' +
        '<select id="pref-format">' + fmtOptionsHtml + '</select>' +
      '</div>' +
    '</div>';

  // 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
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 <body> to:

<body class="{{if .Query}}search_on_results{{end}}">
  {{if .ShowHeader}}
  <header class="site-header">
    <span class="site-title">kafka</span>
    <!-- Desktop trigger (hidden on mobile) -->
    <button id="settings-trigger" class="settings-trigger settings-trigger-desktop"
      aria-label="Preferences" aria-expanded="false" aria-controls="settings-popover">&#9881;</button>
  </header>
  <!-- Mobile FAB trigger (hidden on desktop, shown via CSS on mobile) -->
  <button id="settings-trigger-mobile" class="settings-trigger settings-trigger-mobile"
    aria-label="Preferences" aria-expanded="false" aria-controls="settings-popover"
    style="display:none;">&#9881;</button>
  {{end}}
  <main>
    {{template "content" .}}
  </main>
  <footer>
    <p>Powered by <a href="https://git.ashisgreat.xyz/penal-colony/kafka">kafka</a> — a privacy-respecting, open metasearch engine</p>
  </footer>
  <script src="/static/js/settings.js"></script>
  <div id="settings-popover" data-open="false" role="dialog" aria-label="Preferences" aria-modal="true">
    <div class="settings-popover-header">
      Preferences
      <button class="settings-popover-close" aria-label="Close">&#215;</button>
    </div>
    <div class="settings-popover-body"></div>
  </div>
  <script>
  (function () {
    'use strict';
    var input = document.getElementById('q');
    var dropdown = document.getElementById('autocomplete-dropdown');
    var form = document.getElementById('search-form');
    var debounceTimer = null;
    var suggestions = [];
    var activeIndex = -1;
    var fetchController = null;
    // ... existing autocomplete JS stays unchanged ...
  }());
  </script>
</body>

Note: The existing autocomplete <script> block is preserved as-is. Only the body wrapper and settings elements are added.

  • Step 4: Run tests

Run: go test ./... Expected: all pass

  • Step 5: Commit
git add internal/views/templates/base.html internal/views/views.go
git commit -m "feat(settings): add gear trigger and panel markup to base template"

Task 4: Search form — Inject engine preferences

Files:

  • Modify: internal/views/templates/index.html

  • Modify: internal/views/templates/results.html

  • Step 1: Add hidden engines input to both search forms

In index.html, add inside the <form>:

<input type="hidden" name="engines" id="engines-input" value="">

In results.html, add inside the <form>:

<input type="hidden" name="engines" id="engines-input" value="">

The value is populated by syncEngineInput(prefs) on page load. When the form submits (regular GET or HTMX), the engines parameter is included as a CSV string, which ParseSearchRequest reads correctly via r.FormValue("engines").

  • Step 2: Verify existing search works

Run: go run ./cmd/kafka -config config.toml Open: http://localhost:8080 Search for "golang" — results should appear as normal.

  • Step 3: Commit
git add internal/views/templates/index.html internal/views/templates/results.html
git commit -m "feat(settings): add hidden engines input to search forms"

Task 5: End-to-end verification

  • Step 1: Start server

Run: go run ./cmd/kafka -config config.toml Open: http://localhost:8080

  • Step 2: Verify gear icon and panel

Click the gear icon in the header — panel drops down from top-right with Appearance, Engines, and Search Defaults sections.

  • Step 3: Verify theme persistence

Click Dark → page colors change immediately. Refresh → dark theme persists.

  • Step 4: Verify engine toggle persistence

Uncheck "wikipedia", refresh → "wikipedia" stays unchecked.

  • Step 5: Verify engines appear in search query

With wikipedia unchecked, open DevTools → Network tab, search "golang". Verify request URL includes &engines=arxiv,crossref,... (no wikipedia).

  • Step 6: Verify mobile bottom sheet

Resize to <768px or use mobile device emulation. Click gear → full-width sheet slides up from bottom.

  • Step 7: Final commit
git add -A
git commit -m "feat: complete settings UI — popover, auto-save, theme, engines, mobile bottom-sheet"