From 0d28f693209bc73e27f52441290d0b789ebd368b Mon Sep 17 00:00:00 2001 From: etalon Date: Sat, 13 Jun 2026 10:44:07 +0200 Subject: [PATCH] feat: 5 dramatic extra themes (neon/terminal/blueprint/sunset) + board/review/layout polish - Themes: add neon (cyber gradient), terminal (green CRT), blueprint (graph paper) and sunset (vaporwave) alongside the default Light (Y2K paper) and Dark. Theme toggle now cycles all; profile selector lists them; server validates against a shared whitelist. - Bounty cards now show the project (customer) name. - Remove the celebratory emoji from the empty Review Queue. - Full-width h1 underline rule; My Tasks columns stack vertically for more room per task; card action buttons wrap/right-align so they never overflow. Co-Authored-By: Claude Fable 5 --- internal/http/profile.go | 10 +- internal/http/profile_integration_test.go | 2 +- web/static/css/app.css | 129 +++++++++++++++++++++- web/static/js/board.js | 3 + web/static/js/nav.js | 5 +- web/static/js/reviews.js | 2 +- web/static/js/theme.js | 4 +- web/templates/profile.html | 6 +- 8 files changed, 153 insertions(+), 8 deletions(-) diff --git a/internal/http/profile.go b/internal/http/profile.go index d9df0df..4a446a6 100644 --- a/internal/http/profile.go +++ b/internal/http/profile.go @@ -15,6 +15,12 @@ import ( "bountyboard/internal/store" ) +// validThemes are the selectable UI themes (must match web/static/js/theme.js). +var validThemes = map[string]bool{ + "light": true, "dark": true, "neon": true, + "terminal": true, "blueprint": true, "sunset": true, +} + func (s *Server) routesProfile(mux *http.ServeMux) { mux.Handle("GET /api/v1/profile", s.requireAuth(http.HandlerFunc(s.handleGetProfile))) mux.Handle("PATCH /api/v1/profile", s.authed(s.handlePatchProfile)) @@ -71,8 +77,8 @@ func (s *Server) handlePatchProfile(w http.ResponseWriter, r *http.Request) { if req.Settings != nil { if req.Settings.Theme != nil { theme := *req.Settings.Theme - if theme != "light" && theme != "dark" { - writeError(w, http.StatusBadRequest, "invalid_theme", "theme must be light or dark") + if !validThemes[theme] { + writeError(w, http.StatusBadRequest, "invalid_theme", "unknown theme") return } set["settings.theme"] = theme diff --git a/internal/http/profile_integration_test.go b/internal/http/profile_integration_test.go index 2cf4ea4..c416b23 100644 --- a/internal/http/profile_integration_test.go +++ b/internal/http/profile_integration_test.go @@ -108,7 +108,7 @@ func TestProfilePatchAndThemePersistence(t *testing.T) { // invalid theme rejected resp = patchJSON(t, c, ts.URL+"/api/v1/profile", map[string]any{ - "settings": map[string]any{"theme": "neon"}, + "settings": map[string]any{"theme": "rainbow-unicorn"}, }, csrf) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("invalid theme: %d", resp.StatusCode) diff --git a/web/static/css/app.css b/web/static/css/app.css index 71cb578..130bef6 100644 --- a/web/static/css/app.css +++ b/web/static/css/app.css @@ -60,6 +60,113 @@ --dither-dense: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='2' height='2'%3E%3Crect width='1' height='1' x='0' y='0' fill='%23d9a548'/%3E%3C/svg%3E"); } +/* ============================================================= */ +/* Additional dramatic themes (user-requested). Each redefines the + full token set + an atmospheric override for a distinct look. */ +/* ============================================================= */ + +/* ---- NEON — cyber, magenta/cyan gradients, glowing accents ---- */ +:root[data-theme=neon] { + --bg:#080014; --surface:#160a32; --surface2:#221248; --border:#7a3bf0; + --text:#ece6ff; --muted:#a48fe0; --accent:#00e5ff; --accent-contrast:#06000f; + --ok:#39ff9e; --warn:#ffd23f; --err:#ff3b8b; --radius:8px; + --ink-shadow: 0 0 14px color-mix(in srgb, var(--accent) 55%, transparent); + --ink-shadow-lift: 0 0 26px color-mix(in srgb, var(--accent) 70%, transparent); + --dither: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4'%3E%3Crect width='1' height='1' x='0' y='0' fill='%2300e5ff'/%3E%3Crect width='1' height='1' x='2' y='2' fill='%23ff3bd6'/%3E%3C/svg%3E"); + --dither-dense: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='2' height='2'%3E%3Crect width='1' height='1' x='0' y='0' fill='%2300e5ff'/%3E%3C/svg%3E"); +} +:root[data-theme=neon] body { + background: + radial-gradient(900px 500px at 12% -8%, rgba(255,59,214,0.28), transparent 60%), + radial-gradient(900px 500px at 92% 4%, rgba(0,229,255,0.24), transparent 60%), + linear-gradient(170deg, #0c0020 0%, #080014 55%, #04000c 100%); + background-attachment: fixed; +} +:root[data-theme=neon] .card, +:root[data-theme=neon] .kanban-col { + border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 18%, transparent), + 0 0 22px rgba(122,59,240,0.18); +} +:root[data-theme=neon] .btn.primary { + background: linear-gradient(120deg, #00e5ff, #b34dff); + border: none; color: #06000f; + box-shadow: 0 0 16px rgba(0,229,255,0.5); +} +:root[data-theme=neon] h1, :root[data-theme=neon] .brand { + text-shadow: 0 0 18px rgba(0,229,255,0.45); +} + +/* ---- TERMINAL — green phosphor CRT, mono everything, scanlines ---- */ +:root[data-theme=terminal] { + --bg:#000a02; --surface:#04140a; --surface2:#06200f; --border:#1f8f3f; + --text:#41ff7a; --muted:#1fb04f; --accent:#7dff9f; --accent-contrast:#021006; + --ok:#7dff9f; --warn:#d7ff3f; --err:#ff5d5d; --radius:0px; + --ink-shadow: 0 0 10px rgba(65,255,122,0.4); + --ink-shadow-lift: 0 0 18px rgba(65,255,122,0.55); + --dither: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4'%3E%3Crect width='1' height='1' x='0' y='0' fill='%237dff9f'/%3E%3Crect width='1' height='1' x='2' y='2' fill='%237dff9f'/%3E%3C/svg%3E"); + --dither-dense: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='2' height='2'%3E%3Crect width='1' height='1' x='0' y='0' fill='%237dff9f'/%3E%3C/svg%3E"); + --font-display: "Spline Sans Mono", ui-monospace, monospace; + --font-body: "Spline Sans Mono", ui-monospace, monospace; +} +:root[data-theme=terminal] body { + background: + repeating-linear-gradient(0deg, rgba(0,0,0,0) 0 2px, rgba(0,40,15,0.55) 2px 3px), + radial-gradient(120% 90% at 50% 0%, #03240f 0%, #000a02 70%); + background-attachment: fixed; + text-shadow: 0 0 6px rgba(65,255,122,0.35); +} +:root[data-theme=terminal] h1 { text-transform: uppercase; letter-spacing: 0.1em; } + +/* ---- BLUEPRINT — technical drawing, graph paper, cyan ink ---- */ +:root[data-theme=blueprint] { + --bg:#082a4d; --surface:#0c3460; --surface2:#114576; --border:#6fb8ff; + --text:#eaf4ff; --muted:#9cc6f0; --accent:#ffd24a; --accent-contrast:#082a4d; + --ok:#7fffd4; --warn:#ffd24a; --err:#ff9a8a; --radius:0px; + --ink-shadow: 4px 4px 0 rgba(0,0,0,0.3); + --ink-shadow-lift: 6px 6px 0 rgba(0,0,0,0.4); + --dither: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4'%3E%3Crect width='1' height='1' x='0' y='0' fill='%236fb8ff'/%3E%3Crect width='1' height='1' x='2' y='2' fill='%236fb8ff'/%3E%3C/svg%3E"); + --dither-dense: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='2' height='2'%3E%3Crect width='1' height='1' x='0' y='0' fill='%236fb8ff'/%3E%3C/svg%3E"); +} +:root[data-theme=blueprint] body { + background: + repeating-linear-gradient(0deg, transparent 0 23px, rgba(111,184,255,0.16) 23px 24px), + repeating-linear-gradient(90deg, transparent 0 23px, rgba(111,184,255,0.16) 23px 24px), + #082a4d; + background-attachment: fixed; +} +:root[data-theme=blueprint] .card, +:root[data-theme=blueprint] .kanban-col { + background: rgba(12,52,96,0.82); + border: 1px solid var(--border); +} + +/* ---- SUNSET — warm vaporwave gradient, soft pastel ink ---- */ +:root[data-theme=sunset] { + --bg:#2a1138; --surface:#3a1a4e; --surface2:#4d2363; --border:#ff8ec7; + --text:#fff0fb; --muted:#e0a9d6; --accent:#ffb347; --accent-contrast:#2a1138; + --ok:#7ef0c4; --warn:#ffd76a; --err:#ff6b8e; --radius:12px; + --ink-shadow: 0 8px 24px rgba(255,107,142,0.25); + --ink-shadow-lift: 0 12px 34px rgba(255,142,199,0.35); + --dither: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4'%3E%3Crect width='1' height='1' x='0' y='0' fill='%23ffb347'/%3E%3Crect width='1' height='1' x='2' y='2' fill='%23ff8ec7'/%3E%3C/svg%3E"); + --dither-dense: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='2' height='2'%3E%3Crect width='1' height='1' x='0' y='0' fill='%23ff8ec7'/%3E%3C/svg%3E"); +} +:root[data-theme=sunset] body { + background: + linear-gradient(180deg, #ff6b8e 0%, #b14d9c 26%, #5e2a72 55%, #2a1138 100%); + background-attachment: fixed; +} +:root[data-theme=sunset] .card, +:root[data-theme=sunset] .kanban-col { + background: rgba(58,26,78,0.7); + backdrop-filter: blur(6px); + border: 1px solid color-mix(in srgb, var(--border) 60%, transparent); +} +:root[data-theme=sunset] .btn.primary { + background: linear-gradient(120deg, #ffb347, #ff6b8e); + border: none; color: #2a1138; +} + /* ---- base ---- */ * { box-sizing: border-box; } /* author display rules (flex etc.) must never defeat the hidden attribute */ @@ -100,7 +207,7 @@ h1 { font-size: 2.1rem; } h1::after { content: ""; display: block; - width: 88px; height: 8px; + width: 100%; height: 8px; margin-top: 10px; background-image: var(--dither-dense); border-bottom: 2px solid var(--accent); @@ -527,6 +634,26 @@ table.list tbody tr:nth-child(even) { background: color-mix(in srgb, var(--surfa letter-spacing: 0.16em; color: var(--muted); } +/* Project (customer) label on bounty cards */ +.card-project { + font-family: var(--font-mono); + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--accent); + margin: 6px 0 0; +} +/* My Tasks: stack status columns vertically so each task gets full width */ +.kanban.mt { grid-template-columns: 1fr; } +.kanban.mt .kanban-col { min-height: 0; } +/* Card action rows: wrap and right-align so buttons never overflow the card */ +.kanban .card .spread { flex-wrap: wrap; } +.kanban .card [data-actions] { + display: flex; + gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; +} /* ---- notification bell ---- */ .bell-badge { diff --git a/web/static/js/board.js b/web/static/js/board.js index 8eea802..8fe8b6d 100644 --- a/web/static/js/board.js +++ b/web/static/js/board.js @@ -5,6 +5,7 @@ const grid = document.getElementById('board-grid'); const errorBox = document.getElementById('error'); let meId = ''; let tasks = []; +const customerNames = new Map(); const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', @@ -25,6 +26,7 @@ async function load() { const qs = new URLSearchParams(f).toString(); const res = await api('GET', '/api/v1/board?' + qs); tasks = res.tasks; + (res.customers || []).forEach((c) => customerNames.set(c.id, c.name)); const sel = document.getElementById('f-customer'); if (sel.options.length === 1) { res.customers.forEach((c) => sel.add(new Option(c.name, c.id))); @@ -58,6 +60,7 @@ function renderCard(t) { ◈ ${t.bounty} ${ageBadge(t)} + ${customerNames.has(t.customerId) ? `

${esc(customerNames.get(t.customerId))}

` : ''}

${esc(t.title)}

${esc((t.description || '').slice(0, 180))}

${(t.acceptanceCriteria || []).length} acceptance criteria

diff --git a/web/static/js/nav.js b/web/static/js/nav.js index cac1191..39208d1 100644 --- a/web/static/js/nav.js +++ b/web/static/js/nav.js @@ -16,9 +16,12 @@ const themeBtn = document.getElementById('nav-theme'); if (themeBtn) { themeBtn.addEventListener('click', async () => { const el = document.documentElement; - const next = el.dataset.theme === 'dark' ? 'light' : 'dark'; + const themes = (el.dataset.themes || 'light,dark').split(','); + const cur = themes.indexOf(el.dataset.theme); + const next = themes[(cur + 1) % themes.length]; el.dataset.theme = next; try { localStorage.setItem('theme', next); } catch (e) { /* ignore */ } + toast('Theme: ' + next); if (document.body.dataset.loggedIn === '1') { try { await api('PATCH', '/api/v1/profile', { settings: { theme: next } }); diff --git a/web/static/js/reviews.js b/web/static/js/reviews.js index 41f063d..9a49ed4 100644 --- a/web/static/js/reviews.js +++ b/web/static/js/reviews.js @@ -26,7 +26,7 @@ async function load() { return card; })); if (!res.tasks.length) { - host.innerHTML = '

Nothing waiting for review. 🎉

'; + host.innerHTML = '

Nothing waiting for review.

'; } } catch (e) { errorBox.textContent = e.message; } } diff --git a/web/static/js/theme.js b/web/static/js/theme.js index 9f8f07b..bc40b62 100644 --- a/web/static/js/theme.js +++ b/web/static/js/theme.js @@ -5,7 +5,9 @@ var el = document.documentElement; var saved = null; try { saved = localStorage.getItem('theme'); } catch (e) { /* private mode */ } + var THEMES = ['light', 'dark', 'neon', 'terminal', 'blueprint', 'sunset']; var theme = saved || el.dataset.defaultTheme || 'light'; - if (theme !== 'light' && theme !== 'dark') theme = 'light'; + if (THEMES.indexOf(theme) === -1) theme = 'light'; el.dataset.theme = theme; + el.dataset.themes = THEMES.join(','); })(); diff --git a/web/templates/profile.html b/web/templates/profile.html index cd9fc9d..5786117 100644 --- a/web/templates/profile.html +++ b/web/templates/profile.html @@ -58,7 +58,11 @@