Skip to content

Commit 676d949

Browse files
committed
feat: Sprint W-0.5 — Visual customization, bug fixes, and docs
- Fix event listener leak in sidebar_tree.js (N listeners after N HTMX swaps) - Fix touch listener cleanup in sidebar_reorg.js on deactivate - Replace ES2020 optional chaining with ES5 null checks in sidebar_tree.js - Add per-campaign brand name/logo (model, service, handler, API routes) - Add topbar color/gradient customization (model, service, handler, API routes) - Add Appearance editor tab in Customization Hub with live preview - Create appearance_editor.js widget (debounced auto-save, mode switching) - Add EscapeJSONString helper in layouts/data.go - Wire brand name, logo, and topbar style into template context - Update sidebar brand display and topbar inline styling in app.templ - Update documentation for entities and campaigns plugins https://claude.ai/code/session_01QJLkgjQDu5qohzJKGV4hj9
1 parent 3d70662 commit 676d949

16 files changed

Lines changed: 768 additions & 39 deletions

File tree

.ai/status.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,14 @@
88
<!-- ====================================================================== -->
99

1010
## Last Updated
11-
2026-03-10 -- **Sprint W-0: Nav Menu Reorg Mode (IN PROGRESS).**
11+
2026-03-11 -- **Sprint W-0.5: Visual Customization (IN PROGRESS).**
1212

13-
24. Starting W-0: Sidebar reorg mode toggle button, inline category reorder, conditional entity drag-and-drop, touch support for mobile. Primarily frontend work — existing APIs (`PUT /sidebar-config`, `PUT /entities/:eid/reorder`) already support reordering. Also updating stale relations widget documentation.
13+
25. Starting W-0.5: Per-campaign brand name/logo, topbar color/gradient/image customization, visual editor Appearance tab in Customization Hub. Also fixing 3 bugs from W-0 (event listener leak in sidebar_tree.js, touch listener cleanup in sidebar_reorg.js, ES2020 optional chaining compat) and updating stale entity/campaign documentation.
14+
15+
### Previous Update
16+
2026-03-11 -- **Sprint W-0: Nav Menu Reorg Mode (COMPLETE).**
17+
18+
24. W-0 complete: Sidebar reorg mode toggle button (Owner-only, grip icon next to "Categories" header), inline category drag-to-reorder with visibility toggles, conditional entity drag-and-drop (only active in reorg mode), touch D&D support for mobile, `data-entity-type-id` on category links, auto-exit on navigation. New file `sidebar_reorg.js`. Modified `sidebar_tree.js` to gate D&D behind `data-reorg-active`. Relations `.ai.md` updated with graph visualization features.
1419

1520
### Previous Update
1621
2026-03-10 -- **Sprint V-4: Enhanced Graph View & Cover Images (COMPLETE).**

.ai/todo.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -207,8 +207,8 @@ _Quick capture, backlinks, enhanced graph, editor power-ups. See `.ai/obsidian-n
207207

208208
### Phase W: Polish, Ecosystem & Delight
209209

210-
- [~] **Sprint W-0: Nav Menu Reorg Mode** — Small icon button near Dashboard in sidebar. Click to enter reorg mode for current level (categories or entities). Category level: drag to reorder category icons. Entity level: drag to reorder, create folders/submenus. Click again to exit reorg mode. Must work on desktop, tablet, and mobile. Button is context-aware: on base nav, reorders categories; drilled into a category, reorders entities.
211-
- [ ] **Sprint W-0.5: Owner Visual Customization** — Change "Chronicle" brand name per-campaign with optional image/logo. Top bar color/gradient/animation/background image (responsive). Visual customization editor with faux site outline (editable boxes for colors/backgrounds). Appearance-only, not layout editing. Future: per-addon "feature in use" indicators showing which entities/pages use each feature, which widgets are available, click associations to navigate. Disabled feature banner with "offline" sticker instead of complete removal.
210+
- [x] **Sprint W-0: Nav Menu Reorg Mode** — Small icon button near Dashboard in sidebar. Click to enter reorg mode for current level (categories or entities). Category level: drag to reorder category icons. Entity level: drag to reorder, create folders/submenus. Click again to exit reorg mode. Must work on desktop, tablet, and mobile. Button is context-aware: on base nav, reorders categories; drilled into a category, reorders entities.
211+
- [~] **Sprint W-0.5: Owner Visual Customization** — Change "Chronicle" brand name per-campaign with optional image/logo. Top bar color/gradient/animation/background image (responsive). Visual customization editor with faux site outline (editable boxes for colors/backgrounds). Appearance-only, not layout editing. Future: per-addon "feature in use" indicators showing which entities/pages use each feature, which widgets are available, click associations to navigate. Disabled feature banner with "offline" sticker instead of complete removal.
212212
- [ ] **Sprint W-1: Command Palette & Saved Filters** — Ctrl+Shift+P action palette with fuzzy search. Saved entity list filter presets as sidebar links in `saved_filters` table.
213213
- [ ] **Sprint W-2: Map Drawing Tools, Regions & Measurement** — Leaflet.Draw integration (freehand, polygons, circles, rectangles, text). Uses existing `map_drawings` table. Per-drawing visibility, color/opacity. Also: map regions (polygon fills/strokes/labels), measurement/distance tool, map embed layout block for entity pages.
214214
- [ ] **Sprint W-2.5: Nested / Linked Maps** — Click marker to open sub-map. `linked_map_id` on markers. Breadcrumb navigation between map levels. Competitive gap vs World Anvil/LegendKeeper.

internal/app/routes.go

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1298,9 +1298,26 @@ func (a *App) RegisterRoutes() {
12981298
ctx = layouts.SetCampaignID(ctx, cc.Campaign.ID)
12991299
ctx = layouts.SetCampaignName(ctx, cc.Campaign.Name)
13001300

1301-
// Accent color from campaign settings.
1302-
if accentColor := cc.Campaign.ParseSettings().AccentColor; accentColor != "" {
1303-
ctx = layouts.SetAccentColor(ctx, accentColor)
1301+
// Campaign visual customization from settings.
1302+
campaignSettings := cc.Campaign.ParseSettings()
1303+
if campaignSettings.AccentColor != "" {
1304+
ctx = layouts.SetAccentColor(ctx, campaignSettings.AccentColor)
1305+
}
1306+
if campaignSettings.BrandName != "" {
1307+
ctx = layouts.SetBrandName(ctx, campaignSettings.BrandName)
1308+
}
1309+
if campaignSettings.BrandLogo != "" {
1310+
ctx = layouts.SetBrandLogo(ctx, campaignSettings.BrandLogo)
1311+
}
1312+
if campaignSettings.TopbarStyle != nil {
1313+
ctx = layouts.SetTopbarStyle(ctx, &layouts.TopbarStyleData{
1314+
Mode: campaignSettings.TopbarStyle.Mode,
1315+
Color: campaignSettings.TopbarStyle.Color,
1316+
GradientFrom: campaignSettings.TopbarStyle.GradientFrom,
1317+
GradientTo: campaignSettings.TopbarStyle.GradientTo,
1318+
GradientDir: campaignSettings.TopbarStyle.GradientDir,
1319+
ImagePath: campaignSettings.TopbarStyle.ImagePath,
1320+
})
13041321
}
13051322

13061323
// "View as player" override: when an owner has the toggle active,

internal/plugins/campaigns/.ai.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,12 @@ Plugin (Core -- always enabled)
6464
| POST | /campaigns/:id/transfer | Transfer | Owner | Initiate transfer |
6565
| GET | /campaigns/:id/accept-transfer | AcceptTransfer | Auth only | Accept (token) |
6666
| POST | /campaigns/:id/cancel-transfer | CancelTransfer | Owner | Cancel transfer |
67+
| GET | /campaigns/:id/customize | Customize | Owner | Customization Hub |
68+
| GET | /campaigns/:id/plugins | PluginHub | Player | Features page |
69+
| GET | /campaigns/:id/sidebar-config | GetSidebarConfig | Player | Get sidebar config |
70+
| PUT | /campaigns/:id/sidebar-config | UpdateSidebarConfig | Owner | Save sidebar order/visibility |
71+
| PUT | /campaigns/:id/accent-color | UpdateAccentColorAPI | Owner | Update accent color |
72+
| POST | /campaigns/:id/backdrop | UploadBackdrop | Owner | Upload backdrop image |
6773

6874
## Business Rules
6975

internal/plugins/campaigns/branding.templ

Lines changed: 151 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1-
// branding.templ renders the backdrop upload section and accent color picker
2-
// for campaign settings.
1+
// branding.templ renders the backdrop upload section, accent color picker,
2+
// and appearance customization tab for campaign settings.
33

44
package campaigns
55

6-
import "fmt"
6+
import (
7+
"fmt"
8+
"github.com/keyxmakerx/chronicle/internal/templates/layouts"
9+
)
710

811
// BackdropUploadSection renders the backdrop image upload/remove UI fragment.
912
// Used as both the initial render and the HTMX swap target after upload/remove.
@@ -123,3 +126,148 @@ templ AccentColorPicker(campaignID string, currentColor string, csrfToken string
123126
}
124127
</div>
125128
}
129+
130+
// appearanceTab renders the visual customization editor in the Customization Hub.
131+
// Contains: faux site outline, brand name/logo, accent color, and topbar styling.
132+
templ appearanceTab(cc *CampaignContext, csrfToken string) {
133+
<h2 class="text-base font-semibold text-fg mb-1">Appearance</h2>
134+
<p class="text-xs text-fg-secondary mb-6">Customize the visual style of your campaign.</p>
135+
136+
<div
137+
data-widget="appearance-editor"
138+
data-campaign-id={ cc.Campaign.ID }
139+
data-csrf={ csrfToken }
140+
data-brand-name={ cc.Campaign.ParseSettings().BrandName }
141+
data-brand-logo={ cc.Campaign.ParseSettings().BrandLogo }
142+
data-accent-color={ cc.Campaign.ParseSettings().AccentColor }
143+
data-topbar-style={ topbarStyleJSON(cc.Campaign.ParseSettings().TopbarStyle) }
144+
>
145+
<!-- Faux site outline preview -->
146+
<div class="rounded-lg border border-edge overflow-hidden mb-6 bg-bg-secondary">
147+
<div class="flex">
148+
<!-- Faux sidebar -->
149+
<div class="w-12 bg-[#1a1c23] shrink-0 flex flex-col items-center py-3 gap-2">
150+
<div class="w-5 h-5 rounded bg-gray-600"></div>
151+
<div class="w-5 h-5 rounded bg-gray-700"></div>
152+
<div class="w-5 h-5 rounded bg-gray-700"></div>
153+
</div>
154+
<!-- Faux main area -->
155+
<div class="flex-1 flex flex-col min-h-[160px]">
156+
<!-- Faux topbar (reflects live customization) -->
157+
<div
158+
id="appearance-preview-topbar"
159+
class="h-8 bg-surface border-b border-edge flex items-center px-3"
160+
>
161+
<span class="text-[10px] text-fg-muted font-medium" id="appearance-preview-brand">
162+
if cc.Campaign.ParseSettings().BrandName != "" {
163+
{ cc.Campaign.ParseSettings().BrandName }
164+
} else {
165+
{ cc.Campaign.Name }
166+
}
167+
</span>
168+
</div>
169+
<!-- Faux content -->
170+
<div class="flex-1 p-4">
171+
<div class="w-3/4 h-2 bg-edge rounded mb-2"></div>
172+
<div class="w-1/2 h-2 bg-edge rounded mb-4"></div>
173+
<div class="grid grid-cols-2 gap-2">
174+
<div class="h-8 bg-edge rounded"></div>
175+
<div class="h-8 bg-edge rounded"></div>
176+
</div>
177+
</div>
178+
</div>
179+
</div>
180+
</div>
181+
182+
<!-- Brand name -->
183+
<div class="card p-4 mb-4">
184+
<h3 class="text-sm font-semibold text-fg mb-2">
185+
<i class="fa-solid fa-tag text-xs mr-1.5 text-fg-muted"></i> Brand Name
186+
</h3>
187+
<p class="text-xs text-fg-secondary mb-3">Replace the campaign name in the sidebar with a custom brand name.</p>
188+
<div class="flex gap-2">
189+
<input
190+
type="text"
191+
id="appearance-brand-name"
192+
class="input text-sm flex-1"
193+
placeholder={ cc.Campaign.Name }
194+
maxlength="40"
195+
value={ cc.Campaign.ParseSettings().BrandName }
196+
/>
197+
<button
198+
type="button"
199+
id="appearance-brand-clear"
200+
class="btn-secondary text-xs px-3"
201+
title="Clear brand name"
202+
>
203+
<i class="fa-solid fa-xmark"></i>
204+
</button>
205+
</div>
206+
</div>
207+
208+
<!-- Accent color -->
209+
<div class="card p-4 mb-4">
210+
<h3 class="text-sm font-semibold text-fg mb-2">
211+
<i class="fa-solid fa-droplet text-xs mr-1.5 text-fg-muted"></i> Accent Color
212+
</h3>
213+
<p class="text-xs text-fg-secondary mb-3">Theme color used for buttons, links, and highlights.</p>
214+
@AccentColorPicker(cc.Campaign.ID, cc.Campaign.ParseSettings().AccentColor, csrfToken)
215+
</div>
216+
217+
<!-- Topbar style -->
218+
<div class="card p-4 mb-4">
219+
<h3 class="text-sm font-semibold text-fg mb-2">
220+
<i class="fa-solid fa-paintbrush text-xs mr-1.5 text-fg-muted"></i> Top Bar Style
221+
</h3>
222+
<p class="text-xs text-fg-secondary mb-3">Customize the top navigation bar appearance.</p>
223+
<div class="flex gap-2 mb-3" id="appearance-topbar-mode">
224+
<button type="button" data-mode="" class="btn-secondary text-xs px-3 py-1.5">Default</button>
225+
<button type="button" data-mode="solid" class="btn-secondary text-xs px-3 py-1.5">Solid Color</button>
226+
<button type="button" data-mode="gradient" class="btn-secondary text-xs px-3 py-1.5">Gradient</button>
227+
</div>
228+
<!-- Solid color options -->
229+
<div id="appearance-topbar-solid" class="hidden space-y-2">
230+
<label class="text-xs text-fg-secondary">Color</label>
231+
<input type="color" id="appearance-topbar-color" class="w-10 h-8 rounded border border-edge cursor-pointer"/>
232+
</div>
233+
<!-- Gradient options -->
234+
<div id="appearance-topbar-gradient" class="hidden space-y-2">
235+
<div class="flex gap-3">
236+
<div>
237+
<label class="text-xs text-fg-secondary block mb-1">From</label>
238+
<input type="color" id="appearance-topbar-gradient-from" class="w-10 h-8 rounded border border-edge cursor-pointer"/>
239+
</div>
240+
<div>
241+
<label class="text-xs text-fg-secondary block mb-1">To</label>
242+
<input type="color" id="appearance-topbar-gradient-to" class="w-10 h-8 rounded border border-edge cursor-pointer"/>
243+
</div>
244+
<div class="flex-1">
245+
<label class="text-xs text-fg-secondary block mb-1">Direction</label>
246+
<select id="appearance-topbar-gradient-dir" class="input text-xs py-1.5">
247+
<option value="to-r">Left to Right</option>
248+
<option value="to-br">Top-Left to Bottom-Right</option>
249+
<option value="to-b">Top to Bottom</option>
250+
</select>
251+
</div>
252+
</div>
253+
</div>
254+
</div>
255+
</div>
256+
}
257+
258+
// topbarStyleJSON serializes a TopbarStyle to a JSON data attribute value.
259+
func topbarStyleJSON(style *TopbarStyle) string {
260+
if style == nil {
261+
return "{}"
262+
}
263+
// Manual construction to avoid importing encoding/json in templ file.
264+
return fmt.Sprintf(
265+
`{"mode":"%s","color":"%s","gradient_from":"%s","gradient_to":"%s","gradient_dir":"%s","image_path":"%s"}`,
266+
layouts.EscapeJSONString(style.Mode),
267+
layouts.EscapeJSONString(style.Color),
268+
layouts.EscapeJSONString(style.GradientFrom),
269+
layouts.EscapeJSONString(style.GradientTo),
270+
layouts.EscapeJSONString(style.GradientDir),
271+
layouts.EscapeJSONString(style.ImagePath),
272+
)
273+
}

internal/plugins/campaigns/customize.templ

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,13 @@ templ CustomizePage(cc *CampaignContext, entityTypes []SettingsEntityType, csrfT
8383
>
8484
<i class="fa-solid fa-file-lines mr-1.5 text-xs"></i> Content Templates
8585
</button>
86+
<button
87+
class="px-4 py-2.5 text-sm font-medium border-b-2 transition-colors whitespace-nowrap"
88+
:class="tab === 'appearance' ? 'text-accent border-accent' : 'text-fg-secondary border-transparent hover:text-fg hover:border-edge'"
89+
@click="tab = 'appearance'"
90+
>
91+
<i class="fa-solid fa-palette mr-1.5 text-xs"></i> Appearance
92+
</button>
8693
<button
8794
class="px-4 py-2.5 text-sm font-medium border-b-2 transition-colors whitespace-nowrap"
8895
:class="tab === 'extensions' ? 'text-accent border-accent' : 'text-fg-secondary border-transparent hover:text-fg hover:border-edge'"
@@ -125,6 +132,13 @@ templ CustomizePage(cc *CampaignContext, entityTypes []SettingsEntityType, csrfT
125132
</div>
126133
</div>
127134

135+
<!-- Appearance tab: branding, topbar, and accent color customization -->
136+
<div x-show="tab === 'appearance'" x-cloak class="h-full overflow-y-auto">
137+
<div class="max-w-3xl mx-auto px-6 py-4">
138+
@appearanceTab(cc, csrfToken)
139+
</div>
140+
</div>
141+
128142
<!-- Extensions tab: enable/disable campaign addons + attributes editor -->
129143
<div x-show="tab === 'extensions'" x-cloak class="h-full overflow-y-auto">
130144
<div class="max-w-3xl mx-auto px-6 py-4">

internal/plugins/campaigns/handler.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,71 @@ func (h *Handler) UpdateAccentColorAPI(c echo.Context) error {
466466
return c.Redirect(http.StatusSeeOther, "/campaigns/"+cc.Campaign.ID+"/settings")
467467
}
468468

469+
// UpdateBrandingAPI handles PUT /campaigns/:id/branding. Sets the campaign's
470+
// custom brand name and optional logo path.
471+
func (h *Handler) UpdateBrandingAPI(c echo.Context) error {
472+
cc := GetCampaignContext(c)
473+
if cc == nil {
474+
return apperror.NewMissingContext()
475+
}
476+
477+
if cc.MemberRole < RoleOwner {
478+
return apperror.NewForbidden("only campaign owners can change branding")
479+
}
480+
481+
var req struct {
482+
BrandName string `json:"brand_name"`
483+
BrandLogo string `json:"brand_logo"`
484+
}
485+
if err := c.Bind(&req); err != nil {
486+
return apperror.NewBadRequest("invalid request body")
487+
}
488+
489+
if err := h.service.UpdateBranding(c.Request().Context(), cc.Campaign.ID, req.BrandName, req.BrandLogo); err != nil {
490+
return err
491+
}
492+
493+
h.logAudit(c, cc.Campaign.ID, "campaign.branding.updated", map[string]any{
494+
"brand_name": req.BrandName,
495+
})
496+
497+
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
498+
}
499+
500+
// UpdateTopbarStyleAPI handles PUT /campaigns/:id/topbar-style. Sets the
501+
// campaign's topbar visual customization (solid color, gradient, or image).
502+
func (h *Handler) UpdateTopbarStyleAPI(c echo.Context) error {
503+
cc := GetCampaignContext(c)
504+
if cc == nil {
505+
return apperror.NewMissingContext()
506+
}
507+
508+
if cc.MemberRole < RoleOwner {
509+
return apperror.NewForbidden("only campaign owners can change topbar style")
510+
}
511+
512+
var style TopbarStyle
513+
if err := c.Bind(&style); err != nil {
514+
return apperror.NewBadRequest("invalid request body")
515+
}
516+
517+
// Empty mode means clear/reset.
518+
var stylePtr *TopbarStyle
519+
if style.Mode != "" {
520+
stylePtr = &style
521+
}
522+
523+
if err := h.service.UpdateTopbarStyle(c.Request().Context(), cc.Campaign.ID, stylePtr); err != nil {
524+
return err
525+
}
526+
527+
h.logAudit(c, cc.Campaign.ID, "campaign.topbar_style.updated", map[string]any{
528+
"mode": style.Mode,
529+
})
530+
531+
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
532+
}
533+
469534
// UpdateDmGrantsAPI handles PUT /campaigns/:id/dm-grants. Sets which users
470535
// are granted visibility of dm_only content (co-DM privileges).
471536
func (h *Handler) UpdateDmGrantsAPI(c echo.Context) error {

internal/plugins/campaigns/model.go

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -280,8 +280,21 @@ func (c *Campaign) ParseDashboardLayout() *DashboardLayout {
280280
// CampaignSettings holds campaign-level configuration stored as JSON in
281281
// the campaigns.settings column. Accent color, display preferences, etc.
282282
type CampaignSettings struct {
283-
AccentColor string `json:"accent_color,omitempty"` // Hex color, e.g. "#6366f1".
284-
DmGrantIDs []string `json:"dm_grant_ids,omitempty"` // User IDs granted dm_only visibility.
283+
AccentColor string `json:"accent_color,omitempty"` // Hex color, e.g. "#6366f1".
284+
DmGrantIDs []string `json:"dm_grant_ids,omitempty"` // User IDs granted dm_only visibility.
285+
BrandName string `json:"brand_name,omitempty"` // Custom sidebar brand name (replaces campaign name).
286+
BrandLogo string `json:"brand_logo,omitempty"` // Media path for brand logo image.
287+
TopbarStyle *TopbarStyle `json:"topbar_style,omitempty"` // Topbar visual customization.
288+
}
289+
290+
// TopbarStyle configures the visual appearance of the campaign's top navigation bar.
291+
type TopbarStyle struct {
292+
Mode string `json:"mode"` // "solid", "gradient", or "image".
293+
Color string `json:"color,omitempty"` // Hex color for solid mode.
294+
GradientFrom string `json:"gradient_from,omitempty"` // Start color for gradient mode.
295+
GradientTo string `json:"gradient_to,omitempty"` // End color for gradient mode.
296+
GradientDir string `json:"gradient_dir,omitempty"` // Direction: "to-r", "to-br", etc.
297+
ImagePath string `json:"image_path,omitempty"` // Media path for background image.
285298
}
286299

287300
// ParseSettings parses the campaign's settings JSON into a CampaignSettings

internal/plugins/campaigns/routes.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ func RegisterRoutes(e *echo.Echo, h *Handler, svc CampaignService, authSvc auth.
6161
cg.POST("/backdrop", h.UploadBackdrop, RequireRole(RoleOwner))
6262
cg.DELETE("/backdrop", h.RemoveBackdrop, RequireRole(RoleOwner))
6363
cg.PUT("/accent-color", h.UpdateAccentColorAPI, RequireRole(RoleOwner))
64+
cg.PUT("/branding", h.UpdateBrandingAPI, RequireRole(RoleOwner))
65+
cg.PUT("/topbar-style", h.UpdateTopbarStyleAPI, RequireRole(RoleOwner))
6466

6567
// DM grants (Owner only).
6668
cg.GET("/dm-grants", h.GetDmGrantsAPI, RequireRole(RoleOwner))

0 commit comments

Comments
 (0)