Compare commits

...
Author SHA1 Message Date
Kristian Wind 807aa83e58 Merge pull request #99 from kristianwind/urd-faar-sin-egen-doer
CI / OpenAPI is valid (push) Successful in 13s
CI / End-to-end (push) Successful in 15m49s
CI / Docker image builds (push) Successful in 2m55s
Release / Build and publish image (push) Failing after 6s
CI / Go (push) Failing after 26m49s
feat(urd): den anden dør — /urd kan installeres som sin egen app
2026-10-07 10:42:20 +02:00
kristianwindandClaude Opus 5 9199709aa5 feat(urd): den anden dør — /urd kan installeres som sin egen app
Én instans, én database, ét login — og to ting, der kan installeres.

Et styresystem installerer ikke en URL, det installerer et MANIFEST, og en
installeret apps identitet er manifestets `id`. To manifester på samme
origin med forskellige id'er er to apps; to adresser, der deler ét manifest,
er én app med et bogmærke. Det er hele mekanismen.

  /                     → verdande, ᚹ, /manifest.webmanifest, id "/"
  /urd                  → urd, ð, /urd.webmanifest, id "/urd"

Skallen serveres per dør frem for at blive rettet af JavaScript bagefter,
fordi iOS læser sidens <head> ved "Føj til hjemmeskærm" — og Safari leder
efter apple-touch-icon VED NAVN og har historisk ignoreret manifestets
ikoner helt. Et <head> rettet efter indlæsning er et <head>, der måske ikke
er det, iOS læste.

urds manifest bygges AF verdandes frem for at stå ved siden af det, så de
felter, de faktisk deler — farverne, visningstilstanden, sproget — kommer ét
sted fra og ikke kan drive fra hinanden. Seks nøgler ændres: id, start_url,
scope, de to navne og ikonerne. Et ikon i det byggede manifest, som urd
ikke har en fil til, stopper serveren frem for at gå igennem: symptomet
ville ellers være, at urds installationsprompt stille viste verdandes mærke.

`scope` skrives ud, selvom "/" er hvad specifikationen ville udlede af en
start_url på "/urd". En app, hvis scope ikke dækkede hele origin, ville åbne
sine egne links i en browserfane, første gang nogen navigerede ud af /urd —
og det er en regel, ingen skal behøve at huske, mens de læser koden.

## Ansigtet er en præference, ikke en port

`urdFace` holdes skarpt adskilt fra `notesOnly`, og det er den vigtigste
linje i ændringen:

  notesOnly — serveren HAR ingen opgaveruter. En vagt: hent dem ikke, de
              svarer 404.
  urdFace   — vinduet kom ind ad urd-døren. En præference: ruterne er der,
              dataene er personens egne, kun navnet er et andet.

Slås de sammen, begynder urd-ansigtet at skjule data frem for pynt — en
notesbog ville holde op med at vise sine opgaver, fordi nogen valgte en
anden indgang, og så er det den separate instans i forklædning. En prøve
påstår det modsatte direkte: urd-vinduet får stadig 200 på /api/v1/tasks.

Ansigtet ligger i sessionStorage og ikke localStorage, og det er dét, der
lader de to apps være to apps: de deler origin og dermed localStorage,
cookies og service worker. sessionStorage er per vindue, og en installeret
PWA er sit eget vindue. Det overlever en genindlæsning, hvilket det skal —
efter første navigation står adressen på /noter, og ingen går gennem /urd
igen.

## Undervejs

contentTypeFor slog op på det eksakte navn "manifest.webmanifest", så den
anden dørs manifest blev serveret som HTML i fire minutter. Intet ville have
meldt det: et manifest med forkert type ignoreres af browseren uden fejl, og
det eneste symptom er en installationsknap, der ikke dukker op. Læser nu
endelsen.

Og drift-kontrollen mod app.html dækkede kun udgavens to ankre, ikke dørens
fem — hvilket er grunden til, at dørens fikstur manglede to <link>-linjer,
den rigtige fil har. Fanget alene fordi applyRebrands nægter et anker, den
ikke kan finde. Kontrollen dækker nu begge sæt.

Plantet: et manifest uden `id` giver "the urd manifest has id = <nil>, want
/urd" — altså den værdi, hele designet hviler på.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-07 10:32:25 +02:00
Kristian Wind 1d15cb18c0 Merge pull request #98 from kristianwind/urd-faar-sit-maerke
CI / OpenAPI is valid (push) Successful in 12s
CI / End-to-end (push) Failing after 5m51s
CI / Docker image builds (push) Successful in 3m16s
CI / Go (push) Failing after 27m5s
feat(urd): mærket bliver ð, og frontend-bygget bliver deterministisk
2026-10-07 10:20:28 +02:00
kristianwindandClaude Opus 5 ff988d26bb 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>
2026-10-07 10:10:30 +02:00
Kristian Wind b451fd585d Merge pull request #97 from kristianwind/porten-daekker-eksporten
CI / OpenAPI is valid (push) Successful in 12s
CI / End-to-end (push) Failing after 5m55s
CI / Docker image builds (push) Successful in 3m17s
CI / Go (push) Failing after 27m10s
fix(udgaver): porten dækkede 29 ruter og skulle dække 63 — og fladen ringede stadig på dørene
2026-10-07 02:11:49 +02:00
18 changed files with 1035 additions and 42 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
+107
View File
@@ -13,6 +13,113 @@ 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 the second door, below.
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.
## The second door: two apps, one instance
urd is normally reached at **`/urd`** on an ordinary verdande. One instance, one
database, one login, one set of links between notes and tasks — and two things you
can install.
| | verdande | urd |
|---|---|---|
| entry | `/` | `/urd` |
| manifest | `/manifest.webmanifest` | `/urd.webmanifest` |
| manifest `id` | `/` | `/urd` |
| tab and mark | verdande, ᚹ | urd, ð |
| database, account, login | the same | the same |
An operating system does not install a URL, it installs a **manifest**, and the
identity of an installed app is that manifest's `id`. Two manifests on one origin
with different ids are two apps; two URLs sharing one manifest are one app with a
bookmark. That is the whole mechanism.
The shell is served per door rather than patched by JavaScript after the page
loads, because iOS reads the page's `<head>` when somebody taps Add to Home Screen
— and Safari looks for `apple-touch-icon` **by name**, having historically ignored
the manifest's icons altogether.
urd's manifest is built from verdande's rather than written beside it, so the
fields the two genuinely share — the colours, the display mode, the language —
come from one place and cannot drift. Six keys are changed: `id`, `start_url`,
`scope`, the two names and the icons. An icon in the built manifest that urd has no
file for stops the server rather than being passed through, because the symptom
would otherwise be urd's install prompt quietly showing Verdande's mark.
### The face is a preference, not a gate
Entering at `/urd` makes that **window** wear urd's face: its mark, its name, its
tab. It is kept strictly apart from the notes edition in the code, and the
distinction is the one thing worth reading twice:
| | what it means | what follows |
|---|---|---|
| `notesOnly` | the server has no task routes | a **guard** — the interface must not fetch them, they answer 404 |
| `urdFace` | this window came in through the urd door | a **preference** — the routes are there, the data is yours, only the name differs |
Conflate them and the urd face starts hiding data rather than chrome: a notebook
would stop showing its tasks because somebody chose a different entrance. A test
asserts the opposite directly — the urd window still gets 200 from
`/api/v1/tasks`.
The face lives in `sessionStorage`, not `localStorage`, and that is what lets the
two apps be two apps: they share an origin, so they share localStorage, cookies and
the service worker. `sessionStorage` is per window, and an installed PWA is its own
window. It survives a reload, which it must — after the first navigation the
address is `/noter` and nobody passes through `/urd` again.
!!! note "One thing the two doors cannot have separately"
Theme, typeface and text size are shared, because they are in the localStorage
the origin shares. That was a deliberate choice: splitting them per face would
mean migrating everybody's stored settings for a difference few people want.
## 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,
+42
View File
@@ -83,6 +83,15 @@ type Server struct {
shell []byte
manifest []byte
// Icon paths this edition serves from another file. Empty in the full edition.
assets map[string]string
// The second door: /urd and its own web manifest. Built on every instance,
// whatever the edition, because it is how the notes face is installed as its
// own app rather than a property of the instance. See shell.go.
urdShell []byte
urdManifest []byte
router chi.Router
}
@@ -125,6 +134,23 @@ 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)
})
}
// The second door, on every edition. A notes instance already calls itself urd
// at the root; the door is still built there so the two addresses behave the
// same, and so a single test does not have to know which edition it is in.
if web != nil {
urdShell, urdManifest, err := urdDoorAssets(web)
if err != nil {
log.Error("the urd door could not be built; /urd will not be its own app", "err", err)
} else {
s.urdShell, s.urdManifest = urdShell, urdManifest
}
}
if s.shell != nil {
@@ -789,6 +815,22 @@ func (s *Server) serveWeb(w http.ResponseWriter, r *http.Request) {
name = "index.html"
}
// The second door, before anything else touches the filesystem: /urd is not a
// file, so the fallback below would hand back the other door's shell.
if b := s.door(name); b != nil {
w.Header().Set("Content-Type", contentTypeFor(name))
w.Header().Set("Cache-Control", "no-cache")
http.ServeContent(w, r, name, time.Time{}, bytes.NewReader(b))
return
}
// 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.
+203 -1
View File
@@ -2,8 +2,10 @@ package httpapi
import (
"bytes"
"encoding/json"
"fmt"
"io/fs"
"strings"
)
// The product name is in the built frontend as a literal in four places, because
@@ -132,8 +134,208 @@ func (s *Server) renamed(name string) []byte {
// is ignored by the browser without an error anywhere: the install prompt simply
// does not appear.
func contentTypeFor(name string) string {
if name == "manifest.webmanifest" {
// Any .webmanifest, not the one name — there are two of them now, and the
// second one was served as HTML for about four minutes because this read
// `name == "manifest.webmanifest"`. Nothing would have reported it: a manifest
// with the wrong type is ignored silently, and the only symptom is an install
// prompt that does not appear.
if strings.HasSuffix(name, ".webmanifest") {
return "application/manifest+json"
}
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
}
// ---------------------------------------------------------------------------
// The second door.
//
// One instance, one database, one login — and two things you can install. urd is
// reached at /urd, which serves the same single-page app under its own identity:
// its own title, its own mark, and its own web manifest.
//
// The manifest is the part that matters and the part that is easy to get wrong.
// An operating system does not install a URL, it installs a *manifest*, and the
// identity of an installed app is the manifest's `id`. Two manifests on one origin
// with different ids are two installable apps; two URLs sharing one manifest are
// one app with a bookmark.
//
// And the shell has to be served per door rather than patched by JavaScript,
// because iOS reads the page's <head> when somebody taps Add to Home Screen —
// Safari looks for apple-touch-icon BY NAME and has historically ignored the
// manifest's icons entirely. A head rewritten after load is a head that may or may
// not be the one iOS read.
// ---------------------------------------------------------------------------
// urdDoorPath is the entry point, and it is also the manifest's id. One constant,
// because those two being the same string is the whole mechanism.
const urdDoorPath = "/urd"
// urdShellRebrands rewrites the four things in the shell's <head> that decide what
// this door is: what the tab says, what a crawler reads, which manifest the browser
// fetches, and which icons it finds without the manifest.
//
// Note what is NOT in here, for the same reason as in shellRebrands above: the
// three `verdande:` localStorage keys in the inline theme script. They are storage
// rather than a name, the rest of the app reads them, and — now more than before —
// the two doors share an origin and therefore share that storage. Renaming them
// here would give the two apps different themes by accident and lose everybody's
// setting on the way.
func urdShellRebrands() []rebrand {
return []rebrand{
{"<title>verdande</title>", "<title>urd</title>"},
{
`<meta name="description" content="verdande — opgaver og projekter, delt." />`,
`<meta name="description" content="` + notesShellDescription + `" />`,
},
{
`<link rel="manifest" href="/manifest.webmanifest" />`,
`<link rel="manifest" href="/urd.webmanifest" />`,
},
{
`<link rel="icon" href="/icon.svg" type="image/svg+xml" />`,
`<link rel="icon" href="/urd-icon.svg" type="image/svg+xml" />`,
},
{
`<link rel="apple-touch-icon" href="/apple-touch-icon.png" />`,
`<link rel="apple-touch-icon" href="/urd-apple-touch-icon.png" />`,
},
}
}
// urdManifest builds the second door's manifest from the one the frontend build
// produced, rather than writing a second one by hand.
//
// Transformed as JSON rather than by string replacement, which the shell above
// does: a manifest needs keys the built one does not have — `id` and `start_url`
// — so there is nothing to anchor a replacement on, and a hand-written copy would
// drift on the fields the two genuinely share (the colours, the display mode, the
// language).
//
// `id` is what makes this a second app rather than a bookmark. `scope` is spelled
// out even though "/" is what the spec would default to from a start_url of
// "/urd": an app whose scope did not cover the whole origin would open its own
// links in a browser tab the first time somebody navigated out of /urd, and that
// is a rule nobody should have to remember while reading this.
func urdManifest(raw []byte) ([]byte, error) {
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
return nil, fmt.Errorf("the built manifest is not JSON: %w", err)
}
m["id"] = urdDoorPath
m["start_url"] = urdDoorPath
m["scope"] = "/"
m["name"] = "urd"
m["short_name"] = "urd"
m["description"] = notesManifestDescription
icons, ok := m["icons"].([]any)
if !ok || len(icons) == 0 {
return nil, fmt.Errorf("the built manifest has no icons to rewrite")
}
for i, entry := range icons {
icon, ok := entry.(map[string]any)
if !ok {
return nil, fmt.Errorf("icon %d is not an object", i)
}
src, _ := icon["src"].(string)
// Every icon must be one this door has a file for. Refusing an unknown one
// is the point: an icon added to the manifest and not to this map would
// otherwise leave urd's install prompt showing Verdande's mark, which is
// cosmetic enough that nobody files it.
swapped, ok := map[string]string{
"/icon.svg": "/urd-icon.svg",
"/icon-192.png": "/urd-icon-192.png",
"/icon-512.png": "/urd-icon-512.png",
}[src]
if !ok {
return nil, fmt.Errorf("icon %d is %q, which urd has no file for", i, src)
}
icon["src"] = swapped
}
out, err := json.MarshalIndent(m, "", " ")
if err != nil {
return nil, err
}
return append(out, '\n'), nil
}
// urdDoorAssets builds the second door from the files the frontend build produced.
//
// Both or neither, and loudly: a door whose shell points at /urd.webmanifest while
// that manifest is not being served is an install prompt that silently falls back
// to the other app's identity.
func urdDoorAssets(web fs.FS) (shell, manifest []byte, err error) {
raw, err := fs.ReadFile(web, "index.html")
if err != nil {
return nil, nil, fmt.Errorf("index.html: %w", err)
}
shell, err = applyRebrands(raw, urdShellRebrands())
if err != nil {
return nil, nil, fmt.Errorf("index.html: %w", err)
}
raw, err = fs.ReadFile(web, "manifest.webmanifest")
if err != nil {
return nil, nil, fmt.Errorf("manifest.webmanifest: %w", err)
}
manifest, err = urdManifest(raw)
if err != nil {
return nil, nil, fmt.Errorf("manifest.webmanifest: %w", err)
}
return shell, manifest, nil
}
// door returns the bytes for the second door's two paths, or nil.
//
// Keyed on the requested path rather than on the resolved file, unlike `renamed`
// below: /urd is not a file and never will be, so it has to be answered before the
// fallback to index.html gets to it — which would serve the other door's shell
// under this door's address, with no error anywhere.
func (s *Server) door(name string) []byte {
switch name {
case "urd":
return s.urdShell
case "urd.webmanifest":
return s.urdManifest
}
return nil
}
+213 -6
View File
@@ -1,6 +1,7 @@
package httpapi
import (
"encoding/json"
"io"
"io/fs"
"log/slog"
@@ -45,6 +46,8 @@ const builtShell = `<!doctype html>
<head>
<meta name="description" content="verdande — opgaver og projekter, delt." />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<title>verdande</title>
<script>
try {
@@ -62,7 +65,17 @@ const builtManifest = `{
"name": "verdande",
"short_name": "verdande",
"description": "Opgaver og projekter, delt.",
"start_url": "/"
"start_url": "/",
"display": "standalone",
"background_color": "#111312",
"theme_color": "#111312",
"lang": "da",
"icons": [
{ "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" },
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
`
@@ -194,10 +207,19 @@ func TestAppHTMLStillCarriesTheAnchors(t *testing.T) {
}
src := string(raw)
for _, pair := range shellRebrands("urd", "d") {
if n := strings.Count(src, pair.old); n != 1 {
t.Errorf("web/src/app.html contains %d occurrences of the anchor %q, want exactly 1 — "+
"the notes edition renames the shell by replacing it", n, pair.old)
// Both sets: the edition's rename and the second door's. The door was added
// after this test and its anchors were NOT covered, which is how its fixture
// came to be missing two <link> lines the real file has — caught only because
// applyRebrands refuses an anchor it cannot find.
for what, pairs := range map[string][]rebrand{
"the notes edition's rename": shellRebrands("urd", "d"),
"the urd door": urdShellRebrands(),
} {
for _, pair := range pairs {
if n := strings.Count(src, pair.old); n != 1 {
t.Errorf("web/src/app.html contains %d occurrences of the anchor %q, want exactly 1 — %s replaces it",
n, pair.old, what)
}
}
}
// And the keys the rename must not touch, asserted here too: app.html is where
@@ -206,7 +228,8 @@ func TestAppHTMLStillCarriesTheAnchors(t *testing.T) {
if !strings.Contains(src, `localStorage.getItem('verdande:theme')`) {
t.Error("web/src/app.html no longer reads verdande:theme; check that the app reads the same key")
}
t.Logf("app.html: %d bytes, %d anchors found", len(raw), len(shellRebrands("urd", "d")))
t.Logf("app.html: %d bytes, %d edition anchors, %d door anchors",
len(raw), len(shellRebrands("urd", "d")), len(urdShellRebrands()))
}
// TestTheFullEditionServesTheBuiltFilesUnchanged is the control. Everything above
@@ -233,3 +256,187 @@ 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")
}
}
// TestTheUrdDoorIsItsOwnApp.
//
// An operating system does not install a URL, it installs a manifest, and the
// identity of an installed app is that manifest's `id`. So this asserts the two
// things that decide whether /urd becomes a second icon on a home screen or just a
// bookmark into the first one — and it asserts them as named values, because an
// empty manifest, verdande's manifest and urd's all parse as JSON.
func TestTheUrdDoorIsItsOwnApp(t *testing.T) {
srv := serverWithWeb(t, "full", webWithIcons())
if srv.door("urd") == nil || srv.door("urd.webmanifest") == nil {
t.Fatal("the full edition does not serve the urd door; it is a second face, not an edition")
}
shell := string(srv.door("urd"))
for _, want := range []string{
"<title>urd</title>",
`<link rel="manifest" href="/urd.webmanifest" />`,
// iOS reads this one by name and has historically ignored the manifest's
// icons entirely, which is why the shell is served per door rather than
// patched by script after load.
`<link rel="apple-touch-icon" href="/urd-apple-touch-icon.png" />`,
`<link rel="icon" href="/urd-icon.svg" type="image/svg+xml" />`,
} {
if !strings.Contains(shell, want) {
t.Errorf("the urd door's shell does not contain %q", want)
}
}
// The same trap as the edition shell: the two doors share an origin and
// therefore share localStorage, so renaming a storage key here would give the
// two apps different themes by accident.
if !strings.Contains(shell, `localStorage.getItem('verdande:theme')`) {
t.Error("the urd door's shell lost a storage key; the two doors share one origin and one localStorage")
}
var m map[string]any
if err := json.Unmarshal(srv.door("urd.webmanifest"), &m); err != nil {
t.Fatalf("the urd manifest is not JSON: %v", err)
}
for key, want := range map[string]any{
// id is the install identity. Without it, two manifests on one origin are
// one app with two start urls.
"id": "/urd",
"start_url": "/urd",
"scope": "/",
"name": "urd",
"short_name": "urd",
} {
if m[key] != want {
t.Errorf("the urd manifest has %s = %v, want %v", key, m[key], want)
}
}
// And the shared half, to prove it was transformed rather than written out: a
// hand-written copy would drift on exactly these.
var built map[string]any
raw, _ := fs.ReadFile(webWithIcons(), "manifest.webmanifest")
_ = json.Unmarshal(raw, &built)
for _, key := range []string{"display", "background_color", "theme_color", "lang"} {
if _, ok := built[key]; ok && m[key] != built[key] {
t.Errorf("the urd manifest lost the built one's %s: %v vs %v", key, m[key], built[key])
}
}
icons, _ := m["icons"].([]any)
if len(icons) == 0 {
t.Fatal("the urd manifest has no icons")
}
for i, entry := range icons {
src, _ := entry.(map[string]any)["src"].(string)
if !strings.HasPrefix(src, "/urd-") {
t.Errorf("icon %d is %q — the urd manifest points at verdande's mark", i, src)
}
}
t.Logf("urd door: shell %d bytes, manifest %d bytes, %d icons", len(srv.door("urd")), len(srv.door("urd.webmanifest")), len(icons))
}
// TestAnUnknownIconStopsTheUrdManifest is the direction that matters: an icon
// added to the built manifest and not to urd's map would otherwise leave urd's
// install prompt showing Verdande's mark, which nobody reports.
func TestAnUnknownIconStopsTheUrdManifest(t *testing.T) {
raw := []byte(`{"name":"verdande","icons":[{"src":"/icon-1024.png","sizes":"1024x1024"}]}`)
if _, err := urdManifest(raw); err == nil {
t.Error("urdManifest accepted an icon it has no file for")
} else if !strings.Contains(err.Error(), "/icon-1024.png") {
t.Errorf("the error does not name the icon: %v", err)
}
}
// TestTheWebmanifestTypeCoversBothDoors. A manifest served as text/html is ignored
// by the browser with no error anywhere; the only symptom is that the install
// prompt does not appear. This read `name == "manifest.webmanifest"` when the
// second door was added.
func TestTheWebmanifestTypeCoversBothDoors(t *testing.T) {
for _, name := range []string{"manifest.webmanifest", "urd.webmanifest"} {
if got := contentTypeFor(name); got != "application/manifest+json" {
t.Errorf("%s is served as %q", name, got)
}
}
if got := contentTypeFor("index.html"); got != "text/html; charset=utf-8" {
t.Errorf("index.html is served as %q", got)
}
}
+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);
}
});
+113 -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,116 @@ 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');
});
/**
* The second door.
*
* One instance, one database, one login — and two things that can be installed.
* This runs in the FULL edition on purpose: urd is a second face of an ordinary
* verdande, not a property of an instance, so if it only worked where
* VERDANDE_EDITION=notes it would not be the thing that was asked for.
*
* Measured on the bytes the server sends. An operating system installs a manifest,
* not a URL, so these assertions are about the manifest's `id` and the shell's
* <head> — the two things that decide whether /urd becomes a second icon on a home
* screen or a bookmark into the first app.
*/
test('urd is a second installable app on the same instance', async ({ page }) => {
const shell = await (await page.request.get('/urd')).text();
expect(shell, 'the tab, before any script runs').toContain('<title>urd</title>');
expect(shell, 'which manifest the browser fetches').toContain(
'<link rel="manifest" href="/urd.webmanifest" />'
);
// iOS looks for this one BY NAME and has historically ignored the manifest's
// icons, which is why the shell is served per door rather than patched by
// script after the page loads.
expect(shell, 'what iOS puts on a home screen').toContain(
'<link rel="apple-touch-icon" href="/urd-apple-touch-icon.png" />'
);
// And the root is untouched: two doors, not a rename.
const root = await (await page.request.get('/')).text();
expect(root).toContain('<title>verdande</title>');
expect(root).toContain('<link rel="manifest" href="/manifest.webmanifest" />');
const res = await page.request.get('/urd.webmanifest');
// A manifest served as text/html is ignored with no error anywhere; the only
// symptom is an install prompt that never appears.
expect(res.headers()['content-type']).toContain('application/manifest+json');
const urd = await res.json();
// `id` is the install identity. Without it, two manifests on one origin are one
// app with two start urls.
expect(urd.id).toBe('/urd');
expect(urd.start_url).toBe('/urd');
// Not "/urd": a scope that did not cover the origin would open the app's own
// links in a browser tab the first time somebody navigated out of /urd.
expect(urd.scope).toBe('/');
expect(urd.name).toBe('urd');
for (const icon of urd.icons) {
expect(icon.src, 'the install prompt must not show verdande’s mark').toMatch(/^\/urd-/);
}
const verdande = await (await page.request.get('/manifest.webmanifest')).json();
expect(verdande.name).toBe('verdande');
expect(verdande.id ?? '/').not.toBe('/urd');
// The shared fields come from one source, so a colour changed in the build
// reaches both doors.
expect(urd.theme_color).toBe(verdande.theme_color);
expect(urd.background_color).toBe(verdande.background_color);
});
test('entering through the urd door lands on the notes', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/urd');
// Asserted on what is drawn rather than on the URL: the Go binary serves the
// shell for anything it does not recognise, so a route that no longer exists
// still answers 200 here.
await expect(page.getByRole('heading', { name: /Noter/ }).first()).toBeVisible();
// replaceState, so the phone's back gesture does not return to a page whose
// only job is to redirect.
await expect(page).toHaveURL(/\/noter$/);
// And the window now wears urd's face: its mark, its name, its tab. This is
// chrome only — the task routes are all still there and still answer, which is
// the whole reason this is one instance.
const brand = page.getByRole('navigation', { name: /Hovedmenu|Main menu/ }).locator('.brand');
await expect(brand.locator('.name')).toHaveText('urd');
await expect(brand.locator('.rune')).toHaveText('ð');
await expect(page).toHaveTitle(/· urd$/);
// Nothing is withheld: the same window can still read a task route. A face that
// hid data would be the separate instance wearing a costume.
const tasks = await page.request.get('/api/v1/tasks');
expect(tasks.status(), 'the task routes are still served to the urd window').toBe(200);
// And the face survives a reload — sessionStorage, because the two doors share
// an origin. Nobody passes through /urd again after the first navigation.
await page.reload();
await expect(brand.locator('.name')).toHaveText('urd');
expect(trouble).toEqual([]);
});
test('the verdande door is unaffected by the urd one', async ({ page }) => {
// The control, and it is the half that stops the face from being a rename: a
// window that never went through /urd must be verdande, in the same browser,
// on the same origin, with the other app's sessionStorage sitting beside it.
const trouble = watchForTrouble(page);
await page.goto('/noter');
const brand = page.getByRole('navigation', { name: /Hovedmenu|Main menu/ }).locator('.brand');
await expect(brand.locator('.name')).toHaveText('verdande');
await expect(brand.locator('.rune')).toHaveText('\u16B9');
await expect(page).toHaveTitle(/· verdande$/);
expect(trouble).toEqual([]);
});
+47 -16
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) {
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}`);
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>
+68 -6
View File
@@ -64,6 +64,38 @@ class AppState {
return this.edition === 'notes';
}
/**
* Hvilken dør dette vindue kom ind ad.
*
* HOLDT ADSKILT FRA `notesOnly` med vilje, og det er den vigtigste linje i
* filen her. De to ligner hinanden og betyder ikke det samme:
*
* notesOnly — serveren HAR ingen opgaveruter. En vagt: fladen må ikke hente
* dem, for de svarer 404.
* urdFace — personen kom ind ad urd-døren. En præference: ruterne er der,
* dataene er deres egne, og der er intet at holde tilbage.
*
* Slås de sammen, begynder urd-ansigtet at skjule data frem for pynt — en
* notesbog ville holde op med at vise sine opgaver, selvom de ligger i samme
* database og personen bare valgte en anden indgang.
*
* Læst fra sessionStorage frem for localStorage, fordi de to apps deler origin
* og dermed deler localStorage. sessionStorage er per vindue, og en installeret
* PWA er sit eget vindue.
*/
urdFace = $state(false);
enterUrd() {
this.urdFace = true;
try {
sessionStorage.setItem('verdande:door', 'urd');
} catch (e) {
// Privat browsing kan nægte. Ansigtet holder så vinduet ud alligevel,
// fordi det står i $state ovenfor — det er kun genindlæsningen, der
// mister det.
}
}
/**
* Navnet instansen kalder sig selv — `Urd` i en sætning, `urd` i et mærke.
*
@@ -77,20 +109,41 @@ class AppState {
* (`internal/httpapi/shell.go`). Gjorde vi det her, ville hver indlæsning
* vise søsterens navn et øjeblik og så rette sig selv.
*/
/**
* Om DETTE vindue viser urd — enten fordi instansen ikke har opgaveruter, eller
* fordi personen kom ind ad urd-døren.
*
* De to grunde er forskellige og må ikke slås sammen andre steder end her: det
* her er navnet og mærket, altså pynt. `notesOnly` alene er vagten, der afgør,
* om der overhovedet er en rute at hente fra.
*/
get showsUrd() {
return this.notesOnly || this.urdFace;
}
get productName() {
return this.notesOnly ? 'Urd' : 'Verdande';
return this.showsUrd ? 'Urd' : 'Verdande';
}
get productSlug() {
return this.notesOnly ? 'urd' : 'verdande';
return this.showsUrd ? 'urd' : 'verdande';
}
/**
* 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.showsUrd ? 'ð' : '\u16B9';
}
/** Transient messages: a failed save, a rolled-back change. */
@@ -159,6 +212,15 @@ class AppState {
async load() {
this.loading = true;
// Døren, hvis dette vindue allerede er gået gennem den. Genindlæser nogen på
// /noter inde i urd-appen, kommer de ikke forbi /urd igen, og uden den her
// linje ville ansigtet falde af ved hvert F5.
try {
if (sessionStorage.getItem('verdande:door') === 'urd') this.urdFace = true;
} catch (e) {
// Privat browsing nægter. Så holder ansigtet vinduet ud og ikke længere.
}
// Formen hentes for sig, og en fejl på den tier. En 401 fra `me` må ikke
// tage udgaven med sig — login-skærmen er også en flade i en udgave — og
// en instans, der slet ikke svarer, melder `me` ordentligt nedenfor.
+33
View File
@@ -0,0 +1,33 @@
<script>
/**
* Urds dør.
*
* Serveren svarer på /urd med sin egen skal — egen titel, eget mærke og et link
* til sit eget manifest — og det er dét, styresystemet installerer. Siden her er
* hvad appen gør, når den så er startet: husker hvilken dør den kom ind ad, og
* går videre til noterne.
*
* `sessionStorage` og ikke `localStorage`, og det er hele grunden til at de to
* apps kan være to apps: de deler origin, så de deler localStorage, cookies og
* service worker. sessionStorage er per vindue — og en installeret PWA er sit
* eget vindue — så urd-appen har sit ansigt og verdande-appen sit, uden at
* træde på hinanden. Det overlever også en genindlæsning, hvilket det skal:
* efter et par klik står adressen på /noter, og en F5 dér går ikke gennem /urd
* igen.
*
* `replaceState`, så telefonens tilbage-gestus ikke fører tilbage til en side,
* der kun omdirigerer.
*/
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { app } from '$lib/stores.svelte.js';
onMount(() => {
app.enterUrd();
goto('/noter', { replaceState: true });
});
</script>
<!-- Bevidst tom. Omdirigeringen sker i samme hug som tegningen, så alt her ville
være et glimt af noget, ingen nåede at læse. -->
<svelte:head><title>urd</title></svelte:head>
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'
}
}
};