panel-redesign: the component kit - panel.css, partials, shell, kit page
Stage 0, steps 3-5, in one commit: the three were built together (the kit
page is what exercises the partials and the shell) and do not split into
states that build on their own. Written by Sonnet, reviewed by Opus.
- static/panel.css: shared/brand.css + panel/theme.css, rule for rule.
wordmark.svg and perf-edge.svg beside it; the stamp is logo.svg.
- templates/components.html: every partial of the plan's Three layers,
with its typed input in components.go. Wrappers are open/close pairs
(box_open/box_close, body_open/body_close, record_open/record_close).
Rich text is a Text value built from escaped runs - no field is
trusted HTML. status_tag and wbr_at are template functions; copy_field
is a partial whose button panel.js wires through data-copy.
- templates/layout.html: the shell as build.py draws it, the menu paths
written out literally, visibility from a typed Shell (role, feature
flags, the user's reach); the signed-out screen as a second define.
Not in the 2.0 menu: Inbound Log / Queue / Quarantine / Filter lists
and Preflight.
- GET /server/components (global role, 404 otherwise): the mockup
components page rebuilt from fixtures (kit.go) by the real templates.
- The old panel steps aside unchanged: panel.css -> legacy.css,
layout.html -> layout_legacy.html; a page uses the new layout iff it
is off legacy_pages.txt.
- legacy_pages.txt: @kit removed - the kit is under the guards for good.
Differences from the mockup components page, for the owner:
- the head button is Help, not "Cover sheet"; two "Example" links are
dropped (no fixed URL), two point at /outbound/domains and /login;
- the page sits in the Server group, so the sibling strip shows;
- Sign out is a POST form whose button carries navbar-item: same size
and type as the links, but without Bulma's hover tint;
- a head's actions are always wrapped in div.buttons.
Evidence
- docs/assets/panel-redesign/evidence/components.png: 1440 px wide, full
height, headless Edge. Taken from the real templates served locally,
NOT from the container (no Docker on this machine); the page reads
only its own fixtures and embedded assets.
- panel/audit.js on the kit page, 1440 and 1280:
{"pages":1,"widths":[1440,1280],"textsChecked":516,"problems":[]}
- go test ./internal/web/... (with @kit removed)
ok github.com/mixeme/selfpost/internal/web
ok github.com/mixeme/selfpost/internal/web/auth
ok github.com/mixeme/selfpost/internal/web/handlers
ok github.com/mixeme/selfpost/internal/web/validate
ok github.com/mixeme/selfpost/internal/web/view
TestRoutesFollowNavigation stays skipped behind @routes (stage 1).
Roadmap 10/27.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+61
-20
@@ -26,7 +26,9 @@ type Engine struct {
|
||||
}
|
||||
|
||||
// pageFiles maps a logical page name to its template files. Every page
|
||||
// composes with layout.html; pages that embed a polling fragment
|
||||
// composes with a layout — layout_legacy.html for the pages still in the old
|
||||
// design, layout.html and components.html for those that have been rebuilt
|
||||
// from the component kit (kitPages); pages that embed a polling fragment
|
||||
// (architecture.md § Panel HTTP surface) list that fragment's file too, so the
|
||||
// same {{define}} block renders both the initial page and the fragment's own
|
||||
// refresh responses identically. Pages sharing a block of markup (the
|
||||
@@ -55,6 +57,17 @@ var pageFiles = map[string][]string{
|
||||
"system_log": {"templates/system_log.html", "templates/system_log_body.html"},
|
||||
"status": {"templates/status.html", "templates/status_body.html"},
|
||||
"help": {"templates/help.html"},
|
||||
"components": {"templates/kit.html"},
|
||||
}
|
||||
|
||||
// kitPages are the pages built from the component kit: they are rendered by
|
||||
// layout.html with the partials of components.html and load panel.css. Every
|
||||
// other page is still the old design — layout_legacy.html and legacy.css — and
|
||||
// is listed in legacy_pages.txt; a page is in exactly one of the two, and
|
||||
// restyling one means moving it from the list to here in the same commit.
|
||||
// pageFiles stays a plain map of file lists because the guard tests read it.
|
||||
var kitPages = map[string]bool{
|
||||
"components": true,
|
||||
}
|
||||
|
||||
// fragmentFiles maps a fragment name (also its {{define}} block name) to its
|
||||
@@ -74,8 +87,14 @@ func New(version string) (*Engine, error) {
|
||||
version: version,
|
||||
}
|
||||
for name, files := range pageFiles {
|
||||
patterns := append([]string{"templates/layout.html", "templates/help_drawer.html"}, files...)
|
||||
tmpl, err := template.New("layout.html").Funcs(templateFuncs()).ParseFS(assetsFS, patterns...)
|
||||
// A page parses with the layout of its design. The template is named
|
||||
// after the layout file so that its content is the layout's.
|
||||
layout, shared := "layout_legacy.html", []string{"templates/help_drawer.html"}
|
||||
if kitPages[name] {
|
||||
layout, shared = "layout.html", []string{"templates/components.html"}
|
||||
}
|
||||
patterns := append([]string{"templates/" + layout}, append(shared, files...)...)
|
||||
tmpl, err := template.New(layout).Funcs(templateFuncs()).ParseFS(assetsFS, patterns...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse template %s: %w", name, err)
|
||||
}
|
||||
@@ -124,6 +143,11 @@ func templateFuncs() template.FuncMap {
|
||||
"back": func(href, label string) map[string]string {
|
||||
return map[string]string{"Href": href, "Label": label}
|
||||
},
|
||||
// status_tag and wbr_at are the adapters of the component kit
|
||||
// (components.go): the one mapping of a status to Bulma's tag
|
||||
// classes, and the break opportunity after the @ of an address.
|
||||
"status_tag": StatusTag,
|
||||
"wbr_at": wbrAt,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,31 +162,48 @@ func (e *Engine) Pages() map[string]*template.Template {
|
||||
return e.pages
|
||||
}
|
||||
|
||||
// Render writes a page using the base layout. Rendering to a buffer first means
|
||||
// a template error yields a clean 500 instead of a half-written page.
|
||||
// Render writes a page using its layout. Rendering to a buffer first means a
|
||||
// template error yields a clean 500 instead of a half-written page.
|
||||
func (e *Engine) Render(w http.ResponseWriter, status int, page string, data any) {
|
||||
tmpl, ok := e.pages[page]
|
||||
if !ok {
|
||||
http.Error(w, "template not found", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// The layout's navigation compares .Active against each item, so the key
|
||||
// must exist on every authenticated page. Defaulting it here keeps a page
|
||||
// that forgets it from failing to render — it simply highlights nothing.
|
||||
// Footer fields (.Version, .Copyright, .SourceURL) are the same on every
|
||||
// page, so no handler should have to pass them.
|
||||
if m, ok := data.(map[string]any); ok {
|
||||
if _, has := m["Active"]; !has {
|
||||
m["Active"] = ""
|
||||
var (
|
||||
buf bytes.Buffer
|
||||
layout string
|
||||
frame = data
|
||||
)
|
||||
if kitPages[page] {
|
||||
// A page of the component kit: the shell is typed data derived from the
|
||||
// page's Meta (components.go), and the page's own data travels beside it.
|
||||
// With no user there is no navigation — the signed-out screen.
|
||||
layout = "layout.html"
|
||||
sh := e.shell(metaOf(data), legal.CopyrightLine, legal.SourceURL)
|
||||
if sh.User == "" {
|
||||
layout = "layout_signed_out"
|
||||
}
|
||||
frame = Frame{Shell: sh, Page: data}
|
||||
} else {
|
||||
layout = "layout_legacy.html"
|
||||
// The layout's navigation compares .Active against each item, so the key
|
||||
// must exist on every authenticated page. Defaulting it here keeps a page
|
||||
// that forgets it from failing to render — it simply highlights nothing.
|
||||
// Footer fields (.Version, .Copyright, .SourceURL) are the same on every
|
||||
// page, so no handler should have to pass them.
|
||||
if m, ok := data.(map[string]any); ok {
|
||||
if _, has := m["Active"]; !has {
|
||||
m["Active"] = ""
|
||||
}
|
||||
m["Version"] = e.version
|
||||
m["Copyright"] = legal.CopyrightLine
|
||||
m["SourceURL"] = legal.SourceURL
|
||||
m["InboundEnabled"] = e.inboundEnabled
|
||||
m["DMARCEnabled"] = e.dmarcEnabled
|
||||
}
|
||||
m["Version"] = e.version
|
||||
m["Copyright"] = legal.CopyrightLine
|
||||
m["SourceURL"] = legal.SourceURL
|
||||
m["InboundEnabled"] = e.inboundEnabled
|
||||
m["DMARCEnabled"] = e.dmarcEnabled
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.ExecuteTemplate(&buf, "layout.html", data); err != nil {
|
||||
if err := tmpl.ExecuteTemplate(&buf, layout, frame); err != nil {
|
||||
log.Printf("panel: render %s: %v", page, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user