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:
mix
2026-10-09 17:46:13 +03:00
co-authored by Claude Sonnet 5.5
parent 92678c4157
commit f861d78114
26 changed files with 3273 additions and 1001 deletions
+26 -18
View File
@@ -49,8 +49,11 @@ func TestLayoutShowsTheVersionOnlyWhenSignedIn(t *testing.T) {
}
rendered := 0
for name := range engine.Pages() {
if kitPages[name] {
continue // the kit layout has its own tests (components_test.go)
}
var buf bytes.Buffer
err := engine.Page(name).ExecuteTemplate(&buf, "layout.html", map[string]any{
err := engine.Page(name).ExecuteTemplate(&buf, "layout_legacy.html", map[string]any{
"Title": "t", "User": "admin", "Active": "", "Version": "9.9.9-test",
"Copyright": "Copyright © 2026 Mikhail Yenuchenko",
"SourceURL": "https://github.com/mixeme/selfpost",
@@ -79,7 +82,7 @@ func TestLayoutShowsTheVersionOnlyWhenSignedIn(t *testing.T) {
// Signed out (login, setup) the version must not be advertised, but the
// Appropriate Legal Notices must still be present.
var buf bytes.Buffer
if err := engine.Page("login").ExecuteTemplate(&buf, "layout.html", map[string]any{
if err := engine.Page("login").ExecuteTemplate(&buf, "layout_legacy.html", map[string]any{
"Title": "t", "Active": "", "Version": "9.9.9-test",
"Copyright": "Copyright © 2026 Mikhail Yenuchenko",
"SourceURL": "https://github.com/mixeme/selfpost",
@@ -210,7 +213,7 @@ func TestNavShowsInboundWhenEnabled(t *testing.T) {
}
// Whether a page takes the whole column or the reading measure is declared by
// the page's own "wide" block (see layout.html), which the layout stamps into
// the page's own "wide" block (see layout_legacy.html), which the layout stamps into
// <main>'s class list. A page that loses the block does not fail to render — it
// silently comes back at the measure, with its table squeezed into two thirds
// of the column — so the set is asserted here, in both directions.
@@ -225,6 +228,9 @@ func TestOnlyThePagesMadeOfDataDeclareThemselvesWide(t *testing.T) {
"inbound": true, "inbound_domain": true, "dmarc": true,
}
for name, page := range engine.Pages() {
if kitPages[name] {
continue // "wide" belongs to the legacy layout; the kit's width comes from its columns
}
var buf bytes.Buffer
if err := page.ExecuteTemplate(&buf, "wide", nil); err != nil {
t.Fatalf("execute the wide block of %s: %v", name, err)
@@ -396,17 +402,19 @@ func TestNoTemplateUsesInlineScriptOrStyle(t *testing.T) {
// server actually serves, so a typo in a /static path is a blocked request,
// not a 404 in the page's own colours.
func TestLayoutReferencesOnlyEmbeddedAssets(t *testing.T) {
body, err := fs.ReadFile(assetsFS, "templates/layout.html")
if err != nil {
t.Fatalf("read layout: %v", err)
}
refs := regexp.MustCompile(`(?:src|href)="/static/([^"]+)"`).FindAllStringSubmatch(string(body), -1)
if len(refs) == 0 {
t.Fatal("the layout references no static assets at all")
}
for _, m := range refs {
if _, err := fs.Stat(assetsFS, "static/"+m[1]); err != nil {
t.Errorf("layout references /static/%s, which is not embedded: %v", m[1], err)
for _, file := range []string{"templates/layout_legacy.html", "templates/layout.html"} {
body, err := fs.ReadFile(assetsFS, file)
if err != nil {
t.Fatalf("read layout: %v", err)
}
refs := regexp.MustCompile(`(?:src|href)="/static/([^"]+)"`).FindAllStringSubmatch(string(body), -1)
if len(refs) == 0 {
t.Fatalf("%s references no static assets at all", file)
}
for _, m := range refs {
if _, err := fs.Stat(assetsFS, "static/"+m[1]); err != nil {
t.Errorf("%s references /static/%s, which is not embedded: %v", file, m[1], err)
}
}
}
}
@@ -485,7 +493,7 @@ func renderStatusPage(t *testing.T, data map[string]any) string {
t.Fatalf("New: %v", err)
}
var buf bytes.Buffer
if err := engine.Page("status").ExecuteTemplate(&buf, "layout.html", data); err != nil {
if err := engine.Page("status").ExecuteTemplate(&buf, "layout_legacy.html", data); err != nil {
t.Fatalf("execute status page: %v", err)
}
return buf.String()
@@ -575,7 +583,7 @@ func TestMailQueuePageRendersRetryPolicy(t *testing.T) {
t.Fatalf("New: %v", err)
}
var buf bytes.Buffer
if err := engine.Page("mail_queue").ExecuteTemplate(&buf, "layout.html", map[string]any{
if err := engine.Page("mail_queue").ExecuteTemplate(&buf, "layout_legacy.html", map[string]any{
"Title": "t", "User": "admin", "Active": "mail_queue", "Version": "test",
"Copyright": "Copyright © 2026 Mikhail Yenuchenko",
"SourceURL": "https://github.com/mixeme/selfpost",
@@ -608,7 +616,7 @@ func TestAuthenticatedLayoutIncludesHelpDrawer(t *testing.T) {
t.Fatalf("New: %v", err)
}
var buf bytes.Buffer
if err := engine.Page("help").ExecuteTemplate(&buf, "layout.html", map[string]any{
if err := engine.Page("help").ExecuteTemplate(&buf, "layout_legacy.html", map[string]any{
"Title": "t", "User": "admin", "Active": "help", "IsGlobal": true,
}); err != nil {
t.Fatalf("execute help layout: %v", err)
@@ -630,7 +638,7 @@ func TestLoginPageOmitsHelpDrawer(t *testing.T) {
t.Fatalf("New: %v", err)
}
var buf bytes.Buffer
if err := engine.Page("login").ExecuteTemplate(&buf, "layout.html", map[string]any{
if err := engine.Page("login").ExecuteTemplate(&buf, "layout_legacy.html", map[string]any{
"Title": "t", "Active": "",
}); err != nil {
t.Fatalf("execute login: %v", err)