Skip to content

Commit 02c54d2

Browse files
sarg3ntclaude
andcommitted
fix(onboarding): welcome-flow polish from manual testing
- Sidebar shows Home when it's the only enabled gear. The status middleware was short-circuiting on box-count==0 and never injecting system gears into the nav order; now system gears load first regardless of box state, and box-scoped gears are appended only when a box exists. - /settings/gears no longer 400s with plain "No servers configured" when no boxes are registered. Renders the Home gear plus a "No boxes configured" CTA card linking to /settings/boxes/new. Inline copy switched to "box" terminology to match issue #49. - Welcome screen uses the shared ui.Toggle slider instead of a bare <input type="checkbox">. ui.Toggle gained a value parameter so it can submit a specific value when multiple toggles share a name (multi-select checkbox group). CLAUDE.md adds "Toggle switches — never use a bare <input type=checkbox>" under UI Conventions so this stays the canonical pattern. - Home empty-state icon scaled up via new HomeIconClass(class) wrapper. Sidebar HomeIcon() unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 08cb17e commit 02c54d2

8 files changed

Lines changed: 161 additions & 106 deletions

File tree

CLAUDE.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,28 @@ await showAlertDialog({
200200

201201
Existing reference usages: [user-pages/admin-user-detail.js](static/js/user-pages/admin-user-detail.js), [user-pages/profile-management.js](static/js/user-pages/profile-management.js), [haproxy_config/editor.js](static/js/haproxy_config/editor.js).
202202

203+
### Toggle switches — never use a bare `<input type="checkbox">` in templates
204+
205+
For every boolean input in a `.templ` file — feature opt-ins, settings, "enable this gear", "show all", per-row enable/disable — use the shared slider component in [internal/framework/ui/toggle.templ](gearbox/internal/framework/ui/toggle.templ):
206+
207+
```templ
208+
import "github.com/sarg3nt/gearbox/internal/framework/ui"
209+
210+
// Single toggle (no inline label — pair with your own <label for=...>)
211+
@ui.Toggle("welcome-gear-home", "gears", "home", false, false)
212+
// args: id, name, value (submitted when checked), checked, disabled
213+
214+
// Toggle + label + description, stacked horizontally
215+
@ui.ToggleWithLabel("notify-email", "notify_email", "1", "Email notifications", "Send a digest each morning", true, false)
216+
```
217+
218+
**Rules of thumb:**
219+
220+
- The underlying input is `sr-only` but real — it submits with the form and respects `checked` / `disabled`. No JS required for plain forms.
221+
- Pass `value` when multiple toggles share a `name` (e.g., a multi-select checkbox group posting as `name="gears"`). Leave empty when a single boolean field submits as the default `"on"`.
222+
- For AJAX toggles that POST on change (no enclosing form), the per-row `gear-toggle` `<button role="switch">` pattern in [gears.templ](gearbox/internal/framework/templates/pages/gears.templ) is the established alternative — but for **anything inside a `<form>`**, use `@ui.Toggle`.
223+
- Never inline `peer-checked:after:...` Tailwind salads in a new template — that's a sign you should be calling `@ui.Toggle`. Existing inline copies in `overview.templ` and `admin_user_permissions.templ` are tech debt; migrate them when you're already editing those files.
224+
203225
## Key Constraints
204226

205227
### NEVER

gearbox/internal/framework/handler/gears.go

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -38,21 +38,23 @@ func (h *Handler) GearsPage(w http.ResponseWriter, r *http.Request) {
3838
boxID = servers[0].ID
3939
}
4040

41-
if boxID == "" {
42-
http.Error(w, "No servers configured", http.StatusBadRequest)
43-
return
44-
}
45-
46-
// Ensure default gears exist for this server
47-
if err := h.db.EnsureServerGears(boxID); err != nil {
48-
h.logger.Error("Failed to ensure server gears", "error", err)
49-
}
41+
// boxID may be empty on a fresh install — the template renders an
42+
// "Add a Box" CTA in place of the per-box gear list. System-scoped
43+
// gears still render regardless.
44+
45+
var plugins []database.Gear
46+
if boxID != "" {
47+
// Ensure default gears exist for this box
48+
if err := h.db.EnsureServerGears(boxID); err != nil {
49+
h.logger.Error("Failed to ensure server gears", "error", err)
50+
}
5051

51-
plugins, err := h.db.GetGears(boxID)
52-
if err != nil {
53-
h.logger.Error("Failed to get gears", "error", err)
54-
http.Error(w, "Internal server error", http.StatusInternalServerError)
55-
return
52+
plugins, err = h.db.GetGears(boxID)
53+
if err != nil {
54+
h.logger.Error("Failed to get gears", "error", err)
55+
http.Error(w, "Internal server error", http.StatusInternalServerError)
56+
return
57+
}
5658
}
5759

5860
// Load system-scoped (box-agnostic) gears so they render alongside the

gearbox/internal/framework/handler/handler.go

Lines changed: 42 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,11 @@ func (h *Handler) getDefaultServerID() string {
186186

187187
// InjectIntegrationStatus is middleware that adds integration status and user permissions to the request context.
188188
// This enables server-side conditional rendering of navigation items based on integration status and permissions.
189+
//
190+
// System-scoped gears (Home, etc. — keyed by database.SystemServerID) are
191+
// always loaded, even when no boxes are registered. Box-scoped gears load
192+
// from the default box if one exists; otherwise they're explicitly disabled
193+
// so the sidebar stays clean during first-run.
189194
func (h *Handler) InjectIntegrationStatus(next http.Handler) http.Handler {
190195
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
191196
ctx := r.Context()
@@ -196,69 +201,51 @@ func (h *Handler) InjectIntegrationStatus(next http.Handler) http.Handler {
196201
ctx = auth.SetUserPermissions(ctx, perms)
197202
}
198203

199-
boxID := h.getDefaultServerID()
200-
if boxID != "" {
201-
// Get integrations with their enabled status and sort order
204+
status := make(map[string]bool)
205+
orderedIntegrations := make([]auth.SidebarIntegration, 0)
206+
207+
// System gears go first so they render at the head of the nav.
208+
systemGears, err := h.db.GetGears(database.SystemServerID)
209+
if err != nil {
210+
h.logger.Warn("failed to load system gears for sidebar", "error", err)
211+
}
212+
for _, sg := range systemGears {
213+
status[sg.Name] = sg.Enabled
214+
orderedIntegrations = append(orderedIntegrations, auth.SidebarIntegration{
215+
Name: sg.Name,
216+
Enabled: sg.Enabled,
217+
SortOrder: sg.SortOrder,
218+
})
219+
}
220+
221+
if boxID := h.getDefaultServerID(); boxID != "" {
202222
integrations, err := h.db.GetGears(boxID)
203223
if err != nil {
204224
h.logger.Error("failed to get integrations for sidebar", "error", err)
205-
// Continue without status - sidebar will show all items (fail open)
206-
next.ServeHTTP(w, r.WithContext(ctx))
207-
return
208-
}
209-
210-
// Merge in system-wide (box-agnostic) gears so the sidebar can show them.
211-
systemGears, err := h.db.GetGears(database.SystemServerID)
212-
if err != nil {
213-
h.logger.Warn("failed to load system gears for sidebar", "error", err)
225+
} else {
226+
for _, i := range integrations {
227+
status[i.Name] = i.Enabled
228+
orderedIntegrations = append(orderedIntegrations, auth.SidebarIntegration{
229+
Name: i.Name,
230+
Enabled: i.Enabled,
231+
SortOrder: i.SortOrder,
232+
})
233+
}
214234
}
215-
216-
// Build status map for backward compatibility
217-
status := make(map[string]bool)
218-
for _, i := range integrations {
219-
status[i.Name] = i.Enabled
220-
}
221-
for _, i := range systemGears {
222-
status[i.Name] = i.Enabled
223-
}
224-
225-
// Build ordered list for sidebar (box gears first, then system gears
226-
// at the head — Home should sit at the top of navigation when enabled).
227-
orderedIntegrations := make([]auth.SidebarIntegration, 0, len(integrations)+len(systemGears))
228-
for _, i := range systemGears {
229-
orderedIntegrations = append(orderedIntegrations, auth.SidebarIntegration{
230-
Name: i.Name,
231-
Enabled: i.Enabled,
232-
SortOrder: i.SortOrder,
233-
})
235+
} else {
236+
// No box configured — explicitly mark box-scoped gears off so
237+
// the sidebar doesn't fall back to fail-open and clutter
238+
// first-run with disabled items. System gears (Home) are
239+
// already injected above and remain visible if enabled.
240+
for _, n := range []string{"haproxy", "metrics", "logs", "services", "certificates", "traffic", "alerts", "os_updates"} {
241+
if _, present := status[n]; !present {
242+
status[n] = false
243+
}
234244
}
235-
for _, i := range integrations {
236-
orderedIntegrations = append(orderedIntegrations, auth.SidebarIntegration{
237-
Name: i.Name,
238-
Enabled: i.Enabled,
239-
SortOrder: i.SortOrder,
240-
})
241-
}
242-
243-
ctx = auth.SetGearStatus(ctx, status)
244-
ctx = auth.SetIntegrationOrder(ctx, orderedIntegrations)
245-
next.ServeHTTP(w, r.WithContext(ctx))
246-
return
247245
}
248246

249-
// No server configured - explicitly disable all gears in navigation
250-
// This provides a clean initial setup experience without gear clutter
251-
status := map[string]bool{
252-
"metrics": false,
253-
"logs": false,
254-
"services": false,
255-
"certificates": false,
256-
"traffic": false,
257-
"alerts": false,
258-
"os_updates": false,
259-
}
260247
ctx = auth.SetGearStatus(ctx, status)
261-
ctx = auth.SetIntegrationOrder(ctx, []auth.SidebarIntegration{})
248+
ctx = auth.SetIntegrationOrder(ctx, orderedIntegrations)
262249
next.ServeHTTP(w, r.WithContext(ctx))
263250
})
264251
}

gearbox/internal/framework/templates/pages/gears.templ

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -74,28 +74,57 @@ templ GearsPage(user *models.User, servers []models.BoxConfig, currentServerID s
7474
<div class="mb-6">
7575
<div class="flex items-baseline justify-between mb-2">
7676
<h3 class="text-base font-semibold text-gray-700 dark:text-gray-200">System</h3>
77-
<span class="text-xs text-gray-500 dark:text-gray-400">Applies install-wide, not tied to a server</span>
77+
<span class="text-xs text-gray-500 dark:text-gray-400">Applies install-wide, not tied to a box</span>
7878
</div>
7979
<div id="system-gears-list" class="space-y-4">
8080
for _, sg := range systemGears {
8181
@gearCard(sg, sg.ServerID)
8282
}
8383
</div>
8484
</div>
85-
<div class="mb-2">
86-
<h3 class="text-base font-semibold text-gray-700 dark:text-gray-200">
87-
if currentServerID != "" && len(servers) > 0 {
88-
{ serverNameByID(servers, currentServerID) }
89-
} else {
90-
Server gears
91-
}
92-
</h3>
93-
</div>
9485
}
9586

87+
<div class="mb-2">
88+
<h3 class="text-base font-semibold text-gray-700 dark:text-gray-200">
89+
if currentServerID != "" && len(servers) > 0 {
90+
{ serverNameByID(servers, currentServerID) }
91+
} else {
92+
Box gears
93+
}
94+
</h3>
95+
</div>
9696
<div id="gears-list" class="space-y-4">
97-
for _, integration := range integrations {
98-
@gearCard(integration, currentServerID)
97+
if len(servers) > 0 {
98+
for _, integration := range integrations {
99+
@gearCard(integration, currentServerID)
100+
}
101+
} else {
102+
<div class="bg-white dark:bg-slate-800 rounded-lg shadow p-6">
103+
<div class="flex items-start gap-4">
104+
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-blue-50 dark:bg-blue-900/30 flex items-center justify-center">
105+
<svg class="w-6 h-6 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
106+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"></path>
107+
</svg>
108+
</div>
109+
<div class="flex-1 min-w-0">
110+
<h4 class="text-base font-semibold text-gray-800 dark:text-gray-100">No boxes configured</h4>
111+
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
112+
Per-box gears (HAProxy, Metrics, Logs, Services, Certificates, Traffic, Alerts, OS Updates) need a registered box to run against. Add one to enable them.
113+
</p>
114+
<div class="mt-4">
115+
<a
116+
href="/settings/boxes/new"
117+
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
118+
>
119+
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
120+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
121+
</svg>
122+
Add a Box
123+
</a>
124+
</div>
125+
</div>
126+
</div>
127+
</div>
99128
}
100129
</div>
101130

gearbox/internal/framework/templates/pages/welcome.templ

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"github.com/sarg3nt/gearbox/internal/framework/database"
55
"github.com/sarg3nt/gearbox/internal/framework/middleware"
66
"github.com/sarg3nt/gearbox/internal/framework/models"
7+
"github.com/sarg3nt/gearbox/internal/framework/ui"
78
)
89

910
// WelcomePage is the first-run landing screen shown to an admin when nothing
@@ -99,18 +100,15 @@ templ WelcomePage(user *models.User, systemGears []database.Gear, csrfToken stri
99100

100101
<div class="space-y-3">
101102
for _, g := range systemGears {
102-
<label class="flex items-start p-4 rounded-md border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-slate-700/50 cursor-pointer">
103-
<input
104-
type="checkbox"
105-
name="gears"
106-
value={ g.Name }
107-
class="mt-1 h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500 dark:border-gray-600 dark:bg-slate-700"
108-
/>
109-
<span class="ml-3">
103+
<div class="flex items-start p-4 rounded-md border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-slate-700/50">
104+
<div class="flex items-center h-5 mt-0.5 flex-shrink-0">
105+
@ui.Toggle("welcome-gear-"+g.Name, "gears", g.Name, false, false)
106+
</div>
107+
<label for={ "welcome-gear-" + g.Name } class="ml-3 cursor-pointer flex-1">
110108
<span class="block text-sm font-medium text-gray-900 dark:text-white">{ g.DisplayName }</span>
111109
<span class="block text-sm text-gray-500 dark:text-gray-400">{ g.Description }</span>
112-
</span>
113-
</label>
110+
</label>
111+
</div>
114112
}
115113
</div>
116114

gearbox/internal/framework/ui/toggle.templ

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,23 @@
11
package ui
22

3-
// Toggle creates a switch/toggle input component
3+
// Toggle creates a switch/toggle input component.
4+
//
45
// id: unique identifier for the input
56
// name: form field name
7+
// value: form value submitted when checked (empty string → defaults to "on")
68
// checked: initial state
79
// disabled: whether the toggle is disabled
8-
templ Toggle(id string, name string, checked bool, disabled bool) {
10+
//
11+
// This is the canonical toggle for the app. Always use it for boolean form
12+
// inputs in templates instead of a bare <input type="checkbox">. See
13+
// CLAUDE.md → UI Conventions → Toggle switches.
14+
templ Toggle(id string, name string, value string, checked bool, disabled bool) {
915
<label class={ "relative inline-flex items-center", templ.KV("cursor-pointer", !disabled), templ.KV("cursor-not-allowed opacity-50", disabled) }>
1016
<input
1117
type="checkbox"
1218
id={ id }
1319
name={ name }
20+
value={ value }
1421
class="sr-only peer"
1522
checked?={ checked }
1623
disabled?={ disabled }
@@ -20,10 +27,10 @@ templ Toggle(id string, name string, checked bool, disabled bool) {
2027
}
2128

2229
// ToggleWithLabel creates a toggle with an accompanying label
23-
templ ToggleWithLabel(id string, name string, label string, description string, checked bool, disabled bool) {
30+
templ ToggleWithLabel(id string, name string, value string, label string, description string, checked bool, disabled bool) {
2431
<div class="flex items-start">
2532
<div class="flex items-center h-5">
26-
@Toggle(id, name, checked, disabled)
33+
@Toggle(id, name, value, checked, disabled)
2734
</div>
2835
<div class="ml-3">
2936
<label for={ id } class={ "text-sm font-medium text-gray-900 dark:text-gray-100", templ.KV("cursor-pointer", !disabled), templ.KV("cursor-not-allowed", disabled) }>

gearbox/internal/gears/home/icons.go

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,33 @@ package home
22

33
import (
44
"context"
5+
"fmt"
56
"io"
67

78
"github.com/a-h/templ"
89
)
910

10-
// HomeIcon returns the SVG icon component for the Home gear sidebar entry.
11-
//
12-
// Path is the canonical Heroicons v1 "home" outline: a roof with both
11+
// homeIconPath is the canonical Heroicons v1 "home" outline: a roof with both
1312
// walls reaching it, a small door cut in the bottom-center. The previous
14-
// path was a malformed variant whose right wall stopped halfway up,
15-
// leaving a visual gap (issue: "the right wall is missing"). It also
16-
// drew a stray vertical stroke down the middle.
13+
// path was a malformed variant whose right wall stopped halfway up, leaving
14+
// a visual gap (issue: "the right wall is missing"). It also drew a stray
15+
// vertical stroke down the middle.
16+
const homeIconPath = `M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6`
17+
18+
// HomeIcon returns the home SVG sized for the sidebar entry (w-5 h-5).
19+
// For other sizes use HomeIconClass.
1720
func HomeIcon() templ.Component {
21+
return HomeIconClass("w-5 h-5")
22+
}
23+
24+
// HomeIconClass returns the home SVG with caller-supplied Tailwind classes
25+
// (e.g. "w-16 h-16") so the same icon can be reused at larger sizes
26+
// without duplicating the SVG markup.
27+
func HomeIconClass(class string) templ.Component {
1828
return templ.ComponentFunc(func(ctx context.Context, w io.Writer) error {
19-
_, err := io.WriteString(w, `<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
20-
<path stroke-linecap="round" stroke-linejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
21-
</svg>`)
29+
_, err := fmt.Fprintf(w, `<svg class=%q fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">`+
30+
`<path stroke-linecap="round" stroke-linejoin="round" d=%q></path>`+
31+
`</svg>`, class, homeIconPath)
2232
return err
2333
})
2434
}

gearbox/internal/gears/home/pages.templ

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -249,8 +249,8 @@ templ IndexPage(d IndexPageData) {
249249
<div class="px-4 sm:px-6 lg:px-8 py-6">
250250
if len(d.Tiles) == 0 {
251251
<div id="home-empty-state" class="border-2 border-dashed border-gray-300 dark:border-slate-700 rounded-xl py-16 text-center">
252-
<div class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500 mb-3">
253-
@HomeIcon()
252+
<div class="mx-auto text-gray-400 dark:text-gray-500 mb-4 flex justify-center">
253+
@HomeIconClass("w-20 h-20")
254254
</div>
255255
<h2 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Your dashboard is empty</h2>
256256
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400 max-w-md mx-auto">

0 commit comments

Comments
 (0)