diff options
| author | grm <grm@eyesin.space> | 2026-09-17 22:29:43 +0300 |
|---|---|---|
| committer | grm <grm@eyesin.space> | 2026-09-17 22:29:43 +0300 |
| commit | 51ca573ef0c1ce4bd2ef7b7525e5543800fd1988 (patch) | |
| tree | baab4282475d4856476a59b7406c1a23673a941a | |
| parent | 2a65a1af2dd22c91ed3296dbea3809bdd1e988c3 (diff) | |
| download | blogspace-51ca573ef0c1ce4bd2ef7b7525e5543800fd1988.tar.gz blogspace-51ca573ef0c1ce4bd2ef7b7525e5543800fd1988.tar.bz2 blogspace-51ca573ef0c1ce4bd2ef7b7525e5543800fd1988.zip | |
Add a page width: cap the columns at so many pixels, centred or at the left
The header's and footer's text follows the cap when they already follow
the main column; their backgrounds stay full width. The inset that lines
them up with the main column is now exact — the wrap's 1em plus the
column's share of the wrap's content box — and, with a page width, uses
min() so it is right whether the page or the screen is the narrower.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A
| -rw-r--r-- | AGENTS.md | 15 | ||||
| -rw-r--r-- | README.md | 3 | ||||
| -rw-r--r-- | internal/i18n/el.go | 16 | ||||
| -rw-r--r-- | internal/web/templates/dashboard/design.html | 8 | ||||
| -rw-r--r-- | internal/web/theme.go | 48 | ||||
| -rw-r--r-- | internal/web/web_test.go | 32 |
6 files changed, 105 insertions, 17 deletions
@@ -240,11 +240,16 @@ internal/web/ server.go (host router, middleware, render helpers) laid out only when it has modules, or always with `theme.keep_columns` (`HasLeft/HasRight` → `body.has-*` classes; column widths in the theme CSS under `@media (min-width: 701px)`). - The page is always full width — there is no content max-width; the - column percentages divide the page and `keep_columns` is how a blogger - gets a narrower, centred main column, with `align_to_main` insetting the - header's and footer's `.wrap`s by the column widths (`calc(1em + N%)`, - wide screens only, backgrounds untouched) so they line up with it; + The page is the screen's width unless `page_width` (pixels, 0 = screen, + clamped 400–3000) caps `.wrap.body-wrap` with `max-width`, centred or at + the left per `page_align` (`Theme.PageMargin`); the column percentages + divide the page either way and `keep_columns` is how a blogger gets a + narrower main column. `align_to_main` insets the header's and footer's + `.wrap`s to the main column's edges (`Theme.Inset`: the wrap's 1em plus + the column's share of the wrap's content box, `calc((1-2w/100)em + w%)`; + with a page width those wraps get the same `max-width`/margin and the + share is `min(w%, w% of the page width)`, the px value alone first for + browsers without `min()` — wide screens only, backgrounds untouched); below 700px `.cols` becomes block flow so the order is main → left → right → footer. Everything is rendered by the `module` template in `layouts/blog.html`. New blogs get `insertDefaultModules` (title + menu @@ -31,7 +31,8 @@ Server-rendered HTML, no JavaScript required, works on old browsers and phones. order: blog title, logo, menu, archive (posts by year and month), recent posts, tags (with counts), tag cloud, a search box (finds posts by title and text, any 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 + text, RSS link, site map. Column widths are percentages of the page, which is the whole screen or a + set number of pixels, centred or at the left; 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, 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 diff --git a/internal/i18n/el.go b/internal/i18n/el.go index f97ac3f..f1be9d4 100644 --- a/internal/i18n/el.go +++ b/internal/i18n/el.go @@ -270,12 +270,16 @@ var el = map[string]string{ "Wrong username or password.": "Λάθος όνομα χρήστη ή κωδικός.", // ---- layout ---- - "Header": "Κεφαλίδα", - "Footer": "Υποσέλιδο", - "Menu": "Μενού", - "Left": "Αριστερά", - "Right": "Δεξιά", - "Left column width": "Πλάτος αριστερής στήλης", + "Header": "Κεφαλίδα", + "Footer": "Υποσέλιδο", + "Menu": "Μενού", + "Left": "Αριστερά", + "Right": "Δεξιά", + "Left column width": "Πλάτος αριστερής στήλης", + "Page width": "Πλάτος σελίδας", + "Page position": "Θέση σελίδας", + "the screen": "η οθόνη", + "Leave the width blank for the whole screen. With one, the columns share that many pixels at most — and so do the header and footer when they follow the main column (below); their backgrounds always span the screen.": "Αφήστε το πλάτος κενό για ολόκληρη την οθόνη. Με πλάτος, οι στήλες μοιράζονται το πολύ τόσα εικονοστοιχεία — το ίδιο και η κεφαλίδα και το υποσέλιδο όταν ακολουθούν την κύρια στήλη (παρακάτω)· τα φόντα τους καλύπτουν πάντα την οθόνη.", "Right column width": "Πλάτος δεξιάς στήλης", "Between 15% and 40% each; the main content takes the rest. A column only appears when it has at least one module; without columns the posts take the whole width. On phones the columns stack: main content first, then the left and right columns, then the footer.": "Από 15% έως 40% η καθεμία· το κύριο περιεχόμενο παίρνει το υπόλοιπο. Μια στήλη εμφανίζεται μόνο όταν έχει τουλάχιστον ένα στοιχείο· χωρίς στήλες οι δημοσιεύσεις παίρνουν όλο το πλάτος. Στα κινητά οι στήλες στοιβάζονται: πρώτα το κύριο περιεχόμενο, μετά η αριστερή και η δεξιά στήλη, μετά το υποσέλιδο.", "Always keep the columns' space, even when a column is empty or hidden": "Να κρατιέται πάντα ο χώρος των στηλών, ακόμα κι όταν μια στήλη είναι άδεια ή κρυφή", diff --git a/internal/web/templates/dashboard/design.html b/internal/web/templates/dashboard/design.html index 49f4699..31913f6 100644 --- a/internal/web/templates/dashboard/design.html +++ b/internal/web/templates/dashboard/design.html @@ -238,6 +238,14 @@ <label>{{t "Right column width"}}<br><input type="number" name="right_width" value="{{$t.RightWidth}}" min="15" max="40" step="5"> %</label> </div> <p class="muted small">{{t "Between 15% and 40% each; the main content takes the rest. A column only appears when it has at least one module; without columns the posts take the whole width. On phones the columns stack: main content first, then the left and right columns, then the footer."}}</p> + <div class="row"> + <label>{{t "Page width"}}<br><input type="number" name="page_width" value="{{if $t.PageWidth}}{{$t.PageWidth}}{{end}}" min="400" max="3000" step="10" placeholder="{{t "the screen"}}"> px</label> + <label>{{t "Page position"}}<br><select name="page_align"> + <option value="center"{{if eq $t.PageAlign "center"}} selected{{end}}>{{t "Centred"}}</option> + <option value="left"{{if eq $t.PageAlign "left"}} selected{{end}}>{{t "Left"}}</option> + </select></label> + </div> + <p class="muted small">{{t "Leave the width blank for the whole screen. With one, the columns share that many pixels at most — and so do the header and footer when they follow the main column (below); their backgrounds always span the screen."}}</p> <label class="check"><input type="checkbox" name="keep_columns"{{if $t.KeepColumns}} checked{{end}}> {{t "Always keep the columns' space, even when a column is empty or hidden"}} <span class="muted">({{t "the main content then keeps its width and stays centred"}})</span></label> <label class="check"><input type="checkbox" name="align_to_main"{{if $t.AlignToMain}} checked{{end}}> {{t "Header and footer follow the main column"}} <span class="muted">({{t "their text lines up with the main content instead of the page edges; the backgrounds stay full width"}})</span></label> <h3>{{t "Left column"}}</h3> diff --git a/internal/web/theme.go b/internal/web/theme.go index 66ac7ad..d686c4e 100644 --- a/internal/web/theme.go +++ b/internal/web/theme.go @@ -79,6 +79,11 @@ type Theme struct { // column's edges (the backgrounds stay full width), so a blog with // KeepColumns and empty columns reads as one centred column. AlignToMain bool `json:"align_to_main"` + // PageWidth caps the columns' box at so many pixels (0 = the screen), + // centred or at the left per PageAlign; the header's and footer's + // content follows it when AlignToMain is on, their backgrounds never. + PageWidth int `json:"page_width"` + PageAlign string `json:"page_align"` // center | left } // Areas of the blog page, in page order. above/below sit inside the main column. @@ -88,6 +93,8 @@ const ( minColumnWidth = 15 maxColumnWidth = 40 maxColumnsSum = 70 // main keeps at least 30% + minPageWidth = 400 + maxPageWidth = 3000 ) // postsPerPageChoices are the listing sizes the design form offers; the first is the default. @@ -104,7 +111,7 @@ func DefaultTheme() Theme { ShowDates: true, DateFormat: "long", PostsPerPage: postsPerPageChoices[0], ListStyle: "full", PostImage: "top", TitleRule: "none", FooterBg: "#2f3a4a", FooterColor: "#d0d5dc", FooterAlign: "center", LogoSize: "medium", - HeaderOn: true, LeftOn: true, RightOn: true, FooterOn: true, LeftWidth: 25, RightWidth: 25, + HeaderOn: true, LeftOn: true, RightOn: true, FooterOn: true, LeftWidth: 25, RightWidth: 25, PageAlign: "center", } } @@ -202,6 +209,10 @@ func (t *Theme) normalize() { if t.LeftWidth+t.RightWidth > maxColumnsSum { // the right column gives way t.RightWidth = maxColumnsSum - t.LeftWidth } + if t.PageWidth != 0 { + t.PageWidth = clampInt(t.PageWidth, minPageWidth, maxPageWidth) + } + t.PageAlign = pick(t.PageAlign, "center", "left") } func clampInt(v, lo, hi int) int { @@ -272,6 +283,8 @@ func ThemeFromForm(cur Theme, f url.Values) Theme { t.LeftWidth, t.RightWidth = num("left_width"), num("right_width") t.KeepColumns = on("keep_columns") t.AlignToMain = on("align_to_main") + t.PageWidth = num("page_width") + t.PageAlign = get("page_align") t.normalize() return t } @@ -294,6 +307,29 @@ func (t Theme) AreaOn(area string) bool { // MainWidth is what the main column gets when both side columns are shown. func (t Theme) MainWidth() int { return 100 - t.LeftWidth - t.RightWidth } +// PageMargin centres the capped page box or leaves it at the left. +func (t Theme) PageMargin() string { + if t.PageAlign == "left" { + return "0" + } + return "0 auto" +} + +// Inset is the padding-<side> declarations (AlignToMain) that put the header's +// and footer's text at a side column's inner edge: the wrap's own 1em plus +// the column's share of the wrap's content box, which is the wrap less its two +// 1em paddings — so (1 - 2w/100)em + w% of the wrap. With a page width the +// wrap is that width or the screen, whichever is narrower: min() where the +// browser has it, the page width alone (the wide-screen case) before it. +func (t Theme) Inset(side string, w int) string { + em := strconv.FormatFloat(1-float64(2*w)/100, 'f', -1, 64) + "em" + if t.PageWidth == 0 { + return "padding-" + side + ":calc(" + em + " + " + strconv.Itoa(w) + "%);" + } + px := strconv.Itoa(w*t.PageWidth/100) + "px" + return "padding-" + side + ":calc(" + em + " + " + px + "); padding-" + side + ":calc(" + em + " + min(" + strconv.Itoa(w) + "%, " + px + "));" +} + // pickFile applies an imagepick or fontpick select: "none" clears, an id // selects, and an empty value (no select in the form) keeps what was there. func pickFile(cur, v string) string { @@ -515,6 +551,12 @@ a:hover {{"{"}}{{if .LinkHover}} color:{{.LinkHover}};{{end}}{{if eq .LinkUnderl .page-title, .post-title { border-color:{{.LinkColor}}; } .page-title::after, .post-title::after { color:{{.LinkColor}}; } {{- end}} .logo { max-height:{{.LogoMaxHeight}}; } +{{- if .PageWidth}} +.wrap.body-wrap { max-width:{{.PageWidth}}px; margin:{{.PageMargin}}; } +{{- if .AlignToMain}} +.site-header .wrap, .site-footer .wrap { max-width:{{.PageWidth}}px; margin:{{.PageMargin}}; } +{{- end}} +{{- end}} @media (min-width: 701px) { .col-left { width:{{.LeftWidth}}%; } .col-right { width:{{.RightWidth}}%; } body.has-left.has-right .col-main { width:{{.MainWidth}}%; } @@ -524,8 +566,8 @@ a:hover {{"{"}}{{if .LinkHover}} color:{{.LinkHover}};{{end}}{{if eq .LinkUnderl .site-title { font-size:{{.}}; } {{- end}} {{- if .AlignToMain}} - body.has-left .site-header .wrap, body.has-left .site-footer .wrap { padding-left:calc(1em + {{.LeftWidth}}%); } - body.has-right .site-header .wrap, body.has-right .site-footer .wrap { padding-right:calc(1em + {{.RightWidth}}%); } + body.has-left .site-header .wrap, body.has-left .site-footer .wrap { {{.Inset "left" .LeftWidth}} } + body.has-right .site-header .wrap, body.has-right .site-footer .wrap { {{.Inset "right" .RightWidth}} } {{- end}} } {{- with .PaddingPhone}} diff --git a/internal/web/web_test.go b/internal/web/web_test.go index ffa9db7..1ca2677 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -251,12 +251,40 @@ func TestLayoutTheme(t *testing.T) { } th = ThemeFromForm(th, url.Values{"left_width": {"15"}, "right_width": {"20"}, "align_to_main": {"on"}}) css = th.CSS() - for _, want := range []string{"body.has-left .site-header .wrap, body.has-left .site-footer .wrap { padding-left:calc(1em + 15%); }", - "body.has-right .site-header .wrap, body.has-right .site-footer .wrap { padding-right:calc(1em + 20%); }"} { + // The inset is the wrap's 1em plus the column's share of the wrap's content box (the wrap less 2em). + for _, want := range []string{"body.has-left .site-header .wrap, body.has-left .site-footer .wrap { padding-left:calc(0.7em + 15%); }", + "body.has-right .site-header .wrap, body.has-right .site-footer .wrap { padding-right:calc(0.6em + 20%); }"} { if !strings.Contains(css, want) { t.Errorf("css missing %q:\n%s", want, css) } } + if strings.Contains(css, "max-width") { + t.Error("no page width, no max-width") + } + // A page width caps the columns' wrap; the header and footer only when they follow the main column. + th = ThemeFromForm(th, url.Values{"left_width": {"15"}, "right_width": {"20"}, "page_width": {"700"}, "page_align": {"left"}}) + if th.PageWidth != 700 || th.PageAlign != "left" || th.AlignToMain { + t.Errorf("page width from form: %+v", th) + } + css = th.CSS() + if !strings.Contains(css, ".wrap.body-wrap { max-width:700px; margin:0; }") || strings.Contains(css, ".site-header .wrap, .site-footer .wrap { max-width") { + t.Errorf("page width css: %s", css) + } + th = ThemeFromForm(th, url.Values{"left_width": {"15"}, "right_width": {"20"}, "page_width": {"700"}, "align_to_main": {"on"}}) + css = th.CSS() + for _, want := range []string{".wrap.body-wrap { max-width:700px; margin:0 auto; }", ".site-header .wrap, .site-footer .wrap { max-width:700px; margin:0 auto; }", + "{ padding-left:calc(0.7em + 105px); padding-left:calc(0.7em + min(15%, 105px)); }", "{ padding-right:calc(0.6em + 140px); padding-right:calc(0.6em + min(20%, 140px)); }"} { + if !strings.Contains(css, want) { + t.Errorf("css missing %q:\n%s", want, css) + } + } + th = ThemeFromForm(th, url.Values{"page_width": {"12"}, "page_align": {"diagonal"}}) + if th.PageWidth != 400 || th.PageAlign != "center" { + t.Errorf("page width clamp: %+v", th) + } + if th = ThemeFromForm(th, url.Values{"page_width": {""}}); th.PageWidth != 0 { + t.Errorf("blank is the screen: %+v", th) + } th.LeftOn = false if th.AreaOn("left") || !th.AreaOn("above") || !th.AreaOn("bogus") { t.Errorf("area switches: %+v", th) |
