No description
  • Python 32.9%
  • SCSS 27.4%
  • JavaScript 25.9%
  • HTML 13%
  • Dockerfile 0.8%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Till Wesselmann 9159fba1fa Close the page with where else the Verein is, and name the newsletter
Two more Textbausteine, `newsletter` and `social`, both markdown lists of
links like the menu and read by the same `content.links`. The newsletter
stands at the top of "Auf dem Laufenden bleiben", because the signup lives on
somebody else's site and the layout cannot know where. The profiles are a line
of their own under the three columns, as words rather than logos, each with
`rel="me"` so that a Mastodon profile linking back verifies.

The Impressum moves down into that line. It sat under "Auf dem Laufenden
bleiben" only because there was nowhere else, and it never was a way of
staying up to date.

`{% fragment_links %}` accepts the keys `windsbraut.apps` declares and no
others: a misspelt key is a row that silently is not there, so it stops the
page under DEBUG the way a missing icon does. `tests/test_fragments.py` holds
the keys the layout reads to the slots the app declares, which is where the
one test about the menu's key went.

`.footer li + li` becomes `.footer__grid li + li` - it said the footer and
meant the columns, and the row at the bottom is the first list to tell them
apart.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 19:24:21 +02:00
assets Close the page with where else the Verein is, and name the newsletter 2026-09-19 19:24:21 +02:00
tests Close the page with where else the Verein is, and name the newsletter 2026-09-19 19:24:21 +02:00
windsbraut Close the page with where else the Verein is, and name the newsletter 2026-09-19 19:24:21 +02:00
.dockerignore Let the Verein write the Impressum, and the browser pick its own format 2026-09-03 13:58:49 +02:00
.editorconfig Build the assets with vite, and lint like bokstaever does 2026-09-02 16:59:50 +02:00
.gitattributes Convert the slides out of Display P3 before scaling them 2026-09-02 20:30:04 +02:00
.gitignore Let the Verein write the Impressum, and the browser pick its own format 2026-09-03 13:58:49 +02:00
.prettierignore Keep the tool caches out of prettier's way 2026-09-02 23:11:13 +02:00
.prettierrc Build the assets with vite, and lint like bokstaever does 2026-09-02 16:59:50 +02:00
.python-version Build the assets with vite, and lint like bokstaever does 2026-09-02 16:59:50 +02:00
.stylelintrc.json Give every photograph the same enlarged view 2026-09-03 00:17:20 +02:00
crest.py crest.py: draw the favicon on a navy tile instead of in the page's two schemes 2026-09-04 13:16:14 +02:00
Dockerfile Let the head describe the page instead of the site 2026-09-02 21:06:44 +02:00
eslint.config.js Build the assets with vite, and lint like bokstaever does 2026-09-02 16:59:50 +02:00
package-lock.json Take the export precision out of the drawings and the OTF off the wordmark 2026-09-03 13:26:11 +02:00
package.json Let the Verein write the Impressum, and the browser pick its own format 2026-09-03 13:58:49 +02:00
pyproject.toml Ask for the greeting and the menu by name instead of by slug 2026-09-04 19:44:24 +02:00
README.md Close the page with where else the Verein is, and name the newsletter 2026-09-19 19:24:21 +02:00
resize.py Resize the slides through imagevariants rather than beside it 2026-09-03 14:26:44 +02:00
svgo.config.js Take the export precision out of the drawings and the OTF off the wordmark 2026-09-03 13:26:11 +02:00
uv.lock Pin imagevariants in the lock 2026-09-03 18:06:13 +02:00
vite.config.js Build the stylesheets for the browsers the site promises 2026-09-03 13:10:32 +02:00
vitest.config.js Build the assets with vite, and lint like bokstaever does 2026-09-02 16:59:50 +02:00

windsbraut-ev.de

Layout and static pages of windsbraut-ev.de as an extension app for bokstaever.

bokstaever loads the app named in BOKSTAEVER_EXTENSION before its own apps: the templates in windsbraut/templates/default/ replace the default layout, windsbraut/urls.py adds the two urls it serves itself, Impressum and Schiffsdaten. Everything else - posts, pages, the image feed and the dashboard - is bokstaever.

Layout

assets/js/          the bundle entries and the behaviours they load: nav.js
                    (the menu), ship.js (the drawing), lightbox.js and
                    gallery.js (the enlarged view, see below)
assets/scss/        stylesheets; _tokens.scss holds the palette and the scales,
                    _kit.scss the breakpoints and the mixins, _layers.scss the
                    order the three bundles settle in
assets/fonts/       the text face and the display face, both self-hosted
assets/slides/      large originals of the slide images (git-lfs), resized
                    by resize.py while the container image is built
assets/logo/        the crest as Illustrator exported it, made transparent
                    by crest.py
tests/              the calendar reader, the menu, the views, the sitemap and
                    the two build scripts, run with plain pytest
windsbraut/         the Django app: templates, urls, views and static files

Design

The palette comes from the ship. The greens and the crimson are the values used inside windsbraut.svg; the page ground is the grey-green of the Watt at low tide rather than the cream a heritage site usually reaches for, and the muted ink every caption and date is set in is a green-grey of the same family, because a warm neutral on that ground reads as dirt. They are defined once, in assets/scss/_tokens.scss, together with the type scale and the spacing scale. No other stylesheet writes a literal colour - that is the one rule here with no exceptions, and it is worth keeping that way. Sizes are looser: the scales are tokens, and a constant that appears in one rule and nowhere else stays where it is used, because a token used once is a name with no second reader.

The four breakpoints live in _kit.scss rather than with the tokens, because a @media prelude cannot read a custom property: they have to be Sass variables, and _kit.scss is the file that emits nothing and can therefore be used by every bundle without reprinting the palette into it.

Text is set in Literata, ragged right and hyphenated, at about 65 characters to the line; the chancery face is the wordmark only. Tex Gyre Chorus blocks briefly and then stays, because swapping into it would change the shape of the wordmark after the fact; Literata swaps.

The wordmark ships as texgyrechorus.woff2, a subset of the OTF beside it - ASCII, the umlauts and the eszett, which is a good deal more than the two strings it sets but enough that changing them cannot silently lose a glyph. The OTF is the source and is not served: 101 kB of a format that carries no compression and that Caddy does not gzip either, against 15 kB. npm run font regenerates it.

The posts are log entries - several of them are titled "Logbuch" - so the front page sets them as one, with the date in its own column. Schiffsdaten is the one page that borrows from a sea chart: the technical data is tabulated with lining figures and the drawings break out of the prose measure.

Static assets

The bundles are built by vite from assets/ into windsbraut/static/windsbraut/dist, with a content hash in every file name. The site cannot write into bokstaever's own dist/, which belongs to the base image, so it builds below its own static directory instead; django's app directory finder serves it and bokstaever's {% vite %} tag looks for an extension's bundles at exactly that path.

Templates never name a bundle file directly:

{% load vite %}
{% vite "windsbraut/app" "css" %}          {# in the head #}
{% vite "windsbraut/schiffsdaten" "js" %}  {# at the end of the body #}

The entry names are the keys of ENTRIES in vite.config.js; adding a bundle means adding one there and referencing that name. A name that is in neither manifest raises ImproperlyConfigured instead of silently rendering nothing, and {% vite "default/imageFeed" "js" %} in default/images.html reaches bokstaever's own bundle the same way.

Everything under windsbraut/static/windsbraut/assets/ is unbundled and stays on {% static %}: the slides, the film, the crest and the two drawings. The fonts live in assets/fonts instead, so that vite hashes them along with the css that declares them.

resize.py writes each slide at every width its original can fill and in three formats - AVIF, WebP and JPEG, in that order - and {% slide %} renders the whole <picture> from what it recorded, so a browser takes the first type it knows and one that knows none of them still gets the JPEG. Every file carries a hash of its own bytes in its name, which is what puts them in the deployment's year-long immutable bucket rather than its hourly one; that is also why windsbraut/slides.py reads slides.json instead of working the names out. A slide the manifest does not know renders an empty slot, because a name that was not read from the manifest is a name that cannot exist.

The drawings are Illustrator exports and are optimised by svgo against svgo.config.js, with cleanupIds off: the ship is inlined into the page so that ship.js can reach eleven groups inside it by name, and renaming those ids would take the drawing apart. npm run svg re-runs it over the two hand-drawn files; the crest goes through the same pass inside crest.py, so regenerating it cannot put the export precision back.

The crest hangs from the top right corner of the masthead, twice the bar's height, on a disc of the page ground - its two rings of lettering need that size to be read at all, and the disc is what lets it overhang the page without tangling with what is underneath. Below $bp-desk there is no corner to spare and it is not drawn.

The crest is generated rather than drawn. Illustrator drew it in black on white, so crest.py makes every black the page's ink and every white the page ground, and writes one file per colour scheme because a <picture> cannot reach the stylesheet's custom properties. The white shapes stay filled rather than becoming transparent: the sails and the disc behind the ship are the drawing's own depth, and letting the ground through them put the mizzen mast and the shrouds back through the sailcloth. Filled costs nothing, since the ground they are filled with is the disc the crest sits on:

The favicon falls out of the same script as a third output. The whole crest is grey noise at sixteen pixels, so the icon is the drawing clipped to Illustrator's own inner circle, where the ship has the frame to itself. It is one file for both schemes, and follows neither: the disc is inset on a rounded tile of $navy, drawn in the night's ink, and a navy tile reads on a light tab bar and a dark one alike. The calendar's tab icon is the same tile around the other mark, so the two tabs are a pair.

uv run python crest.py assets/logo windsbraut/static/windsbraut/assets

Content the layout reads

Four blocks of the layout are Textbausteine: text the layout asks bokstaever for by key, with no url of their own, no draft flag and no place in the menu. windsbraut/apps.py declares the ones this layout expects, with the label and the hint the editors read, and the dashboard offers exactly those under Textbausteine - there is no slug to spell right and nothing to remember to tick. The greeting and the menu used to be pages marked as drafts, which was a trick and leaked like one: a signed-in visitor could open /navigation and read the menu as a page.

Three of the four are markdown lists of links and are parsed rather than rendered, by windsbraut.content.links: the layout decides what each list is drawn as - a row of nav links, a column in the footer, a line across the bottom of the page - and markdown that arrived as a ready-made <ul> would decide it instead. {% fragment_links "social" %} is how a template asks for one, and a key windsbraut/apps.py does not declare stops the page under DEBUG the way a missing icon does, because a misspelt key is a row that silently is not there.

  • Begrüßung auf der Startseite (greeting) is the text beside the slides on the front page. Its first line has to be a markdown heading: the front page takes its h1 from it, so a greeting that opens with a paragraph leaves the front page with no h1 at all. Unwritten, the page shows the text the site shipped with, which is the body of the {% fragment %} tag in default/index.html.

  • Hauptmenü (menu) is the menu, as a markdown list of links in the order they should appear:

    - [Mitsegeln](/mitsegeln)
    - [Bildungsarbeit](/bildungsarbeit)
    

    Unwritten, the menu falls back to bokstaever's own SITEMAP, which is every page with "show in menu" set, ordered by headline. Either way the menu ends with Das Schiff, Bilder and the Törnplan, which the layout adds itself.

  • Newsletter (newsletter) is a list of the same shape, and it stands at the top of "Auf dem Laufenden bleiben" in the footer, above the RSS feed and the calendar the site serves itself. It is a Textbaustein because the signup lives on somebody else's site and this one cannot know where; unwritten, the column is the two entries it always was.

  • Soziale Netzwerke (social) is the list that closes the page: where the Verein is to be found off this site, as words rather than logos. The site draws three glyphs of its own and they are all line work out of the same hand, so three foreign trademarks would be the only shapes on it that do not come from this ship.

    Every link in it carries rel="me". That is what verifies the site against a Mastodon profile linking back to it - fill the profile's website field in on norden.social and the check turns green - and it is true of the other profiles in exactly the same sense, so it goes on all of them rather than on the one with a use for it. Unwritten, the bottom line is the Impressum alone, which is where that link now lives: it sat in "Auf dem Laufenden bleiben" until there was a line for it, and it never was a way of staying up to date.

    The Signal newsletter is deliberately not linked from here. An invite with no sentence beside it saying who writes and how often is a link nobody follows, so it belongs on a page the Verein writes - which pages and the menu can already do, with no code at all.

The Impressum is a page, an ordinary published one under the slug impressum. The address, the mail address and the name of the current Vorsitzender are what German law asks the site to publish and the one part of it that changes on nobody else's schedule, which is why it is a page and not a template any more.

The url stays this app's own: windsbraut/urls.py is included ahead of bokstaever's page route, so /impressum is served by ImpressumView - and while there is no page to show it renders the text the site shipped with, from windsbraut/templates/windsbraut/impressum.html. That branch is what keeps a legally required url answering on the day a new deployment lands before anybody has opened the dashboard, so keep the names in it right. Draft means here what it means everywhere else on the site: the editors see the page, the public keeps reading the shipped text until it is published - and from that day /impressum is in bokstaever's own page sitemap, which is why windsbraut/sitemaps.py stops listing the route itself.

The page carries its own image, so the header photograph is the editors' choice; the fallback keeps the one it always had. Markdown has no <address> in it - set the page's type to "Markdown with inline HTML" to keep the postal address marked up as one.

The slots are declared in windsbraut/apps.py and read in windsbraut/content.py, default/index.html and the footer in default/base.html. bokstaever's markdown keeps hard line breaks, so each paragraph has to be written as one line.

The front page also shows the next three dates from the calendar the Verein keeps at kalender.windsbraut-ev.de, read from its iCalendar feed by windsbraut/calendar.py. The feed is fetched at most twice a day and a failure is remembered for five minutes, because the request that finds the entry cold is the one that waits for it; a calendar that cannot be reached simply leaves the block off the page. The dates are read for the first page only, which is the only one that draws them.

The document head is one tag. {% seo %} in base.html renders the title, the meta description, the canonical url, the Open Graph and Twitter tags, the feed link and the JSON-LD, and works all of it out of what the view is showing - a post and a page describe themselves out of their own text and their own image, the index and the paginated pages out of BOKSTAEVER_SITE_DESCRIPTION. Nothing in this repository writes a <title> any more.

The views that show no object say so themselves, in windsbraut/views.py:

class ImpressumView(StaticPageView):
    title = "Impressum"
    description = "Kontakt und Anbieterkennzeichnung des Windsbraut e.V., ..."

The title is the subject only; the tag appends the site name. ImpressumView pushes that pair only while there is no impressum page to show; once there is one it puts the row in the context instead and the tag describes the page out of the page, like every other page. Those two urls are also the only public ones of the site bokstaever does not know it serves, so windsbraut/sitemaps.py adds them to bokstaever's sitemap and windsbraut/urls.py shadows the sitemap.xml route to serve it - the same move the front page makes for the index. The Impressum leaves that list the day the page is published, since bokstaever lists the page at the same url from then on.

BOKSTAEVER_SITE_URL is the origin all of that is written in terms of. It is set in the Dockerfile rather than by the deployment, because it is the site's own address. Unset, absolute urls follow whichever Host header a request arrived with and the page cache splits per host; manage.py check --deploy warns about it.

Container image

The image builds on top of the bokstaever image:

podman build --tag bokstaever /path/to/bokstaever
podman build --build-arg BASE_IMAGE=localhost/bokstaever --tag windsbraut .

The image sets what belongs to the site itself: BOKSTAEVER_EXTENSION, the site name, description and url, the image feed, language and time zone. The deployment provides the rest of bokstaever's environment (DATABASE_URL, DJANGO_SECRET_KEY, DJANGO_ALLOWED_HOSTS, DJANGO_CSRF_TRUSTED_ORIGINS, DJANGO_REDIS_QUEUE, CACHE_URL, ...).

Development

Check the repository out next to bokstaever, build the stylesheets and the slides once and point bokstaever at the app:

uv sync && npm install && npm run build
uv run python resize.py assets/slides windsbraut/static/windsbraut/assets
cd ../bokstaever/src
PYTHONPATH=../../windsbraut-site BOKSTAEVER_EXTENSION=windsbraut \
    BOKSTAEVER_IMAGE_FEED=true uv run python manage.py runserver

npm run dev in a second shell rebuilds the bundles as they change. The build has to have run at least once before bokstaever's collectstatic, since the templates resolve their assets through the manifest it writes.

git lfs pull is required for the slide originals and the film.

Code quality

npm run lint    # eslint, prettier, djlint, ruff
npm run fix     # the same, writing the fixes it can make
npm test        # vitest, then pytest

npm run lint builds the bundles as one of its steps, which is the only thing that compiles the stylesheets: stylelint parses SCSS but does not resolve @use, so a renamed partial or a missing mixin is clean to every other check in the list and fails at build time.

npm is the entry point for both languages, the python tools are invoked through uv run. The python tests need neither a database nor the bokstaever checkout: tests/conftest.py configures django by hand and registers four modules standing in for what the app imports from the cms - the page model, the fragments, the sitemap base class and the index view - which is the image this app is installed into rather than a dependency of the repository. Everything they need is configured in pyproject.toml; ruff does the import sorting and wraps at 88 while the lint limit is 120, and the two djlint rules that look for a <title> and a meta description in the template are switched off there, with the reason.

Content migration

The content of the previous installation (bokstaever 2020, template bundle) is imported with bokstaever's import_legacy management command, see its docstring for the export and the media layout it expects. Of the two pages that were file pages there, Schiffsdaten is a template in this app and Impressum is a published page again, under the slug the section above names. The greeting and the menu were never pages on that installation either; they are Textbausteine now and there is nothing to import into them.