diff options
| -rw-r--r-- | AGENTS.md | 9 | ||||
| -rw-r--r-- | README.md | 4 | ||||
| -rw-r--r-- | internal/i18n/el.go | 6 | ||||
| -rw-r--r-- | internal/web/static/blog.css | 17 | ||||
| -rw-r--r-- | internal/web/templates/dashboard/design.html | 8 | ||||
| -rw-r--r-- | internal/web/templates/layouts/blog.html | 12 | ||||
| -rw-r--r-- | internal/web/theme.go | 22 | ||||
| -rw-r--r-- | internal/web/web_test.go | 54 |
8 files changed, 118 insertions, 14 deletions
@@ -176,6 +176,15 @@ internal/web/ server.go (host router, middleware, render helpers) Summaries) and `PostImage` (where the post page shows the featured image: `top` | `bottom` | `list` = listings only). `TitleSize` is emitted inside the wide-screen media query so blog.css's phone rule keeps winning. + `HeaderRule` (`none` | `line` | `dots` | `stripes`) is a decoration beside + the title and logo modules in the header, filling the rest of the row (both + sides when `HeaderAlign` is `center`): `Theme.RuleClass` turns it into the + `ruled rule-<kind>` classes on the block, the `rule` template adds the + `.header-rule` cells and blog.css draws them — table cells (so the rule is + as tall as the brand and old browsers agree) in `currentColor`, i.e. the + header text colour; hidden on phones so a long title can wrap. `NavStyle` + `plain` is regular weight + underline for the menu links (blog.css makes + them bold). Optional colours (`LinkHover`, `NavHover`) are `""` = inherit and come from a colour input paired with a `<name>_custom` checkbox, shown/hidden with CSS only (`.hoverpick > input:not(:checked) ~ …`). Image fields use the @@ -31,7 +31,9 @@ Server-rendered HTML, no JavaScript required, works on old browsers and phones. case; several words match in order with anything in between), custom HTML, footer text, RSS link, site map. Column widths are percentages; on phones the columns stack under the posts; the header and footer can follow the main column's edges - for a centred, single-column look. The **menu** mixes blog pages and outside links. Posts per + for a centred, single-column look, and a rule (line, dots or stripes) can fill the + row beside the title or logo. The **menu** mixes blog pages and outside links, with + bold, uppercase or plain underlined links. Posts per page, the date format, the site title size and the footer alignment live here too, as does whether listings show whole posts or **summaries** with a "Read more" link (a post's summary ends at a `<!--more-->` line, or after its first paragraphs). 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) { |
