aboutsummaryrefslogtreecommitdiffstats
path: root/internal
diff options
context:
space:
mode:
authorgrm <grm@eyesin.space>2026-09-17 20:15:14 +0300
committergrm <grm@eyesin.space>2026-09-17 20:15:14 +0300
commit69912c4ac04ada3286c09400ce523d5d3ab4cfb4 (patch)
tree409c59ce65e1612d587aa698bb5e25c2cf7bfc0b /internal
parent616aa3a934951260d5daa81ab3d0f93fbc94ebee (diff)
downloadblogspace-69912c4ac04ada3286c09400ce523d5d3ab4cfb4.tar.gz
blogspace-69912c4ac04ada3286c09400ce523d5d3ab4cfb4.tar.bz2
blogspace-69912c4ac04ada3286c09400ce523d5d3ab4cfb4.zip
Add a header rule beside the title or logo, and plain menu links
gizmolab's header is a logo with a striped rule filling the rest of the row, and a menu of plain underlined links under it; nothing in the Design tab could draw either. The rule (line, dots or stripes) is a theme option drawn in the header text colour beside the title and logo modules — on both sides when the header is centred — as table cells, so it is as tall as the brand and old browsers lay it out the same; it is hidden on phones so a long title can wrap. The menu's link style gains "plain, underlined" next to bold and uppercase. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A
Diffstat (limited to 'internal')
-rw-r--r--internal/i18n/el.go6
-rw-r--r--internal/web/static/blog.css17
-rw-r--r--internal/web/templates/dashboard/design.html8
-rw-r--r--internal/web/templates/layouts/blog.html12
-rw-r--r--internal/web/theme.go22
-rw-r--r--internal/web/web_test.go54
6 files changed, 106 insertions, 13 deletions
diff --git a/internal/i18n/el.go b/internal/i18n/el.go
index 1fd6ceb..911eaf0 100644
--- a/internal/i18n/el.go
+++ b/internal/i18n/el.go
@@ -382,6 +382,7 @@ var el = map[string]string{
"menu in the header": "μενού στην κεφαλίδα",
"Link style": "Στυλ συνδέσμων",
"Uppercase, spaced": "Κεφαλαία, αραιά",
+ "Plain, underlined": "Απλοί, υπογραμμισμένοι",
"Show the date on posts": "Εμφάνιση ημερομηνίας στις δημοσιεύσεις",
"Date format": "Μορφή ημερομηνίας",
"Logo & site icon": "Λογότυπο & εικονίδιο ιστολογίου",
@@ -471,6 +472,11 @@ var el = map[string]string{
"Title size": "Μέγεθος τίτλου",
"Banner image": "Εικόνα-πανό",
"Banner height": "Ύψος πανό",
+ "Rule beside the title": "Γραμμή δίπλα στον τίτλο",
+ "Line": "Γραμμή",
+ "Dots": "Τελείες",
+ "Stripes": "Ρίγες",
+ "The rule is drawn in the text colour next to the title and logo modules, filling the rest of the row — on both sides when the header is centred. Wide screens only.": "Η γραμμή σχεδιάζεται στο χρώμα κειμένου δίπλα στα δομοστοιχεία τίτλου και λογότυπου, γεμίζοντας το υπόλοιπο της σειράς — και από τις δύο πλευρές όταν η κεφαλίδα είναι κεντραρισμένη. Μόνο σε πλατιές οθόνες.",
"Shown across the top of the header whenever one is chosen.": "Εμφανίζεται κατά μήκος της κορυφής της κεφαλίδας όποτε έχει επιλεγεί κάποια.",
"Stack the title, a logo, the menu and a search box in the order you like.": "Βάλτε τον τίτλο, ένα λογότυπο, το μενού και ένα πλαίσιο αναζήτησης με όποια σειρά θέλετε.",
"A page or link filled in here is added when you save.": "Μια σελίδα ή ένας σύνδεσμος που συμπληρώνετε εδώ προστίθεται όταν αποθηκεύσετε.",
diff --git a/internal/web/static/blog.css b/internal/web/static/blog.css
index eabb314..10568fa 100644
--- a/internal/web/static/blog.css
+++ b/internal/web/static/blog.css
@@ -7,6 +7,22 @@ img { max-width: 100%; height: auto; }
.header-text { padding: 1.4em 1em; }
.header-logo { padding: 1em; }
.header-logo img.logo { display: inline-block; width: auto; }
+/* header rule: a line beside the title or logo (both sides when centred) filling
+ the rest of the row. Table cells, so the rule is as tall as the brand and old
+ browsers lay it out the same; the stripes are a gradient in currentColor (the
+ header text colour). Wide screens only: on a phone a long title must wrap. */
+.ruled { display: table; width: 100%; }
+.ruled > .brand, .ruled > .header-rule { display: table-cell; vertical-align: middle; }
+.ruled > .brand { white-space: nowrap; padding: 0 1em; }
+.ruled > .brand:first-child { padding-left: 0; }
+.ruled > .brand:last-child { padding-right: 0; }
+.ruled > .brand a, .ruled > .brand img.logo { display: block; } /* no line box under the logo: the rule is exactly as tall as it */
+.ruled > .brand img.logo { max-width: none; } /* a percentage max-width is 0 inside an auto-width cell */
+.ruled > .header-rule { width: 100%; }
+.ruled > .header-rule:first-child, .ruled > .header-rule:first-child ~ .header-rule { width: 50%; } /* centred: two rules share the remainder */
+.rule-line > .header-rule::before, .rule-dots > .header-rule::before { content: ""; display: block; border-top: 1px solid; }
+.rule-dots > .header-rule::before { border-top: 3px dotted; }
+.rule-stripes > .header-rule { background: repeating-linear-gradient(90deg, currentColor, currentColor 2px, transparent 2px, transparent 10px); }
.site-title { margin: 0; font-size: 2em; line-height: 1.2; }
.site-title a { text-decoration: none; }
.tagline { margin: 0.3em 0 0; opacity: 0.85; }
@@ -126,6 +142,7 @@ img { max-width: 100%; height: auto; }
.content { padding: 1em; }
.post-thumb { width: 40%; margin-left: 1em; }
.site-title { font-size: 1.5em; }
+ .ruled > .header-rule { display: none; } .ruled > .brand { white-space: normal; }
.cols { display: block; }
.col-left, .col-right { padding: 0; margin-top: 1.5em; }
.col-left { order: 0; }
diff --git a/internal/web/templates/dashboard/design.html b/internal/web/templates/dashboard/design.html
index c22a35e..5fd3fbe 100644
--- a/internal/web/templates/dashboard/design.html
+++ b/internal/web/templates/dashboard/design.html
@@ -101,7 +101,14 @@
<option value="normal"{{if eq $t.TitleSize "normal"}} selected{{end}}>{{t "Normal"}}</option>
<option value="large"{{if eq $t.TitleSize "large"}} selected{{end}}>{{t "Large"}}</option>
</select></label>
+ <label>{{t "Rule beside the title"}}<br><select name="header_rule">
+ <option value="none"{{if eq $t.HeaderRule "none"}} selected{{end}}>{{t "None"}}</option>
+ <option value="line"{{if eq $t.HeaderRule "line"}} selected{{end}}>{{t "Line"}}</option>
+ <option value="dots"{{if eq $t.HeaderRule "dots"}} selected{{end}}>{{t "Dots"}}</option>
+ <option value="stripes"{{if eq $t.HeaderRule "stripes"}} selected{{end}}>{{t "Stripes"}}</option>
+ </select></label>
</div>
+ <p class="muted small">{{t "The rule is drawn in the text colour next to the title and logo modules, filling the rest of the row — on both sides when the header is centred. Wide screens only."}}</p>
{{template "imagepick" (dict "name" "header_image" "current" $t.HeaderImage "sub" $sub "images" .Data.images "names" .Data.imageNames "label" (t "Banner image") "hint" (t "Shown across the top of the header whenever one is chosen."))}}
<div class="row"><label>{{t "Banner height"}}<br><select name="header_image_height">
<option value="short"{{if eq $t.HeaderImageHeight "short"}} selected{{end}}>{{t "Short"}}</option>
@@ -143,6 +150,7 @@
<label>{{t "Link style"}}<br><select name="nav_style">
<option value="normal"{{if eq $t.NavStyle "normal"}} selected{{end}}>{{t "Normal"}}</option>
<option value="uppercase"{{if eq $t.NavStyle "uppercase"}} selected{{end}}>{{t "Uppercase, spaced"}}</option>
+ <option value="plain"{{if eq $t.NavStyle "plain"}} selected{{end}}>{{t "Plain, underlined"}}</option>
</select></label>
<label>{{t "Background"}}<br><input type="color" name="nav_bg" value="{{$t.NavBg}}"></label>
<label>{{t "Text"}}<br><input type="color" name="nav_text" value="{{$t.NavText}}"></label>
diff --git a/internal/web/templates/layouts/blog.html b/internal/web/templates/layouts/blog.html
index 76475b9..ba9ea24 100644
--- a/internal/web/templates/layouts/blog.html
+++ b/internal/web/templates/layouts/blog.html
@@ -53,12 +53,12 @@
{{/* One layout module. .m is the store.Module, .root the page data; the same
kind renders differently in the header (big blocks) and elsewhere (boxes). */}}
-{{define "module"}}{{$m := .m}}{{$r := .root}}{{$t := $r.Data.theme}}
-{{- if eq $m.Kind "title"}}<div class="wrap header-text">
+{{define "module"}}{{$m := .m}}{{$r := .root}}{{$t := $r.Data.theme}}{{$rc := $t.RuleClass}}
+{{- if eq $m.Kind "title"}}<div class="wrap header-text{{with $rc}} {{.}}{{end}}">{{template "rule" (dict "before" true "rc" $rc "theme" $t)}}<div class="brand">
<h1 class="site-title"><a href="/">{{$r.Blog.Title}}</a></h1>
{{if $r.Blog.Tagline}}<p class="tagline">{{$r.Blog.Tagline}}</p>{{end}}
-</div>
-{{- else if eq $m.Kind "logo"}}{{if $t.Logo}}{{if eq $m.Area "header"}}<div class="wrap header-logo"><a href="/"><img class="logo" src="/media/{{$t.Logo}}" alt="{{$r.Blog.Title}}"></a></div>
+</div>{{template "rule" (dict "before" false "rc" $rc "theme" $t)}}</div>
+{{- else if eq $m.Kind "logo"}}{{if $t.Logo}}{{if eq $m.Area "header"}}<div class="wrap header-logo{{with $rc}} {{.}}{{end}}">{{template "rule" (dict "before" true "rc" $rc "theme" $t)}}<div class="brand"><a href="/"><img class="logo" src="/media/{{$t.Logo}}" alt="{{$r.Blog.Title}}"></a></div>{{template "rule" (dict "before" false "rc" $rc "theme" $t)}}</div>
{{else}}<div class="mod mod-logo"><a href="/"><img class="logo" src="/media/{{$t.Logo}}" alt="{{$r.Blog.Title}}"></a></div>{{end}}{{end}}
{{- else if eq $m.Kind "menu"}}{{if eq $m.Area "header"}}<div class="site-nav"><div class="wrap nav-inner">{{if $r.Data.layout.HeaderSearch}}{{template "searchform" $r}}{{end}}{{template "menulinks" $r}}</div></div>
{{else}}<div class="mod site-nav mod-menu">{{template "modtitle" $m}}<div class="nav-inner">{{template "menulinks" $r}}</div></div>{{end}}
@@ -95,6 +95,10 @@
say "Search" over and over. .Data.q only exists on the search page. */}}
{{define "searchform"}}<form method="get" action="/search" class="search-form"><input type="search" name="q"{{with .Data.q}} value="{{.}}"{{end}} placeholder="{{t "Search…"}}" aria-label="{{t "Search…"}}" size="16"></form>{{end}}
+{{/* The header rule beside a title or logo block: after it, and before it
+ too when the header is centred. Nothing without a rule (.rc is empty). */}}
+{{define "rule"}}{{if .rc}}{{if or (not .before) (eq .theme.HeaderAlign "center")}}<div class="header-rule"></div>{{end}}{{end}}{{end}}
+
{{define "modtitle"}}{{if .Title}}<h3 class="mod-title">{{.Title}}</h3>{{end}}{{end}}
{{define "menulinks"}}{{range .Data.menu}}<a href="{{.Href}}"{{if and .PageID $.Data.page (eq $.Data.page.ID (deref64 .PageID))}} class="active"{{end}}>{{.Text}}</a>
diff --git a/internal/web/theme.go b/internal/web/theme.go
index df2c613..c0c0729 100644
--- a/internal/web/theme.go
+++ b/internal/web/theme.go
@@ -34,10 +34,11 @@ type Theme struct {
HeaderAlign string `json:"header_align"` // left | center
HeaderBg string `json:"header_bg"`
HeaderText string `json:"header_text"`
- TitleSize string `json:"title_size"` // normal | small | large (the site title in the header)
+ TitleSize string `json:"title_size"` // normal | small | large (the site title in the header)
+ HeaderRule string `json:"header_rule"` // none | line | dots | stripes, drawn beside the title/logo modules in the header
NavAlign string `json:"nav_align"` // left | center | right (header menu)
- NavStyle string `json:"nav_style"` // normal | uppercase
+ NavStyle string `json:"nav_style"` // normal | uppercase | plain (regular weight, underlined)
NavBg string `json:"nav_bg"`
NavText string `json:"nav_text"`
NavHover string `json:"nav_hover"` // "" = same as NavText
@@ -91,7 +92,7 @@ func DefaultTheme() Theme {
ContentBg: "#ffffff", TextColor: "#222222", LinkColor: "#1a5fb4", LinkUnderline: "always",
ContentStyle: "card", ContentPadding: "normal",
Font: "sans", HeadingFont: "same", FontSize: "normal",
- HeaderImageHeight: "medium", HeaderAlign: "left", HeaderBg: "#2f3a4a", HeaderText: "#ffffff", TitleSize: "normal",
+ HeaderImageHeight: "medium", HeaderAlign: "left", HeaderBg: "#2f3a4a", HeaderText: "#ffffff", TitleSize: "normal", HeaderRule: "none",
NavAlign: "left", NavStyle: "normal", NavBg: "#ffffff", NavText: "#222222",
ShowDates: true, DateFormat: "long", PostsPerPage: postsPerPageChoices[0], ListStyle: "full", PostImage: "top",
FooterBg: "#2f3a4a", FooterColor: "#d0d5dc", FooterAlign: "center",
@@ -170,8 +171,9 @@ func (t *Theme) normalize() {
t.HeaderBg = color(t.HeaderBg, d.HeaderBg)
t.HeaderText = color(t.HeaderText, d.HeaderText)
t.TitleSize = pick(t.TitleSize, "normal", "small", "large")
+ t.HeaderRule = pick(t.HeaderRule, "none", "line", "dots", "stripes")
t.NavAlign = pick(t.NavAlign, "left", "center", "right")
- t.NavStyle = pick(t.NavStyle, "normal", "uppercase")
+ t.NavStyle = pick(t.NavStyle, "normal", "uppercase", "plain")
t.NavBg = color(t.NavBg, d.NavBg)
t.NavText = color(t.NavText, d.NavText)
t.NavHover = optColor(t.NavHover)
@@ -233,6 +235,7 @@ func ThemeFromForm(cur Theme, f url.Values) Theme {
t.HeaderBg = get("header_bg")
t.HeaderText = get("header_text")
t.TitleSize = get("title_size")
+ t.HeaderRule = get("header_rule")
t.NavAlign = get("nav_align")
t.NavStyle = get("nav_style")
t.NavBg = get("nav_bg")
@@ -413,6 +416,15 @@ func (t Theme) TitleSizeCSS() string {
return ""
}
+// RuleClass is what a title or logo block in the header carries when a rule
+// is drawn beside it; blog.css does the drawing (normalize clamped the value).
+func (t Theme) RuleClass() string {
+ if t.HeaderRule == "none" {
+ return ""
+ }
+ return "ruled rule-" + t.HeaderRule
+}
+
func (t Theme) HeaderImageMaxHeight() string {
switch t.HeaderImageHeight {
case "short":
@@ -442,7 +454,7 @@ a:hover {{"{"}}{{if .LinkHover}} color:{{.LinkHover}};{{end}}{{if eq .LinkUnderl
.header-image img { max-height:{{.HeaderImageMaxHeight}}; }
.site-nav { background:{{.NavBg}}; }
.nav-inner { text-align:{{.NavAlign}}; }
-.site-nav a { color:{{.NavText}};{{if eq .NavStyle "uppercase"}} text-transform:uppercase; letter-spacing:0.05em; font-size:0.9em;{{end}} }
+.site-nav a { color:{{.NavText}};{{if eq .NavStyle "uppercase"}} text-transform:uppercase; letter-spacing:0.05em; font-size:0.9em;{{else if eq .NavStyle "plain"}} font-weight:normal; text-decoration:underline;{{end}} }
{{- if .NavHover}}
.site-nav a:hover { color:{{.NavHover}}; }
{{- end}}
diff --git a/internal/web/web_test.go b/internal/web/web_test.go
index fcd1e41..914c991 100644
--- a/internal/web/web_test.go
+++ b/internal/web/web_test.go
@@ -82,8 +82,8 @@ func TestRootRoutePrecedence(t *testing.T) {
}
func TestThemeNormalizeAndCSS(t *testing.T) {
- th := ParseTheme([]byte(`{"bg_color":"red;}body{display:none","font":"comic","bg_image":"../etc","logo":"../etc","link_hover":"blue","heading_font":"wingdings","favicon":"x","logo_size":"huge","left_width":5,"right_width":90}`))
- if th.BgColor != DefaultTheme().BgColor || th.Font != "sans" || th.BgImage != "" || th.Logo != "" || th.LinkHover != "" || th.HeadingFont != "same" || th.Favicon != "" || th.LogoSize != "medium" {
+ th := ParseTheme([]byte(`{"bg_color":"red;}body{display:none","font":"comic","bg_image":"../etc","logo":"../etc","link_hover":"blue","heading_font":"wingdings","favicon":"x","logo_size":"huge","left_width":5,"right_width":90,"header_rule":"neon\"><script>"}`))
+ if th.BgColor != DefaultTheme().BgColor || th.Font != "sans" || th.BgImage != "" || th.Logo != "" || th.LinkHover != "" || th.HeadingFont != "same" || th.Favicon != "" || th.LogoSize != "medium" || th.HeaderRule != "none" || th.RuleClass() != "" {
t.Errorf("normalize: %+v", th)
}
if th.LeftWidth != 15 || th.RightWidth != 40 {
@@ -101,9 +101,9 @@ func TestThemeNormalizeAndCSS(t *testing.T) {
}
f := url.Values{"bg_color": {"#ABCDEF"}, "bg_image": {"not-a-uuid"}, "logo_size": {"large"},
"link_hover": {"#ff0000"}, "nav_hover": {"#00ff00"}, "nav_hover_custom": {"on"}, "nav_style": {"uppercase"}, "nav_align": {"center"},
- "link_underline": {"hover"}, "heading_font": {"serif"}, "content_style": {"flat"}, "content_padding": {"roomy"}, "date_format": {"iso"}}
+ "link_underline": {"hover"}, "heading_font": {"serif"}, "content_style": {"flat"}, "content_padding": {"roomy"}, "date_format": {"iso"}, "header_rule": {"stripes"}}
th = ThemeFromForm(DefaultTheme(), f)
- if th.BgColor != "#abcdef" || th.BgImage != "" || th.LogoSize != "large" {
+ if th.BgColor != "#abcdef" || th.BgImage != "" || th.LogoSize != "large" || th.RuleClass() != "ruled rule-stripes" {
t.Errorf("from form: %+v", th)
}
if th.LinkHover != "" || th.NavHover != "#00ff00" || th.ShowDates || th.DateFormat != "iso" {
@@ -123,6 +123,52 @@ func TestThemeNormalizeAndCSS(t *testing.T) {
if css := DefaultTheme().CSS(); strings.Contains(css, "padding:") || strings.Contains(css, "a:hover") {
t.Errorf("default css should not set padding or hover: %s", css)
}
+ // Plain menu links: regular weight and underlined, instead of blog.css's bold.
+ css = ThemeFromForm(DefaultTheme(), url.Values{"nav_style": {"plain"}}).CSS()
+ if !strings.Contains(css, ".site-nav a { color:#222222; font-weight:normal; text-decoration:underline; }") {
+ t.Errorf("plain nav css: %s", css)
+ }
+}
+
+// The header rule is drawn beside the title and logo blocks — after them, and
+// before them too when the header is centred — and not at all by default.
+func TestHeaderRule(t *testing.T) {
+ tpl, err := newTemplates(false).get("en", "blog/page.html")
+ if err != nil {
+ t.Fatal(err)
+ }
+ logo := uuid.New().String()
+ render := func(kind string, th Theme) string {
+ var b strings.Builder
+ root := view{Blog: &store.Blog{Title: "Gizmo", Tagline: "a hacklab"}, Data: map[string]any{"theme": th, "layout": Layout{}}}
+ if err := tpl.ExecuteTemplate(&b, "module", map[string]any{"m": store.Module{Area: "header", Kind: kind}, "root": root}); err != nil {
+ t.Fatal(err)
+ }
+ return b.String()
+ }
+ plain := DefaultTheme()
+ plain.Logo = logo
+ for _, kind := range []string{"title", "logo"} {
+ if out := render(kind, plain); strings.Contains(out, "header-rule") || strings.Contains(out, "ruled") {
+ t.Errorf("%s without a rule: %s", kind, out)
+ }
+ }
+ left := plain
+ left.HeaderRule = "stripes"
+ out := render("title", left)
+ if !strings.Contains(out, `class="wrap header-text ruled rule-stripes"`) || strings.Count(out, `<div class="header-rule"></div>`) != 1 || !strings.HasSuffix(strings.TrimSpace(out), `</div><div class="header-rule"></div></div>`) {
+ t.Errorf("left-aligned rule: %s", out)
+ }
+ if !strings.Contains(out, `<p class="tagline">a hacklab</p>`) {
+ t.Errorf("tagline lost: %s", out)
+ }
+ centred := left
+ centred.HeaderAlign, centred.HeaderRule = "center", "line"
+ out = render("logo", centred)
+ if !strings.Contains(out, `class="wrap header-logo ruled rule-line"`) || strings.Count(out, `<div class="header-rule"></div>`) != 2 ||
+ !strings.Contains(out, `<div class="header-rule"></div><div class="brand"><a href="/"><img class="logo" src="/media/`+logo+`"`) {
+ t.Errorf("centred rule: %s", out)
+ }
}
func TestLayoutTheme(t *testing.T) {