<feed xmlns='http://www.w3.org/2005/Atom'>
<title>blogspace/internal/web/templates/dashboard/section_form.html, branch master</title>
<subtitle>blogspace</subtitle>
<link rel='alternate' type='text/html' href='https://git.eyesin.space/blogspace/'/>
<entry>
<title>Pair headed gizmo announcements two to a row, split by text length</title>
<updated>2026-09-18T09:10:33+00:00</updated>
<author>
<name>grm</name>
<email>grm@eyesin.space</email>
</author>
<published>2026-09-18T09:10:26+00:00</published>
<link rel='alternate' type='text/html' href='https://git.eyesin.space/blogspace/commit/?id=19353a51c869f4b24ef2253d856084b6e6728048'/>
<id>19353a51c869f4b24ef2253d856084b6e6728048</id>
<content type='text'>
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 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A
</content>
<content type='xhtml'>
<div xmlns='http://www.w3.org/1999/xhtml'>
<pre>
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 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A
</pre>
</div>
</content>
</entry>
<entry>
<title>Add the Gizmo look for announcements: framed boxes that share a row</title>
<updated>2026-09-17T14:09:28+00:00</updated>
<author>
<name>grm</name>
<email>grm@eyesin.space</email>
</author>
<published>2026-09-17T14:09:01+00:00</published>
<link rel='alternate' type='text/html' href='https://git.eyesin.space/blogspace/commit/?id=f16bd0f79c624665580615321b5d13b4be27cdad'/>
<id>f16bd0f79c624665580615321b5d13b4be27cdad</id>
<content type='text'>
gizmolab's "next event" and "next assembly" boxes were hand-written HTML in
the home page's intro; they are announcements by nature, so now they can
be: a fourth style, a box framed in the theme's link colour with an italic
heading. Headed Gizmo boxes in the same spot share a row on wide screens
(the notices wrapper is a flex row, stacking on old browsers like .cols);
one without a heading takes the whole row, which is how a banner line goes
above a pair of boxes.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A
</content>
<content type='xhtml'>
<div xmlns='http://www.w3.org/1999/xhtml'>
<pre>
gizmolab's "next event" and "next assembly" boxes were hand-written HTML in
the home page's intro; they are announcements by nature, so now they can
be: a fourth style, a box framed in the theme's link colour with an italic
heading. Headed Gizmo boxes in the same spot share a row on wide screens
(the notices wrapper is a flex row, stacking on old browsers like .cols);
one without a heading takes the whole row, which is how a banner line goes
above a pair of boxes.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A
</pre>
</div>
</content>
</entry>
<entry>
<title>Merge the Layout and Design tabs into one form with a single Save</title>
<updated>2026-09-16T16:10:13+00:00</updated>
<author>
<name>grm</name>
<email>grm@eyesin.space</email>
</author>
<published>2026-09-16T16:10:13+00:00</published>
<link rel='alternate' type='text/html' href='https://git.eyesin.space/blogspace/commit/?id=4a6f987bf7f9d73e9dd33d225fd2d73e9bf69a88'/>
<id>4a6f987bf7f9d73e9dd33d225fd2d73e9bf69a88</id>
<content type='text'>
The look and the layout of a blog were split over two tabs that cross-linked
each other, and the Layout tab saved every toggle, move and add straight
away through a dozen tiny forms. Now one Design tab holds it all, organised
by part of the blog (colours &amp; fonts, header, menu, content, side columns,
footer, logo &amp; icon) behind a sticky side index, and one Save stores the
theme, every module and the menu atomically (BlogStore.SaveDesign). Module
settings are edited inline; Discard changes throws the edits away; a
validation error shows the form again exactly as it was sent.

The image pickers no longer render the whole library four times over: a
select of the newest images, and a script-driven panel that fetches the
library page by page (GET /files as JSON) with lazy thumbnails.

New options: posts per page, site title size, footer alignment.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A
</content>
<content type='xhtml'>
<div xmlns='http://www.w3.org/1999/xhtml'>
<pre>
The look and the layout of a blog were split over two tabs that cross-linked
each other, and the Layout tab saved every toggle, move and add straight
away through a dozen tiny forms. Now one Design tab holds it all, organised
by part of the blog (colours &amp; fonts, header, menu, content, side columns,
footer, logo &amp; icon) behind a sticky side index, and one Save stores the
theme, every module and the menu atomically (BlogStore.SaveDesign). Module
settings are edited inline; Discard changes throws the edits away; a
validation error shows the form again exactly as it was sent.

The image pickers no longer render the whole library four times over: a
select of the newest images, and a script-driven panel that fetches the
library page by page (GET /files as JSON) with lazy thumbnails.

New options: posts per page, site title size, footer alignment.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A
</pre>
</div>
</content>
</entry>
<entry>
<title>Add an HTML mode to posts, page intros and announcements</title>
<updated>2026-09-16T15:37:20+00:00</updated>
<author>
<name>grm</name>
<email>grm@eyesin.space</email>
</author>
<published>2026-09-16T15:37:20+00:00</published>
<link rel='alternate' type='text/html' href='https://git.eyesin.space/blogspace/commit/?id=6b6b5d2e35ff182a0732da245f4eb37c8afa0564'/>
<id>6b6b5d2e35ff182a0732da245f4eb37c8afa0564</id>
<content type='text'>
Markdown is the default and unchanged; the editor's new Format switch
stores the text as raw HTML instead, put on the blog exactly as written.
Like the custom HTML module it is unsanitised on purpose: the escape
hatch for embeds, scripts and inline styles that Markdown cannot express.

The source column keeps holding the text in both modes and a `format`
column says how to read it, so the public templates and the feed still
print the stored `*_html`. The dashboard preview of HTML goes into a
sandboxed iframe rather than the page, because a superadmin edits other
people's blogs and their markup must never run on the dashboard origin.
Search snippets of HTML posts are cut from a tag-stripped copy.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A
</content>
<content type='xhtml'>
<div xmlns='http://www.w3.org/1999/xhtml'>
<pre>
Markdown is the default and unchanged; the editor's new Format switch
stores the text as raw HTML instead, put on the blog exactly as written.
Like the custom HTML module it is unsanitised on purpose: the escape
hatch for embeds, scripts and inline styles that Markdown cannot express.

The source column keeps holding the text in both modes and a `format`
column says how to read it, so the public templates and the feed still
print the stored `*_html`. The dashboard preview of HTML goes into a
sandboxed iframe rather than the page, because a superadmin edits other
people's blogs and their markup must never run on the dashboard origin.
Search snippets of HTML posts are cut from a tag-stripped copy.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A
</pre>
</div>
</content>
</entry>
<entry>
<title>Give the editor a Markdown toolbar and preview, restyle the dashboard</title>
<updated>2026-09-15T19:26:13+00:00</updated>
<author>
<name>grm</name>
<email>grm@eyesin.space</email>
</author>
<published>2026-09-15T19:26:13+00:00</published>
<link rel='alternate' type='text/html' href='https://git.eyesin.space/blogspace/commit/?id=55732bdfc25933bab26220ccd82fe5e172d9e673'/>
<id>55732bdfc25933bab26220ccd82fe5e172d9e673</id>
<content type='text'>
The post/page/announcement editor was a bare textarea, so bloggers had to
know the Markdown syntax by heart. It now has a toolbar (bold, italic,
strike, heading cycle, quote, code, lists, rule, link box, insert file),
Ctrl+B/I/K, list continuation on Enter and a Write/Preview toggle that
renders the text through the same goldmark + bluemonday pipeline a save
uses (POST /b/{sub}/preview; nothing is stored). Every edit goes through
execCommand("insertText") so browser undo keeps working. The toolbar
stays hidden without JavaScript, leaving the old form untouched.

The dashboard moves from the "paper &amp; ink" look to a lean one: neutral
surfaces, 1px borders, one blue accent, system sans, with custom
properties and flexbox/grid now allowed there (blog.css is unchanged).
Class names were kept so the templates barely change; a global [hidden]
rule keeps flex containers from overriding the hidden attribute.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A
</content>
<content type='xhtml'>
<div xmlns='http://www.w3.org/1999/xhtml'>
<pre>
The post/page/announcement editor was a bare textarea, so bloggers had to
know the Markdown syntax by heart. It now has a toolbar (bold, italic,
strike, heading cycle, quote, code, lists, rule, link box, insert file),
Ctrl+B/I/K, list continuation on Enter and a Write/Preview toggle that
renders the text through the same goldmark + bluemonday pipeline a save
uses (POST /b/{sub}/preview; nothing is stored). Every edit goes through
execCommand("insertText") so browser undo keeps working. The toolbar
stays hidden without JavaScript, leaving the old form untouched.

The dashboard moves from the "paper &amp; ink" look to a lean one: neutral
surfaces, 1px borders, one blue accent, system sans, with custom
properties and flexbox/grid now allowed there (blog.css is unchanged).
Class names were kept so the templates barely change; a global [hidden]
rule keeps flex containers from overriding the hidden attribute.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A
</pre>
</div>
</content>
</entry>
<entry>
<title>Turn the image library into a file library, with a per-blog upload limit</title>
<updated>2026-09-14T20:51:55+00:00</updated>
<author>
<name>grm</name>
<email>grm@eyesin.space</email>
</author>
<published>2026-09-14T20:51:55+00:00</published>
<link rel='alternate' type='text/html' href='https://git.eyesin.space/blogspace/commit/?id=778cb72c8a0902bd0b8159ebd3bb7eff93f28c83'/>
<id>778cb72c8a0902bd0b8159ebd3bb7eff93f28c83</id>
<content type='text'>
Bloggers want to attach PDFs, archives, audio and other files to posts,
not only images. The Images tab becomes Files: any type is accepted,
listed by kind with search, paging, rename and multi-file upload, and the
editor's paste/drop/"Insert file" takes anything (images are shown,
everything else becomes a link). The default limit goes from 5 to 10 MB
and the superadmin can override it per blog from /admin/.

Files stay in Postgres so one pg_dump is still the whole blog. The bytea
column is STORAGE EXTERNAL and /media streams it in substring() slices,
so serving never holds a whole file in memory whatever limit a blog gets.

Serving any type on the root domain, which carries the session cookie,
needs a policy: uploads are typed by sniffing (the extension may only
refine a generic sniff to an allowlisted type) and only images, PDF,
plain text, audio and video render inline; HTML, SVG, XML, scripts,
archives and binaries always go out as application/octet-stream with
Content-Disposition: attachment.

The body cap moves out of requireAuth into guardPOST, which runs after
withBlog has resolved the blog and so knows its limit.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A
</content>
<content type='xhtml'>
<div xmlns='http://www.w3.org/1999/xhtml'>
<pre>
Bloggers want to attach PDFs, archives, audio and other files to posts,
not only images. The Images tab becomes Files: any type is accepted,
listed by kind with search, paging, rename and multi-file upload, and the
editor's paste/drop/"Insert file" takes anything (images are shown,
everything else becomes a link). The default limit goes from 5 to 10 MB
and the superadmin can override it per blog from /admin/.

Files stay in Postgres so one pg_dump is still the whole blog. The bytea
column is STORAGE EXTERNAL and /media streams it in substring() slices,
so serving never holds a whole file in memory whatever limit a blog gets.

Serving any type on the root domain, which carries the session cookie,
needs a policy: uploads are typed by sniffing (the extension may only
refine a generic sniff to an allowlisted type) and only images, PDF,
plain text, audio and video render inline; HTML, SVG, XML, scripts,
archives and binaries always go out as application/octet-stream with
Content-Disposition: attachment.

The body cap moves out of requireAuth into guardPOST, which runs after
withBlog has resolved the blog and so knows its limit.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A
</pre>
</div>
</content>
</entry>
<entry>
<title>Add languages: English and Greek, chosen per blog on the Settings tab</title>
<updated>2026-09-13T22:32:25+00:00</updated>
<author>
<name>grm</name>
<email>grm@eyesin.space</email>
</author>
<published>2026-09-13T22:32:25+00:00</published>
<link rel='alternate' type='text/html' href='https://git.eyesin.space/blogspace/commit/?id=e14a4250efb828262347f5a4bbc4067cd71d885f'/>
<id>e14a4250efb828262347f5a4bbc4067cd71d885f</id>
<content type='text'>
Every blog and its dashboard were hard-wired to English. A blogger can
now pick the language of their blog; it switches the whole dashboard and
the blog's fixed text — post dates, archive months, the RSS link, the
pager, the 404 page — while what the blogger wrote is left alone.

The new internal/i18n package keys translations by the English string,
so an untranslated key renders as English rather than blank, and
TestGreekCatalogComplete scans the templates and handlers to fail when
the Greek catalog misses a key or keeps a stale one. Templates are
compiled once per language with t/tf/date/postdate/month closed over the
language, so they need no data plumbing.

The language lives in settings.language (blog migration 00002), not in
the theme, so "Reset design" does not touch it. Public pages use the
blog's language; management pages use the logged-in user's own blog's,
so a superadmin editing someone else's blog keeps theirs; the login page
follows Accept-Language.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A
</content>
<content type='xhtml'>
<div xmlns='http://www.w3.org/1999/xhtml'>
<pre>
Every blog and its dashboard were hard-wired to English. A blogger can
now pick the language of their blog; it switches the whole dashboard and
the blog's fixed text — post dates, archive months, the RSS link, the
pager, the 404 page — while what the blogger wrote is left alone.

The new internal/i18n package keys translations by the English string,
so an untranslated key renders as English rather than blank, and
TestGreekCatalogComplete scans the templates and handlers to fail when
the Greek catalog misses a key or keeps a stale one. Templates are
compiled once per language with t/tf/date/postdate/month closed over the
language, so they need no data plumbing.

The language lives in settings.language (blog migration 00002), not in
the theme, so "Reset design" does not touch it. Public pages use the
blog's language; management pages use the logged-in user's own blog's,
so a superadmin editing someone else's blog keeps theirs; the login page
follows Accept-Language.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A
</pre>
</div>
</content>
</entry>
<entry>
<title>Add the Layout tab: five areas with modules, columns and a menu editor</title>
<updated>2026-09-12T21:38:24+00:00</updated>
<author>
<name>grm</name>
<email>grm@eyesin.space</email>
</author>
<published>2026-09-12T21:38:24+00:00</published>
<link rel='alternate' type='text/html' href='https://git.eyesin.space/blogspace/commit/?id=d11e4c9544f544b3a024c7ac705a674b4096cf50'/>
<id>d11e4c9544f544b3a024c7ac705a674b4096cf50</id>
<content type='text'>
A blog page is now header, left column, main content, right column and
footer, each holding an ordered list of modules (blog title, logo, menu,
archive by year/month, recent posts, custom HTML, footer text, RSS link,
site map). Side columns have percentage widths, can be hidden, and can
keep their space when empty; on phones they stack under the posts. The
menu becomes its own ordered list mixing pages and outside links.
Announcements are placed per column at its top or bottom.

Modules and menu items get tables; the migration derives them from each
blog's old theme (nav position, footer text, show-in-nav pages) so
existing blogs look the same. Custom HTML is deliberately stored and
served as-is (owner's decision; see AGENTS.md for why the blast radius
is the blogger's own origin).

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A
</content>
<content type='xhtml'>
<div xmlns='http://www.w3.org/1999/xhtml'>
<pre>
A blog page is now header, left column, main content, right column and
footer, each holding an ordered list of modules (blog title, logo, menu,
archive by year/month, recent posts, custom HTML, footer text, RSS link,
site map). Side columns have percentage widths, can be hidden, and can
keep their space when empty; on phones they stack under the posts. The
menu becomes its own ordered list mixing pages and outside links.
Announcements are placed per column at its top or bottom.

Modules and menu items get tables; the migration derives them from each
blog's old theme (nav position, footer text, show-in-nav pages) so
existing blogs look the same. Custom HTML is deliberately stored and
served as-is (owner's decision; see AGENTS.md for why the blast radius
is the blogger's own origin).

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A
</pre>
</div>
</content>
</entry>
<entry>
<title>Add announcements: blog-wide notices on every page and post</title>
<updated>2026-09-12T09:33:57+00:00</updated>
<author>
<name>grm</name>
<email>grm@eyesin.space</email>
</author>
<published>2026-09-12T09:33:57+00:00</published>
<link rel='alternate' type='text/html' href='https://git.eyesin.space/blogspace/commit/?id=dd13e4b73a105ee416b6cfd8eb19b217622c0031'/>
<id>dd13e4b73a105ee416b6cfd8eb19b217622c0031</id>
<content type='text'>
A blog can have any number of announcements ("sections" in the schema):
a book club's next meeting, this month's pick, a holiday closure. Each
has a placement (above or below the posts, or under the menu when the
menu is a left sidebar), a look (highlighted, warning, plain), an order,
and an on/off switch so recurring notices can be kept around while hidden.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A
</content>
<content type='xhtml'>
<div xmlns='http://www.w3.org/1999/xhtml'>
<pre>
A blog can have any number of announcements ("sections" in the schema):
a book club's next meeting, this month's pick, a holiday closure. Each
has a placement (above or below the posts, or under the menu when the
menu is a left sidebar), a look (highlighted, warning, plain), an order,
and an on/off switch so recurring notices can be kept around while hidden.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A
</pre>
</div>
</content>
</entry>
</feed>
