aboutsummaryrefslogtreecommitdiffstats
path: root/internal/web
diff options
context:
space:
mode:
authorgrm <grm@eyesin.space>2026-09-18 12:10:26 +0300
committergrm <grm@eyesin.space>2026-09-18 12:10:33 +0300
commit19353a51c869f4b24ef2253d856084b6e6728048 (patch)
tree6d58fe2d284e0d26f491a87924c65b536e979983 /internal/web
parente3114ba866a555e49f6f0f8321ed6106a9f135bf (diff)
downloadblogspace-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
Diffstat (limited to 'internal/web')
-rw-r--r--internal/web/handlers_blog.go2
-rw-r--r--internal/web/layout.go61
-rw-r--r--internal/web/static/blog.css14
-rw-r--r--internal/web/templates/dashboard/section_form.html2
-rw-r--r--internal/web/templates/layouts/blog.html2
-rw-r--r--internal/web/web_test.go34
6 files changed, 105 insertions, 10 deletions
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 {