feat(urd): mærket bliver ð, og frontend-bygget bliver deterministisk

## Mærket

Urds oplagte mærke var ᚢ uruz — bogstavet navnet begynder med i den ældre
futhark, præcis som Verdandes er ᚹ wunjo. Det blev tegnet sådan først.

Men uruz er allerede et af husets egne produkter, og dets egen
`scripts/gen-icons.ts` siger i sin kommentar at den "draws the Uruz rune (ᚢ)
in rune-gold" med "a tall left vertical, a shoulder across the top, and a
shorter" — altså samme glyf, samme konstruktion, kun en anden farve. To af
samme persons apps side om side på en hjemmeskærm, hvilket er nøjagtig det
sted mærket skal kunne skelnes.

ᚦ thurisaz blev forkastet et niveau nede af samme grund: en stav med en
trekant til højre, og det er ᚹ også. Ved 16px er de én form.

Så bogstavet frem for runen: ð, ethet i Urðr. Centreret ved at tælle malede
pixels frem for ud fra path-tallene, fordi en centreret kasse ikke er en
centreret glyf:

  verdande ᚹ   blæk x  53–108  midte 80.5  ·  y  18–131  midte 74.5
  urd      ð   blæk x  39–110  midte 74.5  ·  y  15–134  midte 74.5

Verdandes +5.5 i x er dens egen bevidste forskydning — en tung stav ved
siden af et let flag skal sidde til højre for midten for at se centreret ud.
ð er symmetrisk nok til at undvære den, og de to er enige i y.

Læsbarhed, målt: ð læses fra 32px og op. Ved 16px er det en klat, og det er
ᚹ også — en favicon er ikke det, der skiller to installerede apps i en dock.

`productRune` hedder nu `productMark`, fordi ð ikke er en rune.

## Determinisme

SvelteKits `kit.version.name` er som standard `Date.now()`. Millisekundet
landede i version.json, i index.html og i service-workeren, ændrede
indholdshashen på hver chunk der refererer dem, og dermed deres filnavne. To
bygninger af samme commit havde forskellige filer i sig.

Dockerfilen har i et år sagt at `-trimpath` og en tom buildid "keep the
output reproducible". Flagene gjorde deres arbejde; frontenden i binæren bar
et tidsstempel. Sætningen var falsk, og ingen læste den.

Fastgjort til VERSION, som udgivelsen allerede sender ind, og `dev` når
intet sætter den. Målt ved at bygge to gange og diffe træet, før og efter.
Prisen er at et lokalt `npm run build` ikke længere ligner en ny version for
service-workeren — hvilket kun betyder noget på en udrullet instans, hvor
udgivelsen sender det rigtige mærkat.

Det gør også CI's nye påstand mulig: de to images deler alle lag, så urd
bærer samme binær som verdande frem for en genbygning der tilfældigvis
matcher.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kristianwind
2026-10-07 10:10:30 +02:00
co-authored by Claude Opus 5
parent b451fd585d
commit ff988d26bb
17 changed files with 463 additions and 33 deletions
+14
View File
@@ -277,3 +277,17 @@ jobs:
set -eu
docker buildx create --use --name ci-verdande 2>/dev/null || docker buildx use ci-verdande
docker buildx build --platform linux/amd64 --output type=cacheonly .
# Begge udgaver, fordi EDITION rører den sidste stage: en Dockerfile, der
# ikke længere bygger med den, skal fejle her og ikke på et mærkat.
docker buildx build --platform linux/amd64 --build-arg EDITION=notes --output type=cacheonly .
# Hvad der IKKE er her, og hvorfor, så den næste læser ikke tror det er glemt:
# GitHubs ci.yml har et trin, der henter begge images ind i daemonen og
# påstår, at de deler alle lag — altså at verdande og urd bærer samme binær.
# Det kræver `--load` to gange, og det er et halvt minut per image på en
# runner, der deles med Yggdrasils produktionscontainere og allerede har
# dræbt en kørsel på hukommelse (exit 137). Påstanden er den samme kode
# begge steder, så det er nok at evaluere den ét sted — og GitHub er
# autoritativ for de offentlige projekter.
#
# Det, der SKAL være her, er ovenstående: at begge varianter kan bygges.
+50
View File
@@ -123,9 +123,59 @@ jobs:
# Build only — publishing happens on a tag, in release.yml. This job exists
# so a Dockerfile that no longer builds fails on the pull request rather
# than at release time.
#
# Loaded into the local daemon rather than discarded, because the step below
# has to inspect what came out.
- uses: docker/build-push-action@v6
with:
context: .
push: false
load: true
tags: verdande:ci
cache-from: type=gha
cache-to: type=gha,mode=max
- uses: docker/build-push-action@v6
with:
context: .
push: false
load: true
tags: urd:ci
build-args: EDITION=notes
cache-from: type=gha
cache-to: type=gha,mode=max
# The claim this repository makes about urd is that it is the same program,
# not a fork — and that claim is checkable rather than a sentence in a README.
#
# Stages 1 and 2 are identical for both builds, and the edition is set with
# ENV, which is image *config* rather than a layer. So the two images must
# have the same layer digests and differ only in that one variable. A change
# that made them diverge — a second COPY, a conditional RUN, a separate build
# of the binary — would quietly turn "one source" into two, and the only
# symptom would be a bug fixed in one product and not the other.
#
# This was impossible until the frontend build became deterministic: SvelteKit
# put Date.now() in version.json, so no two builds ever matched. See the note
# in web/svelte.config.js.
- name: The two images are one build
run: |
set -eu
layers() { docker image inspect "$1" --format '{{json .RootFS.Layers}}'; }
if [ "$(layers verdande:ci)" != "$(layers urd:ci)" ]; then
echo "The verdande and urd images do not share their layers:"
layers verdande:ci
layers urd:ci
echo "They are built from one source, so this is either a Dockerfile that"
echo "now builds the binary twice, or a stage that is no longer shared."
exit 1
fi
echo "Shared layers: $(layers verdande:ci | tr ',' '\n' | wc -l)"
# And the half that stops the check above from passing over two identical
# images: they must actually differ in the one way they are supposed to.
edition() { docker image inspect "$1" --format '{{json .Config.Env}}' | tr ',' '\n' | grep VERDANDE_EDITION; }
echo "verdande: $(edition verdande:ci)"
echo "urd: $(edition urd:ci)"
edition verdande:ci | grep -q 'VERDANDE_EDITION=full'
edition urd:ci | grep -q 'VERDANDE_EDITION=notes'
+35 -1
View File
@@ -20,6 +20,14 @@ FROM --platform=$BUILDPLATFORM node:22-alpine AS web
WORKDIR /src
COPY web/ ./
# Passed into the frontend build, because SvelteKit's default build version is
# `Date.now()` — a millisecond that landed in version.json, in index.html and in
# the service worker, changed the content hash of every chunk referencing them, and
# so changed those chunks' filenames. Every build of one commit produced different
# bytes. See the long note in web/svelte.config.js.
ARG VERSION=dev
ENV VERDANDE_VERSION=${VERSION}
# The frontend is built when it is present. During backend-only development it is
# not, and the build must still produce a working image rather than failing on a
# missing directory — so this stage falls back to a placeholder page. The Go stage
@@ -58,6 +66,12 @@ ARG TARGETARCH=amd64
# CGO off is what makes this binary static, and it is only possible because the
# SQLite driver is pure Go. -trimpath and the empty buildid keep the output
# reproducible; -s -w drop the symbol and DWARF tables, which is most of the size.
#
# "Reproducible" was false for a year and nobody read the sentence that said it:
# the flags above do their job, and the frontend embedded below carried a
# millisecond timestamp, so every build of one commit produced a different binary.
# Fixed in web/svelte.config.js, and CI now asserts that the two images' layers
# match rather than trusting this comment.
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build \
-tags embedweb \
-trimpath \
@@ -65,8 +79,27 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build \
-o /verdande ./cmd/verdande
# --- 3. what actually ships --------------------------------------------------
#
# Two images come out of this stage, and the only difference between them is one
# environment variable:
#
# docker build . → verdande
# docker build --build-arg EDITION=notes → urd
#
# Stages 1 and 2 are byte-identical for both, so the binary in the two images is
# the same build rather than a rebuild that happens to match. That is the point:
# urd is not a fork, and "built from the same source" is a property of the layers
# instead of a sentence in a README. ENV is image *config* rather than a layer, so
# the two images share every layer digest and differ only in that config — which CI
# asserts on every pull request.
FROM gcr.io/distroless/static-debian12:nonroot
# Declared here as well as used here: an ARG is scoped to the stage it appears in,
# so one above the FROM above would be invisible down here. `full` by default,
# because that is what this program has always been and an unqualified build must
# not quietly produce the other product.
ARG EDITION=full
# SQLite writes timestamps and verdande resolves due dates in the user's timezone,
# so the container needs a tz database and CA certificates for outbound SMTP.
COPY --from=build /usr/local/go/lib/time/zoneinfo.zip /zoneinfo.zip
@@ -78,7 +111,8 @@ COPY --from=build /verdande /verdande
# nightly backups.
VOLUME ["/data"]
ENV VERDANDE_DATA_DIR=/data \
VERDANDE_ADDR=:8080
VERDANDE_ADDR=:8080 \
VERDANDE_EDITION=${EDITION}
EXPOSE 8080
USER nonroot:nonroot
+52
View File
@@ -13,6 +13,58 @@ Set `VERDANDE_EDITION=notes` and the instance is urd: the task routes are not
mounted, the sidebar has no way to them, search answers with notes only, and the
program calls itself urd in its tab, its wordmark and its web manifest.
## Running the notes edition
There is one image. The edition is an environment variable on the container:
```
docker run -e VERDANDE_EDITION=notes -v urd-data:/data -p 8080:8080 \
ghcr.io/kristianwind/verdande:latest
```
No second image and no rune of its own, deliberately.
The edition is for an operator who wants an instance that genuinely has no task
routes — the routes are absent from the router, not hidden in the interface. It is
**not** how urd is normally reached. That is being built as a second face of one
instance, with its own entry point and its own web manifest, so that both can be
installed as separate apps while sharing one database, one login and one set of
links. Until that lands, this page describes the edition only.
Two details that surprise people who look inside:
- **The binary is called `/verdande`** and the database file is `verdande.db`, in
both editions. Not an oversight: it is the same binary, and renaming the file per
edition would be a migration hazard in exchange for nothing a user sees.
- **The icons for both editions ship in the one image.** The server decides which
to serve at `/icon.svg`, because Safari looks for `apple-touch-icon.png` by name
and never reads the manifest, so renaming paths would have left iOS with the
wrong mark.
## Moving between the two, measured
A `/data` directory moves between the editions in **both directions, losslessly**.
Same binary, same schema, same migrations — so this is not a feature that was built,
it is a property that was checked:
| step | result |
|---|---|
| verdande creates a notebook, a note in it, and a task | 1 note, 1 task |
| the same directory, `VERDANDE_EDITION=notes` | `needs_setup: false` — same account. The note keeps its title, its body **and its `project_id`**. `/api/v1/tasks` → 404 |
| the same directory, back to `full` | the task is still there; so is the note |
The ownership stayed at the panel's service account through all three.
So the way to move notes is to change the variable. `GET /export/notes.zip` is for
getting notes **into other programs** — it writes one `.md` per note and carries the
body and nothing else: no notebook, no sharing, no attachments, no pinned flag. That
is the right division of labour, because the database already holds those.
!!! note "The task rows stay"
An instance run as `notes` still has its tasks in the database; they are simply
not served. Harmless on a fresh instance, dead weight on a converted one — and
worth knowing before handing a converted instance to somebody else.
## Why one codebase and not a fork
The two share everything that is hard: accounts, sessions, passkeys, sharing,
+15
View File
@@ -83,6 +83,9 @@ type Server struct {
shell []byte
manifest []byte
// Icon paths this edition serves from another file. Empty in the full edition.
assets map[string]string
router chi.Router
}
@@ -125,6 +128,11 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
} else {
s.shell, s.manifest = shell, manifest
}
s.assets = resolveNotesAssets(web, func(name, alias string) {
log.Error("this edition has no icon of its own at that path; it will serve verdande's",
"path", name, "wanted", alias)
})
}
if s.shell != nil {
@@ -789,6 +797,13 @@ func (s *Server) serveWeb(w http.ResponseWriter, r *http.Request) {
name = "index.html"
}
// This edition's own icon, where it has one. Before the Open rather than after,
// unlike the shell below: these are real files, so there is no fallback to get
// in the way, and resolveNotesAssets has already checked each one exists.
if alias, ok := s.assets[name]; ok {
name = alias
}
f, err := s.web.Open(name)
if errors.Is(err, fs.ErrNotExist) {
// A client-side route: hand back the shell and let the app route it.
+38
View File
@@ -137,3 +137,41 @@ func contentTypeFor(name string) string {
}
return "text/html; charset=utf-8"
}
// notesAssetAliases maps the icon paths the shell and the manifest already point
// at to urd's own files. Both sets are in the binary; the server decides.
//
// Aliased rather than renamed in the manifest, and the reason is the third entry.
// Safari ignores a manifest's icons entirely and looks for `apple-touch-icon.png`
// BY NAME — so pointing the manifest at urd-icon-192.png would have left iOS with
// Verdande's mark, or, if that file were renamed too, with a screenshot of the page
// as its home-screen icon. Keeping the paths and swapping the bytes behind them
// fixes all four at once, including the favicon in `app.html`, which is not in the
// manifest either.
var notesAssetAliases = map[string]string{
"icon.svg": "urd-icon.svg",
"icon-192.png": "urd-icon-192.png",
"icon-512.png": "urd-icon-512.png",
"apple-touch-icon.png": "urd-apple-touch-icon.png",
}
// resolveNotesAssets returns the aliases that have a file behind them, and reports
// the ones that do not.
//
// Resolved once at startup rather than per request, so a missing file is one loud
// line in the log instead of a silent fallback on every page load. The fallback is
// still to Verdande's icon rather than to nothing: an icon is the one asset whose
// absence turns into a broken-image placeholder on the Updates screen — which is
// exactly how four releases of nolimit-views shipped with no icon — so serving the
// wrong mark beats serving none.
func resolveNotesAssets(web fs.FS, missing func(name, alias string)) map[string]string {
out := map[string]string{}
for name, alias := range notesAssetAliases {
if _, err := fs.Stat(web, alias); err != nil {
missing(name, alias)
continue
}
out[name] = alias
}
return out
}
+81
View File
@@ -233,3 +233,84 @@ func TestTheFullEditionServesTheBuiltFilesUnchanged(t *testing.T) {
})
}
}
// webWithIcons is builtWeb plus both editions' icons, as a real build has them.
func webWithIcons() fstest.MapFS {
web := builtWeb()
for _, name := range []string{
"icon.svg", "icon-192.png", "icon-512.png", "apple-touch-icon.png",
"urd-icon.svg", "urd-icon-192.png", "urd-icon-512.png", "urd-apple-touch-icon.png",
} {
web[name] = &fstest.MapFile{Data: []byte("bytes of " + name)}
}
return web
}
// TestEachEditionServesItsOwnMark.
//
// Four paths, named one by one rather than counted: the favicon in app.html, the
// manifest's two, and the one Safari looks for by name. The last is the reason
// this is an alias rather than a rewritten manifest — Safari does not read the
// manifest at all.
func TestEachEditionServesItsOwnMark(t *testing.T) {
for _, tc := range []struct {
edition string
want map[string]string
}{
{"notes", map[string]string{
"icon.svg": "urd-icon.svg",
"icon-192.png": "urd-icon-192.png",
"icon-512.png": "urd-icon-512.png",
"apple-touch-icon.png": "urd-apple-touch-icon.png",
}},
// The control, and it is the half that stops this being a swap rather than
// a choice: the full edition must resolve nothing.
{"full", map[string]string{}},
} {
t.Run(tc.edition, func(t *testing.T) {
srv := serverWithWeb(t, tc.edition, webWithIcons())
if len(srv.assets) != len(tc.want) {
t.Errorf("%s edition aliases %d paths, want %d: %v", tc.edition, len(srv.assets), len(tc.want), srv.assets)
}
for path, alias := range tc.want {
if srv.assets[path] != alias {
t.Errorf("%s: %s resolves to %q, want %q", tc.edition, path, srv.assets[path], alias)
}
}
// And nothing else: an alias on a build asset would serve one file's
// bytes under another's hashed, immutable name.
for path := range srv.assets {
if _, ok := tc.want[path]; !ok {
t.Errorf("%s edition aliases %s, which is not an icon", tc.edition, path)
}
}
t.Logf("%s: %d aliased paths", tc.edition, len(srv.assets))
})
}
}
// TestAMissingMarkIsReportedAndFallsBack.
//
// The fallback is deliberate and it is the only place in this file that prefers
// carrying on to failing: an icon's absence is a broken-image placeholder on the
// one screen somebody reads before deciding whether to press update, so the wrong
// mark beats no mark. What must not happen is it being quiet.
func TestAMissingMarkIsReportedAndFallsBack(t *testing.T) {
web := webWithIcons()
delete(web, "urd-icon-512.png")
var reported []string
got := resolveNotesAssets(web, func(name, alias string) {
reported = append(reported, name+" → "+alias)
})
if len(reported) != 1 || reported[0] != "icon-512.png → urd-icon-512.png" {
t.Errorf("the missing icon was reported as %v, want exactly one naming both paths", reported)
}
if _, ok := got["icon-512.png"]; ok {
t.Error("a path with no file behind it was aliased anyway")
}
if got["icon.svg"] != "urd-icon.svg" {
t.Error("one missing icon took the other three with it")
}
}
+29 -5
View File
@@ -141,11 +141,11 @@ test('fanen, mærket og manifestet siger alle urd', async ({ page }) => {
const mærke = page.getByRole('navigation', { name: 'Hovedmenu' }).locator('.brand');
await expect(mærke.locator('.name')).toHaveText('urd');
// ᚢ uruz, det bogstav navnet begynder med i den ældre futhark — og ikke ᚹ
// wunjo, som er Verdandes. Begge er navngivne værdier frem for "der er en
// rune": en tom span og den forkerte rune ser ens ud for en prøve, der kun
// spørger om der står noget.
await expect(mærke.locator('.rune')).toHaveText('ᚢ');
// ð, ikke ᚹ wunjo og ikke ᚢ uruz. Begge de to er navngivne værdier frem for
// "der står noget": en tom span, Verdandes mærke og uruz-appens ser ens ud for
// en prøve, der kun spørger, om feltet er udfyldt.
await expect(mærke.locator('.rune')).toHaveText('ð');
await expect(mærke.locator('.rune')).not.toHaveText('ᚢ');
const manifest = await (await page.request.get('/manifest.webmanifest')).json();
expect(manifest.name).toBe('urd');
@@ -246,3 +246,27 @@ test('ingen side kalder en rute, udgaven ikke har', async ({ page }) => {
// Tallet med på det grønne: elleve sider og et projekt, frem for "ingen fejl".
expect(trouble, `besøgte ${sider.length} sider`).toEqual([]);
});
test('ikonet er urds, hele vejen ud', async ({ page }) => {
// Målt på stregerne i SVG'en frem for på "der kom et billede": en tom
// respons, verdandes mærke og urds ser alle ens ud for en prøve, der kun
// spørger, om der står noget.
const svg = await (await page.request.get('/icon.svg')).text();
expect(svg, 'ð: skålen').toContain('cx="32" cy="42" r="12"');
expect(svg, 'ð: ascenderen').toContain('M44 36L27 10');
expect(svg, 'ð: tværstregen, som er det der skiller ð fra et d').toContain('M25 20h18');
expect(svg, 'og ikke ᚹ wunjos flag').not.toContain('M26 15l17');
// De tre PNG'er har ingen streger at læse, så de måles på at de er urds —
// sammenlignet med filen under sit eget navn, som ligger i samme byg.
for (const [sti, egen] of [
['/icon-192.png', '/urd-icon-192.png'],
['/icon-512.png', '/urd-icon-512.png'],
['/apple-touch-icon.png', '/urd-apple-touch-icon.png']
]) {
const a = await (await page.request.get(sti)).body();
const b = await (await page.request.get(egen)).body();
expect(a.length, `${sti} er tom`).toBeGreaterThan(1000);
expect(a.equals(b), `${sti} er ikke urds`).toBe(true);
}
});
+8 -1
View File
@@ -5498,7 +5498,7 @@ test('the full edition still calls itself verdande', async ({ page }) => {
const brand = page.getByRole('navigation', { name: /Hovedmenu|Main menu/ }).locator('.brand');
await expect(brand.locator('.name')).toHaveText('verdande');
// ᚹ wunjo, not ᚢ uruz.
// ᚹ wunjo, not ð.
await expect(brand.locator('.rune')).toHaveText('ᚹ');
await page.goto('/noter');
@@ -5507,4 +5507,11 @@ test('the full edition still calls itself verdande', async ({ page }) => {
const manifest = await (await page.request.get('/manifest.webmanifest')).json();
expect(manifest.name).toBe('verdande');
expect(manifest.description).toBe('Opgaver og projekter, delt.');
// The other half of the icon alias: this edition must still get Wunjo. Both
// sets of files are in the same binary, so a swap that fired in both editions
// would pass in edition.spec.js and quietly change this program's mark.
const icon = await (await page.request.get('/icon.svg')).text();
expect(icon).toContain('M26 15l17');
expect(icon).not.toContain('M44 36L27 10');
});
+41 -10
View File
@@ -1,40 +1,71 @@
/**
* Renders static/icon.svg to the PNGs the manifest and iOS need.
* Renders each edition's icon SVG to the PNGs the manifest and iOS need.
*
* node scripts/generate-icons.js
*
* A script rather than a test, so CI never runs it and never rewrites a tracked
* binary. Run it when the icon changes; the smoke test fetches every icon the
* binary. Run it when an icon changes; the smoke test fetches every icon the
* manifest names, so a reference with no file behind it fails there.
*
* It drives Chromium because there is no SVG renderer on this machine — which is
* why these references were once removed rather than left pointing at files that
* did not exist. Playwright is a renderer, and it is already a dependency.
*
* Two sets, because there are two editions. Both ship in the one binary and the
* server decides which to serve at /icon.svg and friends — see
* internal/httpapi/shell.go. The alternative, rewriting the manifest to point at
* urd-icon-192.png, would have left `apple-touch-icon.png` behind: Safari looks
* for that one BY NAME and ignores the manifest entirely, so a renamed path there
* is a home-screen icon that is a screenshot of the page.
*/
import { chromium } from '@playwright/test';
import { readFileSync, writeFileSync } from 'node:fs';
// The three sizes, and why each is here.
const SIZES = [
// The two the manifest asks for: 192 for a launcher, 512 for a splash screen.
{ file: 'static/icon-192.png', size: 192 },
{ file: 'static/icon-512.png', size: 512 },
// And the one Safari wants by name. It ignores the manifest's icons entirely
// and looks for this, which is why an installed PWA on iOS otherwise gets a
// screenshot of the page as its home-screen icon.
{ file: 'static/apple-touch-icon.png', size: 180 }
{ suffix: '-192.png', size: 192 },
{ suffix: '-512.png', size: 512 },
// And the one Safari wants by name.
{ suffix: '-apple-touch.png', size: 180 }
];
// `out` is spelled out per set rather than built from the prefix, because
// verdande's three were named before there was a second set and renaming them
// would move the paths the manifest and app.html already point at.
const SETS = [
{
source: 'static/icon.svg',
out: {
'-192.png': 'static/icon-192.png',
'-512.png': 'static/icon-512.png',
'-apple-touch.png': 'static/apple-touch-icon.png'
}
},
{
source: 'static/urd-icon.svg',
out: {
'-192.png': 'static/urd-icon-192.png',
'-512.png': 'static/urd-icon-512.png',
'-apple-touch.png': 'static/urd-apple-touch-icon.png'
}
}
];
const svg = readFileSync('static/icon.svg', 'utf8');
const browser = await chromium.launch();
const page = await browser.newPage();
for (const { file, size } of SIZES) {
for (const set of SETS) {
const svg = readFileSync(set.source, 'utf8');
for (const { suffix, size } of SIZES) {
const file = set.out[suffix];
await page.setViewportSize({ width: size, height: size });
await page.setContent(
`<style>html,body{margin:0;padding:0}svg{display:block;width:${size}px;height:${size}px}</style>${svg}`
);
writeFileSync(file, await page.locator('svg').screenshot());
console.log(`${file} ${size}×${size}`);
}
}
await browser.close();
+8 -6
View File
@@ -480,12 +480,14 @@
<nav class="sidebar" aria-label={t('nav.main')}>
<div class="brand">
<!-- The mark: one rune, the letter the Norn's name starts with in the elder
futhark — Wunjo for Verdande, Uruz for Urd. Both come from the store
rather than from here, because the two editions of this program are two
products with two names, and a wordmark is the one place a reader
decides which one they are looking at. -->
<span class="rune" aria-hidden="true">{app.productRune}</span>
<!-- The mark — ᚹ Wunjo for Verdande, ð for Urd. Not two runes, though it
was meant to be: uruz is already another of this author's apps and
carries that glyph. See web/static/urd-icon.svg.
Both come from the store rather than from here, because the two editions
of this program are two products with two names, and a wordmark is the
one place a reader decides which one they are looking at. -->
<span class="rune" aria-hidden="true">{app.productMark}</span>
<span class="name">{app.productSlug}</span>
</div>
+13 -4
View File
@@ -86,11 +86,20 @@ class AppState {
}
/**
* Mærket: én rune, det bogstav navnet begynder med i den ældre futhark.
* ᚹ wunjo for Verdande, ᚢ uruz for Urd.
* Mærket: ᚹ wunjo for Verdande, ð for Urd.
*
* Ikke to runer, selvom det var meningen. Urds oplagte mærke var ᚢ uruz,
* bogstavet navnet begynder med i den ældre futhark, præcis som Verdandes er
* wunjo — og det blev tegnet sådan først. Men uruz er allerede et af husets
* egne produkter, og dets `gen-icons.ts` tegner samme glyf i guld. To af samme
* persons apps side om side på en hjemmeskærm, hvilket er nøjagtig det sted,
* mærket skal kunne skelnes. ᚦ thurisaz blev forkastet et niveau nede af samme
* grund: en stav med en trekant til højre, og det er ᚹ også.
*
* Så bogstavet frem for runen. Hele historien står i web/static/urd-icon.svg.
*/
get productRune() {
return this.notesOnly ? '\u16A2' : '\u16B9';
get productMark() {
return this.notesOnly ? 'ð' : '\u16B9';
}
/** Transient messages: a failed save, a rolled-back change. */
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+42
View File
@@ -0,0 +1,42 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<!-- Urd's mark: ð, the eth in Urðr. Drawn in Verdande's strokes so the two read
as one family — same width, same round caps, same plate.
NOT a rune, and that is the whole story of this file. The obvious mark was
ᚢ uruz, the letter the name begins with in the elder futhark, exactly as
Verdande's is ᚹ wunjo. It was drawn that way first. Then: uruz is already
one of this author's products, and uruz/scripts/gen-icons.ts says in its own
comment that it "draws the Uruz rune (ᚢ) in rune-gold" with "a tall left
vertical, a shoulder across the top, and a shorter" one — the same glyph,
the same construction, differing only in colour. Two of one person's apps
side by side on a home screen, which is precisely where this mark is meant
to be distinguishable.
ᚦ thurisaz was considered next, for the ð itself, and rejected for the same
reason one level down: it is a stave with a triangle on its right, and so is
ᚹ. At 16px they are one shape.
So the letter rather than the rune. ð is unmistakably Nordic, nothing else
here uses it, and it is a closed bowl where both neighbours are open staves.
Centred by counting painted pixels — white on black, headless Chromium, the
plate removed so the card is not what gets measured — rather than from the
path numbers, because a centred box is not a centred glyph:
verdande ᚹ ink x 53–108 centre 80.5 · y 18–131 centre 74.5
urd ð ink x 39–110 centre 74.5 · y 15–134 centre 74.5
Verdande's +5.5 in x is its own deliberate nudge: a heavy stave beside a
light flag has to sit right of centre for the ink to look centred. ð is
symmetric enough not to need it, and the two agree exactly in y.
Legibility, measured rather than hoped: it reads as ð at 32px and above. At
16px it is a blob — and so is ᚹ, and a favicon is not what tells two
installed apps apart in a dock. -->
<rect width="64" height="64" rx="14" fill="#111312"/>
<g fill="none" stroke="#5fbf8f" stroke-width="6.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="32" cy="42" r="12"/>
<path d="M44 36L27 10"/>
<path d="M25 20h18"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

+31
View File
@@ -14,6 +14,37 @@ export default {
}),
alias: {
$lib: 'src/lib'
},
/**
* The build's own version string, which SvelteKit puts in
* `_app/version.json` and the service worker reads to decide whether a new
* version has been deployed.
*
* Its default is `Date.now()`, and that made every build of this frontend
* produce different bytes: the millisecond lands in version.json, in
* index.html and in service-worker.js, which changes the content hash of
* every chunk that references them, which changes those chunks' filenames.
* So two builds of the same commit had different file names in them.
*
* That is not an aesthetic problem, and it had two costs. The Dockerfile
* said `-trimpath` and an empty buildid "keep the output reproducible", and
* that sentence had been false for a year — the flags did their job and the
* frontend embedded in the binary carried a millisecond. And the Dockerfile
* builds one binary into two images, verdande and urd, where the claim is
* that they are the same program rather than a fork; with a timestamp in the
* build, that claim could not be checked, because no two builds ever
* matched. CI checks it now.
*
* Measured by building twice and diffing the tree, before and after.
*
* `dev` when nothing sets it, so a local build is stable too. The cost is
* that a local `npm run build` no longer looks like a new version to the
* service worker — which only matters on a deployed instance, where the
* release workflow passes the real tag.
*/
version: {
name: process.env.VERDANDE_VERSION ?? 'dev'
}
}
};