diff options
| author | grm <grm@eyesin.space> | 2026-09-18 12:10:26 +0300 |
|---|---|---|
| committer | grm <grm@eyesin.space> | 2026-09-18 12:10:33 +0300 |
| commit | 19353a51c869f4b24ef2253d856084b6e6728048 (patch) | |
| tree | 6d58fe2d284e0d26f491a87924c65b536e979983 | |
| parent | e3114ba866a555e49f6f0f8321ed6106a9f135bf (diff) | |
| download | blogspace-19353a51c869f4b24ef2253d856084b6e6728048.tar.gz blogspace-19353a51c869f4b24ef2253d856084b6e6728048.tar.bz2 blogspace-19353a51c869f4b24ef2253d856084b6e6728048.zip | |
Pair headed gizmo announcements two to a row, split by text length
Headed gizmo boxes used to share a row equally, up to three of them.
Now placeNotices pairs consecutive ones in the main column and gives the
pair one of the fixed splits — 25/75, 33/67, 50/50, 67/33, 75/25 —
nearest the ratio of the text each shows, so the wordier box gets the
room. The bases add up to 90% so a third box never joins the row, and a
side column, too narrow for two, stacks them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A
| -rw-r--r-- | AGENTS.md | 11 | ||||
| -rw-r--r-- | internal/i18n/el.go | 2 | ||||
| -rw-r--r-- | internal/web/handlers_blog.go | 2 | ||||
| -rw-r--r-- | internal/web/layout.go | 61 | ||||
| -rw-r--r-- | internal/web/static/blog.css | 14 | ||||
| -rw-r--r-- | internal/web/templates/dashboard/section_form.html | 2 | ||||
| -rw-r--r-- | internal/web/templates/layouts/blog.html | 2 | ||||
| -rw-r--r-- | internal/web/web_test.go | 34 |
8 files changed, 114 insertions, 14 deletions
@@ -378,9 +378,14 @@ internal/web/ server.go (host router, middleware, render helpers) colours so they fit any theme, except `gizmo`: a box framed in the theme's link colour (`.notice-gizmo { border-color }` comes from the theme CSS). On wide screens `.notices` is a flex row (the second flexbox in blog.css, - stacking on old browsers like `.cols`): headed gizmo boxes (`.headed`) - start at 30% and grow, so up to three share a row; every other box takes - the whole row. + stacking on old browsers like `.cols`). `placeNotices` returns `Notice` + (the section + `Width`): in the main column, consecutive headed gizmo + boxes (`.headed`) are paired two to a row by `pairNotices`, and + `shareRow` gives the pair the `rowShares` split (25/75, 33/67, 50/50, + 67/33, 75/25) nearest the ratio of their text lengths (`noticeLen`: title + + body with tags stripped) as `.w-<percent>` classes whose flex-bases sum + to 90% (the leftover grows into both, so a third never fits). Every other + box, and every box in a side column, takes the whole row. - **Dashboard nav**: `dashnav.html` marks the current tab with `hasPrefix .Path`; a new management section needs a tab there. - **Languages** (`internal/i18n`, `settings.language`): every blog has a diff --git a/internal/i18n/el.go b/internal/i18n/el.go index f52b529..6a97c83 100644 --- a/internal/i18n/el.go +++ b/internal/i18n/el.go @@ -145,7 +145,7 @@ var el = map[string]string{ "Warning": "Προειδοποίηση", "Plain": "Απλή", "Gizmo": "Gizmo", - "Gizmo is a box framed in your link colour. Gizmo boxes with a heading share a row on wide screens (up to three); one without a heading takes the whole row.": "Το Gizmo είναι ένα πλαίσιο στο χρώμα των συνδέσμων σου. Τα πλαίσια Gizmo με επικεφαλίδα μοιράζονται μια σειρά σε φαρδιές οθόνες (μέχρι τρία)· ένα χωρίς επικεφαλίδα πιάνει όλη τη σειρά.", + "Gizmo is a box framed in your link colour. In the main column, two gizmo boxes with a heading share a row on wide screens, the one with more text getting the wider part; one without a heading takes the whole row.": "Το Gizmo είναι ένα πλαίσιο στο χρώμα των συνδέσμων σου. Στην κύρια στήλη, δύο πλαίσια Gizmo με επικεφαλίδα μοιράζονται μια σειρά σε φαρδιές οθόνες, με το πιο πολύλογο να παίρνει το φαρδύτερο μέρος· ένα χωρίς επικεφαλίδα πιάνει όλη τη σειρά.", "Next meeting": "Επόμενη συνάντηση", "“Top” of a side column means above its modules, “bottom” below them. If that column is not shown on your blog (see": "«Κορυφή» μιας πλαϊνής στήλης σημαίνει πάνω από τα στοιχεία της, «τέλος» κάτω από αυτά. Αν η στήλη δεν εμφανίζεται στο ιστολόγιό σας (βλ.", "the announcement moves to the main content instead.": "η ανακοίνωση μεταφέρεται στο κύριο περιεχόμενο.", diff --git a/internal/web/handlers_blog.go b/internal/web/handlers_blog.go index c1afedb..d9caf22 100644 --- a/internal/web/handlers_blog.go +++ b/internal/web/handlers_blog.go @@ -65,7 +65,7 @@ func (s *Server) blogView(r *http.Request) (map[string]any, error) { // noNotices stands in for the placed announcements on pages that hide them // (typed, so the layout's index lookups still work). -var noNotices = map[string][]store.Section{} +var noNotices = map[string][]Notice{} // pageNum reads the ?p= of a paginated listing (1 when absent or silly). func pageNum(r *http.Request) int { diff --git a/internal/web/layout.go b/internal/web/layout.go index 6f5c84f..91728ed 100644 --- a/internal/web/layout.go +++ b/internal/web/layout.go @@ -1,9 +1,12 @@ package web import ( + "html" + "math" "net/url" "strings" "time" + "unicode/utf8" "github.com/gramanas/blogspace/internal/i18n" "github.com/gramanas/blogspace/internal/store" @@ -243,17 +246,69 @@ func groupArchive(posts []store.PostRef) []ArchiveYear { return years } +// Notice is an announcement placed on the page: the section plus, for a +// headed gizmo box sharing a row with the next one, its share of the row. +type Notice struct { + store.Section + Width int // 0 = the whole row; else one of rowShares, in percent +} + +// rowShares are the widths two boxes on one row can take: the longer text +// gets the bigger share, and the pair always adds up to 100. +var rowShares = []int{25, 33, 50, 67, 75} + // placeNotices groups announcements by placement ("left-top", "main-bottom", …). // Ones meant for a side column that is not laid out go to the main column instead. -func placeNotices(secs []store.Section, l Layout) map[string][]store.Section { - out := map[string][]store.Section{} +// In each group, headed gizmo boxes are paired up, two to a row, and the +// pair's widths are set by how much text each carries (main column only — +// a side column stacks them). +func placeNotices(secs []store.Section, l Layout) map[string][]Notice { + out := map[string][]Notice{} for _, sec := range secs { col := sec.Column() if (col == "left" && !l.HasLeft) || (col == "right" && !l.HasRight) { col = "main" } key := col + "-" + sec.Position() - out[key] = append(out[key], sec) + out[key] = append(out[key], Notice{Section: sec}) + } + for key, ns := range out { + if strings.HasPrefix(key, "main-") { + pairNotices(ns) + } } return out } + +// pairNotices gives every two consecutive headed gizmo boxes their row shares. +func pairNotices(ns []Notice) { + for i := 0; i+1 < len(ns); i++ { + if !ns[i].pairable() || !ns[i+1].pairable() { + continue + } + ns[i].Width, ns[i+1].Width = shareRow(noticeLen(ns[i]), noticeLen(ns[i+1])) + i++ + } +} + +func (n Notice) pairable() bool { return n.Style == "gizmo" && n.Title != "" } + +// noticeLen is how much text a box shows: its title and body, tags stripped. +func noticeLen(n Notice) int { + return utf8.RuneCountInString(n.Title) + utf8.RuneCountInString(html.UnescapeString(stripTags.Sanitize(n.BodyHTML))) +} + +// shareRow picks the pair of rowShares nearest to the two lengths' ratio. +func shareRow(a, b int) (int, int) { + if a+b == 0 { + return 50, 50 + } + want := float64(a) * 100 / float64(a+b) + best := 50 + for _, s := range rowShares { + if math.Abs(float64(s)-want) < math.Abs(float64(best)-want) { + best = s + } + } + return best, 100 - best +} diff --git a/internal/web/static/blog.css b/internal/web/static/blog.css index 81a5940..352f841 100644 --- a/internal/web/static/blog.css +++ b/internal/web/static/blog.css @@ -77,15 +77,21 @@ img { max-width: 100%; height: auto; } .content > .notices:last-child { margin-top: 1.5em; } .content > .notices:last-child > .notice:last-child { margin-bottom: 0; } /* gizmo: a box framed in the link colour (set by the theme CSS). On wide - screens the flex wrapper puts up to three headed gizmo boxes in one row - (they start at 30% and grow); a gizmo box without a heading, like every - plain/note/warning box, takes the whole row */ + screens the flex wrapper puts two headed gizmo boxes in one row of the main + column, the wider share (.w-<percent>, set by placeNotices from the amount + of text) to the longer one: the bases add up to 90% so a third never fits, + and the leftover grows into both equally. A gizmo box without a heading, + like every plain/note/warning box, takes the whole row */ .notice-gizmo { padding: 1em 1.2em; border: 1px solid; background: none; } .notice-gizmo .notice-title { font-size: 1.15em; font-style: italic; text-transform: none; letter-spacing: 0; margin-bottom: 0.6em; } @media (min-width: 701px) { .notices { display: flex; flex-wrap: wrap; gap: 0 1em; align-items: stretch; } .notices .notice { flex: 1 1 100%; } - .notices .notice-gizmo.headed { flex: 1 1 30%; } + .col-main .notices .w-25 { flex-basis: 20%; } + .col-main .notices .w-33 { flex-basis: 28%; } + .col-main .notices .w-50 { flex-basis: 45%; } + .col-main .notices .w-67 { flex-basis: 62%; } + .col-main .notices .w-75 { flex-basis: 70%; } } .pager { margin-top: 2em; padding-top: 1em; border-top: 1px solid rgba(0,0,0,0.1); text-align: center; } .pager a, .pager span { margin: 0 0.8em; } diff --git a/internal/web/templates/dashboard/section_form.html b/internal/web/templates/dashboard/section_form.html index 0a0e4ea..f137d1a 100644 --- a/internal/web/templates/dashboard/section_form.html +++ b/internal/web/templates/dashboard/section_form.html @@ -23,7 +23,7 @@ <option value="gizmo"{{if eq $s.Style "gizmo"}} selected{{end}}>{{t "Gizmo"}}</option> </select></label> </div> - <p class="muted small">{{t "Gizmo is a box framed in your link colour. Gizmo boxes with a heading share a row on wide screens (up to three); one without a heading takes the whole row."}}</p> + <p class="muted small">{{t "Gizmo is a box framed in your link colour. In the main column, two gizmo boxes with a heading share a row on wide screens, the one with more text getting the wider part; one without a heading takes the whole row."}}</p> <p class="muted small">{{t "“Top” of a side column means above its modules, “bottom” below them. If that column is not shown on your blog (see"}} <a href="/b/{{.Blog.Subdomain}}/design#columns">{{t "Design"}}</a>) {{t "the announcement moves to the main content instead."}}</p> <label for="ed-body">{{t "Text"}}</label> {{template "editor" (dict "name" "body" "value" $s.BodyMD "format" $s.Format "rows" 10 "tall" false "upload" (print "/b/" .Blog.Subdomain "/files/upload") "preview" (print "/b/" .Blog.Subdomain "/preview") "csrf" .CSRF)}} diff --git a/internal/web/templates/layouts/blog.html b/internal/web/templates/layouts/blog.html index 5b1f984..29147e7 100644 --- a/internal/web/templates/layouts/blog.html +++ b/internal/web/templates/layouts/blog.html @@ -44,7 +44,7 @@ </html>{{end}} {{define "notices"}}{{if .}}<div class="notices"> -{{range .}}<aside class="notice notice-{{.Style}}{{if .Title}} headed{{end}}"> +{{range .}}<aside class="notice notice-{{.Style}}{{if .Title}} headed{{end}}{{if .Width}} w-{{.Width}}{{end}}"> {{if .Title}}<h3 class="notice-title">{{.Title}}</h3>{{end}} <div class="notice-body">{{html .BodyHTML}}</div> </aside> diff --git a/internal/web/web_test.go b/internal/web/web_test.go index 34ec057..fc38536 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -2,6 +2,7 @@ package web import ( "encoding/json" + "fmt" "io/fs" "net/http" "net/http/httptest" @@ -407,6 +408,39 @@ func TestBuildLayout(t *testing.T) { } } +// Headed gizmo boxes in the main column go two to a row, the longer text +// taking the bigger of the fixed shares; anything else breaks the pairing. +func TestPairNotices(t *testing.T) { + g := func(id int64, title, body string) store.Section { + return store.Section{ID: id, Placement: "main-top", Style: "gizmo", Title: title, BodyHTML: body} + } + secs := []store.Section{ + g(1, "A", "<p>short</p>"), g(2, "B", "<p>"+strings.Repeat("long text ", 20)+"</p>"), // 25/75 + g(3, "C", "same"), g(4, "D", "size"), // 50/50 + g(5, "E", strings.Repeat("x", 100)), g(6, "F", strings.Repeat("y", 50)), // 67/33 + g(7, "G", "odd one out"), + {ID: 8, Placement: "main-top", Style: "gizmo", BodyHTML: "no heading"}, + g(9, "H", "after a full-width box"), g(10, "I", strings.Repeat("z", 200)), // 25/75 again + {ID: 11, Placement: "right-top", Style: "gizmo", Title: "J"}, {ID: 12, Placement: "right-top", Style: "gizmo", Title: "K"}, + } + n := placeNotices(secs, Layout{HasRight: true}) + var got []int + for _, x := range n["main-top"] { + got = append(got, x.Width) + } + if want := []int{25, 75, 50, 50, 67, 33, 0, 0, 25, 75}; fmt.Sprint(got) != fmt.Sprint(want) { + t.Errorf("widths %v, want %v", got, want) + } + if r := n["right-top"]; r[0].Width != 0 || r[1].Width != 0 { + t.Errorf("side columns stack: %v", r) + } + for _, c := range []struct{ a, b, want int }{{0, 0, 50}, {1, 1, 50}, {1, 2, 33}, {2, 1, 67}, {1, 5, 25}, {5, 1, 75}, {1000, 1, 75}, {3, 5, 33}, {4, 5, 50}} { + if got, other := shareRow(c.a, c.b); got != c.want || other != 100-c.want { + t.Errorf("shareRow(%d, %d) = %d/%d, want %d", c.a, c.b, got, other, c.want) + } + } +} + func TestModuleKinds(t *testing.T) { for area, kinds := range moduleKinds { if _, ok := areaNames[area]; !ok { |
