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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
+128
-1
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
<span class="badge accent" style="font-size:1rem">◈ ${t.bounty}</span>
|
||||
<span>${ageBadge(t)}</span>
|
||||
</div>
|
||||
${customerNames.has(t.customerId) ? `<p class="muted card-project">${esc(customerNames.get(t.customerId))}</p>` : ''}
|
||||
<h3 class="mt"><a href="/tasks/${t.id}">${esc(t.title)}</a></h3>
|
||||
<p class="muted">${esc((t.description || '').slice(0, 180))}</p>
|
||||
<p class="muted">${(t.acceptanceCriteria || []).length} acceptance criteria</p>
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -26,7 +26,7 @@ async function load() {
|
||||
return card;
|
||||
}));
|
||||
if (!res.tasks.length) {
|
||||
host.innerHTML = '<p class="muted">Nothing waiting for review. 🎉</p>';
|
||||
host.innerHTML = '<p class="muted">Nothing waiting for review.</p>';
|
||||
}
|
||||
} catch (e) { errorBox.textContent = e.message; }
|
||||
}
|
||||
|
||||
@@ -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(',');
|
||||
})();
|
||||
|
||||
@@ -58,7 +58,11 @@
|
||||
<label for="p-theme">Theme</label>
|
||||
<select id="p-theme">
|
||||
<option value="light" {{if eq .User.Settings.Theme "light"}}selected{{end}}>Light (Y2K paper)</option>
|
||||
<option value="dark" {{if eq .User.Settings.Theme "dark"}}selected{{end}}>Dark</option>
|
||||
<option value="dark" {{if eq .User.Settings.Theme "dark"}}selected{{end}}>Dark (Y2K ink)</option>
|
||||
<option value="neon" {{if eq .User.Settings.Theme "neon"}}selected{{end}}>Neon (cyber gradient)</option>
|
||||
<option value="terminal" {{if eq .User.Settings.Theme "terminal"}}selected{{end}}>Terminal (green CRT)</option>
|
||||
<option value="blueprint" {{if eq .User.Settings.Theme "blueprint"}}selected{{end}}>Blueprint (graph paper)</option>
|
||||
<option value="sunset" {{if eq .User.Settings.Theme "sunset"}}selected{{end}}>Sunset (vaporwave)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user