Compare commits

...
Author SHA1 Message Date
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
kristianwindandClaude Opus 5 8c3aa33f13 fix(udgaver): porten dækkede 29 ruter og skulle dække 63 — og fladen ringede stadig på dørene
Prøven, der skal holde udgaverne adskilt, stod grøn gennem to merges, mens
34 opgaveruter blev betjent af noter-udgaven. Den fejlede ikke: den
sammenligner routeren med en nedskreven liste, og listen var skrevet ud
fra de ruter, nogen havde tænkt på. Den er en DRIFT-kontrol, og den kan
ikke være en fuldstændighedskontrol. Det står nu i prøven selv.

Lukket: CalDAV-træet (8 ruter, alle læser eller skriver en VTODO),
.well-known/caldav (en påstand om at der er en CalDAV-server), ICS-feedet
og de to ruter til dets token, de fire opgave-eksporter, Todoist- og
CSV-importen, skabelonerne, sektionerne, de seks AI-opgaveruter og
indbakke-krogen. Plus fem af ni MCP-værktøjer — endepunktet er der
stadig, værktøjslisten er kortere.

Tre blev afgjort ved at læse frem for ved navnet: en skabelon laver
opgaver (CreateProjectFromTemplate), `section_id` står på `tasks` og
findes ikke på `notes`, og add_comment hænger på en opgave.

Men den dyre halvdel var fladen. En vandring gennem hver side, der kan
nås i noter-udgaven, med konsollen og svarene aflæst, fandt SYV brud —
fire af dem fra den første port:

  sidebjælken      GET /filters  404 ved hver navigation, ædt af .catch()
  sidebjælken      GET /labels   404 ved hver navigation, ædt af .catch()
  notifikationer   GET /ai/plan  404, stille
  notesbogens side GET /tasks    kastede → status = 'failed'
  notesbogens side GET /sections kastede → samme fejlskærm
  indstillinger    GET /templates  toast med fejlbesked på skærmen
  integrationer    GET /feed     404, stille

Den fjerde er den, der har kostet noget: /projekt/{id} er notesbogens
side, og kaldene samles i et Promise.all, så én 404 tog hele siden med.
Den har vist en fejlskærm frem for noter siden porten blev sat. Ingen
prøve åbnede en notesbog i den udgave, og .catch(() => {}) sørgede for,
at resten ikke sagde noget. "Fladen følger udgaven" var sandt om menuen
og falsk om hentningerne.

Vagten for opgaver står i butikken frem for ved kaldestederne: der var
tre, og den fjerde blev glemt — hvilket er præcis sådan, den slap
igennem.

Og rettelsen lavede selv en fejl, som vandringen fangede: da opgavekaldet
holdt op med at fejle, kom en uendelig effekt-løkke frem, som 404'eren
havde holdt låg på. En ubetinget tilskrivning til app.tasks får
projektsidens effekt til at kalde tilbage. 404'eren var låget på en
løkke, ikke bare støj.

Også: -timeout 20m i begge CI-workflows. internal/httpapi tog 664s her og
498s i CI på #95, mod Go's standardgrænse på 600 — og når den krydses, er
beskeden "panic: test timed out after 10m0s" plus et goroutine-dump, som
læser som en deadlock i koden frem for som en suite, der blev for stor.

watchForTrouble udskilt til web/e2e/trouble.js, så udgave-suiten kan bruge
den samme.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-07 02:03:40 +02:00
Kristian Wind c6c7979d1a Merge pull request #96 from kristianwind/urd-faar-sit-navn
CI / OpenAPI is valid (push) Successful in 13s
CI / End-to-end (push) Failing after 5m49s
CI / Docker image builds (push) Successful in 3m9s
CI / Go (push) Failing after 16m57s
feat(udgaver): urd — noter-udgaven siger sit eget navn
2026-10-07 01:33:53 +02:00
25 changed files with 977 additions and 180 deletions
+21 -1
View File
@@ -97,7 +97,13 @@ jobs:
# -race is the whole point of running tests in CI for a server with a
# WebSocket fan-out: a data race is exactly the bug that passes locally.
- name: Test
run: go test -race -count=1 ./...
# -timeout, fordi internal/httpapi nærmer sig Go's standardgrænse på 600s:
# 498s i CI på #95, 656s på udviklingsmaskinen. Når den krydser, er
# beskeden "panic: test timed out after 10m0s" plus et goroutine-dump, og
# det læser som en deadlock i koden frem for som en suite, der blev for
# stor. Grænsen er der stadig; den er bare sat et sted, hvor den betyder
# "noget hænger" i stedet for "maskinen var langsom i dag".
run: go test -race -count=1 -timeout 20m ./...
- name: Build
run: CGO_ENABLED=0 go build ./...
@@ -271,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.
+57 -1
View File
@@ -40,7 +40,13 @@ jobs:
# -race is the whole point of running tests in CI for a server with a
# WebSocket fan-out: a data race is exactly the bug that passes locally.
- name: Test
run: go test -race -count=1 ./...
# -timeout, fordi internal/httpapi nærmer sig Go's standardgrænse på 600s:
# 498s i CI på #95, 656s på udviklingsmaskinen. Når den krydser, er
# beskeden "panic: test timed out after 10m0s" plus et goroutine-dump, og
# det læser som en deadlock i koden frem for som en suite, der blev for
# stor. Grænsen er der stadig; den er bare sat et sted, hvor den betyder
# "noget hænger" i stedet for "maskinen var langsom i dag".
run: go test -race -count=1 -timeout 20m ./...
- name: Build
run: CGO_ENABLED=0 go build ./...
@@ -117,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
+2 -1
View File
@@ -28,7 +28,8 @@ wood. Verdande is what is becoming; urd is what has become and was kept.
Not a fork, because the two share everything that is hard — accounts, sharing,
passkeys, the realtime socket, search, backups, migrations — and notes are about a
twentieth of the Go here. A test walks both routers and fails in either direction,
so "the task routes are not there" is measured rather than promised. See
so "the task routes are not there" is measured rather than promised — 63 routes and
five of nine MCP tools. See
[Two editions](docs/editions.md).
## Status
+87 -6
View File
@@ -6,13 +6,65 @@ One program, built once, with two faces.
|---|---|---|
| What it is | Tasks, projects and notes | Notes |
| `VERDANDE_EDITION` | `full` (the default) | `notes` |
| Routes served | 200 | 171 |
| Routes served | 200 | 137 |
| Mark | ᚹ | ᚢ |
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,
@@ -24,7 +76,7 @@ needed applying twice, by hand, forever.
So the split is a configuration value read in one place, and the thing that keeps
it honest is a test rather than a convention: `TestNotesEditionServesOnlyItsOwnRoutes`
walks both routers, compares them against a written-down list of the 29 withheld
walks both routers, compares them against a written-down list of the 63 withheld
routes, and fails in **both** directions — a route that appears in the notes
edition and a route that stops being withheld are both a failure.
@@ -39,16 +91,45 @@ with no error anywhere and no way to tell from the outside.
## What urd does not have
The 29 withheld routes are tasks, sub-tasks, labels, saved filters, reminders, the
upcoming and delegated views, and the task half of search and export. Everything
else is the same program:
The 63 withheld routes:
| | what |
|---|---|
| 29 | tasks, sub-tasks, labels, saved filters, reminders, the upcoming and delegated views |
| 8 | the CalDAV server — every route in it reads or writes a VTODO |
| 1 | `/.well-known/caldav`, which is a claim that a CalDAV server is here |
| 1 | the ICS calendar feed, plus 2 for the token it is reached with |
| 4 | the task exports: `tasks.ics`, `projects.zip`, and a project's `.csv` and `.ics` |
| 2 | the Todoist and CSV imports, which create tasks |
| 4 | project templates — a template is a project plus sections plus tasks |
| 5 | sections, which group tasks: `section_id` is a column on `tasks` and not on `notes` |
| 6 | the AI task features: split, tidy the inbox, and the day's plan |
| 1 | the inbox hook, which pushes a line of text in as a task |
Plus five of the nine MCP tools — `search_tasks`, `create_task`, `update_task`,
`complete_task` and `add_comment`. The endpoint is still there; the tool list is
shorter. A tool a model can call is a surface, and one that answers "there are no
tasks" is worse than one that does not exist: the model tries it, gets an empty
answer, and tells the person there is nothing to do.
Everything else is the same program:
- accounts, invitations, passkeys and two-factor
- projects — which in urd are what a note belongs to
- per-note sharing with other people, independent of projects
- search, over notes
- export, of notes
- the mail, calendar and MCP surfaces, minus their task routes
- the mail, calendar and MCP surfaces, minus their task routes and task tools
- AI settings, `ask`, and the note actions
!!! warning "Still being decided"
Four connectors are reachable in urd today and have not been settled either
way, because each has a plausible notes reading and withholding them would be
deciding what this product is rather than closing a hole: the mail-in address,
IMAP mailboxes, Gmail, and Google Calendar. They currently file what they
receive as **tasks**, so in urd they connect and then have nowhere to put
anything. The same applies to one handler rather than a route:
`POST /api/v1/ai/notes/{noteID}/actions/apply` creates tasks from a note.
## The name
+145
View File
@@ -2,6 +2,7 @@ package httpapi
import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
@@ -36,28 +37,69 @@ func TestNotesEditionServesOnlyItsOwnRoutes(t *testing.T) {
// Every route the full edition has and the notes edition does not. Written as
// the whole list rather than a prefix rule: a prefix is a description of what
// somebody meant, and this has to be a description of what is there.
//
// And note what this list could NOT catch, because it is the shape of mistake
// that outlived the first version of this test. The list went green at 29
// routes for two merges while CalDAV, the ICS feed, the inbox hook, four task
// exports, both task imports, the project templates, the sections and six AI
// task endpoints were all still served by the notes edition — because the test
// compares the router against THIS LIST, and the list was written from the
// routes somebody had thought of. It is a drift check, and it cannot be an
// is-it-complete check.
//
// So when a route is added that one edition should not have, the question to
// ask is not "does the walk still pass" but "can this endpoint do anything at
// all without the half it is being withheld from". Three of the ones above were
// settled by reading rather than by the name: a template turned out to create
// tasks (CreateProjectFromTemplate), `section_id` turned out to exist on
// `tasks` and not on `notes`, and add_comment turned out to hang off a task.
withheld := map[string]bool{}
for _, r := range []string{
"DELETE /api/v1/filters/{filterID}",
"DELETE /api/v1/labels/{labelID}",
"DELETE /api/v1/reminders/{reminderID}",
"DELETE /api/v1/sections/{sectionID}",
"DELETE /api/v1/tasks/{taskID}",
"DELETE /api/v1/templates/{templateID}",
"DELETE /caldav/{userID}/{projectID}/{taskFile}",
"GET /.well-known/caldav",
"GET /api/v1/ai/plan",
"GET /api/v1/delegated",
"GET /api/v1/export/projects.zip",
"GET /api/v1/export/projects/{projectID}.csv",
"GET /api/v1/export/projects/{projectID}.ics",
"GET /api/v1/export/tasks.ics",
"GET /api/v1/feed",
"GET /api/v1/filters",
"GET /api/v1/filters/preview",
"GET /api/v1/filters/{filterID}/tasks",
"GET /api/v1/labels",
"GET /api/v1/projects/{projectID}/sections",
"GET /api/v1/tasks",
"GET /api/v1/tasks/quick-add/preview",
"GET /api/v1/tasks/{taskID}",
"GET /api/v1/tasks/{taskID}/comments",
"GET /api/v1/tasks/{taskID}/reminders",
"GET /api/v1/templates",
"GET /api/v1/upcoming",
"GET /caldav/{userID}/{projectID}/{taskFile}",
"GET /ics/{token}",
"OPTIONS /caldav/*",
"PATCH /api/v1/filters/{filterID}",
"PATCH /api/v1/labels/{labelID}",
"PATCH /api/v1/sections/{sectionID}",
"PATCH /api/v1/tasks/{taskID}",
"POST /api/v1/ai/inbox/tidy",
"POST /api/v1/ai/inbox/tidy/apply",
"POST /api/v1/ai/plan/now",
"POST /api/v1/ai/tasks/{taskID}/split",
"POST /api/v1/feed/rotate",
"POST /api/v1/filters",
"POST /api/v1/import/csv",
"POST /api/v1/import/todoist",
"POST /api/v1/labels",
"POST /api/v1/projects/{projectID}/sections",
"POST /api/v1/projects/{projectID}/sections/reorder",
"POST /api/v1/tasks",
"POST /api/v1/tasks/quick-add",
"POST /api/v1/tasks/{taskID}/attachments",
@@ -67,6 +109,15 @@ func TestNotesEditionServesOnlyItsOwnRoutes(t *testing.T) {
"POST /api/v1/tasks/{taskID}/reminders",
"POST /api/v1/tasks/{taskID}/reopen",
"POST /api/v1/tasks/{taskID}/snooze",
"POST /api/v1/templates",
"POST /api/v1/templates/{templateID}/use",
"POST /inbound/hook/{token}",
"PROPFIND /caldav",
"PROPFIND /caldav/{userID}",
"PROPFIND /caldav/{userID}/{projectID}",
"PUT /api/v1/ai/plan",
"PUT /caldav/{userID}/{projectID}/{taskFile}",
"REPORT /caldav/{userID}/{projectID}",
} {
withheld[r] = true
}
@@ -115,10 +166,25 @@ func TestNotesEditionServesOnlyItsOwnRoutes(t *testing.T) {
for _, must := range []string{
"GET /api/v1/notes",
"POST /api/v1/notes",
"PATCH /api/v1/notes/{noteID}",
"DELETE /api/v1/notes/{noteID}",
// Deling er hele grunden til at det er et selvstændigt produkt frem for en
// enkeltbrugers notesblok, så den halvdel skal navngives her.
"POST /api/v1/notes/{noteID}/shares",
"GET /api/v1/notes/{noteID}/shares",
"POST /api/v1/notes/{noteID}/attachments",
"POST /api/v1/notes/import",
"GET /api/v1/export/notes.zip",
"GET /api/v1/export/account",
"POST /api/v1/ai/notes/{noteID}/actions",
"GET /api/v1/projects", // a notebook, in this edition
"GET /api/v1/search",
"GET /api/v1/auth/me",
// 34 ruter blev lukket i den anden omgang af denne port. Den her linje er
// den, der afgør, at det var en port: MCP-endepunktet er stadig der, og det
// er værktøjslisten bagved, der er kortere.
"POST /api/v1/mcp",
"POST /mcp",
} {
if !notes[must] {
t.Errorf("the notes edition does not serve %s — that is a deletion, not a gate", must)
@@ -228,3 +294,82 @@ func TestNotesEditionSearchFindsNoTasks(t *testing.T) {
}
}
}
// TestNotesEditionOffersNoTaskToolsOverMCP.
//
// The same hole as the routes, through a different door — and it was still open
// after the first gate. An MCP server advertises its tools, so a model connected
// to a notes instance would be told it can create_task, call it, get something
// back, and tell the person their task was filed.
//
// Asked through tools/list rather than by reading the server's own slice: that is
// what a client actually sees, and the slice is unexported for the same reason.
func TestNotesEditionOffersNoTaskToolsOverMCP(t *testing.T) {
taskTools := []string{"search_tasks", "create_task", "update_task", "complete_task", "add_comment"}
noteTools := []string{"search_notes", "create_note", "update_note"}
for _, edition := range []string{config.EditionFull, config.EditionNotes} {
t.Run(edition, func(t *testing.T) {
names := mcpToolNames(t, edition)
// Both editions: the shared tool, and the notes half.
for _, want := range append([]string{"list_projects"}, noteTools...) {
if !names[want] {
t.Errorf("%s edition does not offer %s", edition, want)
}
}
for _, task := range taskTools {
switch edition {
case config.EditionFull:
if !names[task] {
t.Errorf("the full edition lost %s — that is a deletion, not a gate", task)
}
default:
if names[task] {
t.Errorf("the notes edition offers %s over MCP", task)
}
}
}
t.Logf("%s: %d tools", edition, len(names))
})
}
}
// mcpToolNames asks one edition's MCP server for its tool list.
func mcpToolNames(t *testing.T, edition string) map[string]bool {
t.Helper()
db, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { db.Close() })
cfg := &config.Config{BaseURL: "http://localhost", DataDir: t.TempDir(), SessionTTL: time.Hour, Edition: edition}
srv := New(cfg, db, slog.New(slog.NewTextHandler(io.Discard, nil)), nil)
resp := srv.mcp.Handle(context.Background(), "nobody", []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}`))
if resp == nil {
t.Fatal("tools/list got no response")
}
raw, err := json.Marshal(resp)
if err != nil {
t.Fatal(err)
}
var parsed struct {
Result struct {
Tools []struct{ Name string } `json:"tools"`
} `json:"result"`
}
if err := json.Unmarshal(raw, &parsed); err != nil {
t.Fatalf("tools/list: %v — %s", err, raw)
}
if len(parsed.Result.Tools) == 0 {
t.Fatalf("tools/list returned no tools at all: %s", raw)
}
names := map[string]bool{}
for _, tool := range parsed.Result.Tools {
names[tool.Name] = true
}
return names
}
+72 -63
View File
@@ -23,7 +23,9 @@ import (
// `search_tasks` and `list_projects` is answering "which of these gets me closer",
// and a description that only names the endpoint gives it nothing to decide with.
func (s *Server) buildMCP() *mcp.Server {
m := mcp.NewServer("verdande", "1")
// Udgavens eget navn: det er sådan, instansen står i klientens
// forbindelsesliste.
m := mcp.NewServer(s.cfg.ProductSlug(), "1")
m.Register(mcp.Tool{
Name: "list_projects",
@@ -34,72 +36,79 @@ func (s *Server) buildMCP() *mcp.Server {
}),
}, s.mcpListProjects)
m.Register(mcp.Tool{
Name: "search_tasks",
Description: "Find tasks by text, project, label, priority, due date or assignee. " +
"This is the tool for any question about what the user has to do — " +
"\"what is due today\", \"what is overdue\", \"anything about the tax return\". " +
"The query accepts the same filter language as the app: \"today & p1\", " +
"\"overdue\", \"#Firma & @regnskab\", \"7 days\", \"assigned to: me\".",
InputSchema: mcp.Schema(map[string]any{
"query": mcp.Str("A filter expression, e.g. \"today & p1\" or \"overdue\"."),
"text": mcp.Str("Free text to search task titles and descriptions for."),
"limit": mcp.Int("How many tasks to return. Defaults to 50, maximum 200."),
"include_completed": mcp.Bool("Include tasks that are already done. Defaults to false."),
}),
}, s.mcpSearchTasks)
// De fem opgaveværktøjer. Udeladt i noter-udgaven af samme grund som ruterne:
// et værktøj, en model kan kalde, er en flade på instansen, og et værktøj der
// svarer "der er ingen opgaver" er værre end et, der ikke findes — modellen
// prøver det, får et tomt svar og fortæller brugeren, at der ikke er noget at
// lave. add_comment er med her: en kommentar hænger på en opgave.
if !s.cfg.NotesOnly() {
m.Register(mcp.Tool{
Name: "search_tasks",
Description: "Find tasks by text, project, label, priority, due date or assignee. " +
"This is the tool for any question about what the user has to do — " +
"\"what is due today\", \"what is overdue\", \"anything about the tax return\". " +
"The query accepts the same filter language as the app: \"today & p1\", " +
"\"overdue\", \"#Firma & @regnskab\", \"7 days\", \"assigned to: me\".",
InputSchema: mcp.Schema(map[string]any{
"query": mcp.Str("A filter expression, e.g. \"today & p1\" or \"overdue\"."),
"text": mcp.Str("Free text to search task titles and descriptions for."),
"limit": mcp.Int("How many tasks to return. Defaults to 50, maximum 200."),
"include_completed": mcp.Bool("Include tasks that are already done. Defaults to false."),
}),
}, s.mcpSearchTasks)
m.Register(mcp.Tool{
Name: "create_task",
Description: "Create a task. Prefer passing the whole thing as natural language in " +
"`text` — \"betal moms i morgen kl 10 p1 #Firma @regnskab\" — because the app's own " +
"parser handles dates, times, priorities, projects, labels and recurrence in Danish " +
"and English. Use the explicit fields only when the user was explicit.",
InputSchema: mcp.Schema(map[string]any{
"text": mcp.Str("The task as a sentence, parsed for date, time, priority, #project, @label and recurrence."),
"content": mcp.Str("The task title, if not using `text`."),
"project_id": mcp.Str("Which project. Defaults to the Inbox. Naming a project that does not exist is not an error — the task goes to the Inbox and the answer says so."),
"parent_id": mcp.Str("Make this a sub-task of that task. It joins the parent's project. One level deep."),
"due_date": mcp.Str("A date as YYYY-MM-DD."),
"priority": mcp.Int("1 (highest) to 4 (none)."),
"description": mcp.Str("Longer notes on the task."),
"labels": mcp.StrArray("Label names."),
}),
}, s.mcpCreateTask)
m.Register(mcp.Tool{
Name: "create_task",
Description: "Create a task. Prefer passing the whole thing as natural language in " +
"`text` — \"betal moms i morgen kl 10 p1 #Firma @regnskab\" — because the app's own " +
"parser handles dates, times, priorities, projects, labels and recurrence in Danish " +
"and English. Use the explicit fields only when the user was explicit.",
InputSchema: mcp.Schema(map[string]any{
"text": mcp.Str("The task as a sentence, parsed for date, time, priority, #project, @label and recurrence."),
"content": mcp.Str("The task title, if not using `text`."),
"project_id": mcp.Str("Which project. Defaults to the Inbox. Naming a project that does not exist is not an error — the task goes to the Inbox and the answer says so."),
"parent_id": mcp.Str("Make this a sub-task of that task. It joins the parent's project. One level deep."),
"due_date": mcp.Str("A date as YYYY-MM-DD."),
"priority": mcp.Int("1 (highest) to 4 (none)."),
"description": mcp.Str("Longer notes on the task."),
"labels": mcp.StrArray("Label names."),
}),
}, s.mcpCreateTask)
m.Register(mcp.Tool{
Name: "update_task",
Description: "Change an existing task: its title, description, priority, due date or " +
"project. Only the fields given are changed. To move a due date, pass due_date; " +
"to clear it, pass an empty string.",
InputSchema: mcp.Schema(map[string]any{
"task_id": mcp.Str("The task to change."),
"content": mcp.Str("A new title."),
"description": mcp.Str("New notes."),
"priority": mcp.Int("1 (highest) to 4 (none)."),
"due_date": mcp.Str("A date as YYYY-MM-DD, or an empty string to remove the date."),
"project_id": mcp.Str("Move the task to another project."),
"labels": mcp.StrArray("Replace the task's labels."),
}, "task_id"),
}, s.mcpUpdateTask)
m.Register(mcp.Tool{
Name: "update_task",
Description: "Change an existing task: its title, description, priority, due date or " +
"project. Only the fields given are changed. To move a due date, pass due_date; " +
"to clear it, pass an empty string.",
InputSchema: mcp.Schema(map[string]any{
"task_id": mcp.Str("The task to change."),
"content": mcp.Str("A new title."),
"description": mcp.Str("New notes."),
"priority": mcp.Int("1 (highest) to 4 (none)."),
"due_date": mcp.Str("A date as YYYY-MM-DD, or an empty string to remove the date."),
"project_id": mcp.Str("Move the task to another project."),
"labels": mcp.StrArray("Replace the task's labels."),
}, "task_id"),
}, s.mcpUpdateTask)
m.Register(mcp.Tool{
Name: "complete_task",
Description: "Mark a task done. A repeating task advances to its next occurrence " +
"instead of closing, and the result says so.",
InputSchema: mcp.Schema(map[string]any{
"task_id": mcp.Str("The task to complete."),
}, "task_id"),
}, s.mcpCompleteTask)
m.Register(mcp.Tool{
Name: "complete_task",
Description: "Mark a task done. A repeating task advances to its next occurrence " +
"instead of closing, and the result says so.",
InputSchema: mcp.Schema(map[string]any{
"task_id": mcp.Str("The task to complete."),
}, "task_id"),
}, s.mcpCompleteTask)
m.Register(mcp.Tool{
Name: "add_comment",
Description: "Add a comment to a task. Everyone else with access to the project is notified.",
InputSchema: mcp.Schema(map[string]any{
"task_id": mcp.Str("The task to comment on."),
"body": mcp.Str("What to say."),
}, "task_id", "body"),
}, s.mcpAddComment)
m.Register(mcp.Tool{
Name: "add_comment",
Description: "Add a comment to a task. Everyone else with access to the project is notified.",
InputSchema: mcp.Schema(map[string]any{
"task_id": mcp.Str("The task to comment on."),
"body": mcp.Str("What to say."),
}, "task_id", "body"),
}, s.mcpAddComment)
}
s.registerNoteTools(m)
return m
+115 -39
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 {
@@ -159,13 +167,31 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
// Docker both need this to answer before anyone has logged in.
r.Get("/healthz", s.handleHealth)
// The same question as inside /api/v1, asked again here because four of the
// task-only surfaces live outside it: the calendar feed, CalDAV, the inbox
// hook, and the well-known path CalDAV clients discover through. They are out
// here because their callers cannot hold a session, not because they are any
// less about tasks — and the first gate missed all four for exactly that
// reason. It read as complete because the /tasks tree was covered.
full := !s.cfg.NotesOnly()
// The calendar feed is outside /api/v1 and outside the session: a calendar
// client cannot log in, so the token in the path is the whole credential.
r.Get("/ics/{token}", s.handleICSFeed)
//
// A feed of VTODOs, so there is nothing for it to answer in the notes edition.
if full {
r.Get("/ics/{token}", s.handleICSFeed)
// CalDAV, outside /api/v1 because clients do well-known-path discovery
// against the root and authenticate with Basic rather than a session.
r.Get("/.well-known/caldav", s.wellKnownCalDAV)
// CalDAV, outside /api/v1 because clients do well-known-path discovery
// against the root and authenticate with Basic rather than a session.
//
// Withheld rather than left to answer an empty collection: the well-known
// path is a claim that a CalDAV server is here, and a client that believes
// it goes on to PROPFIND, authenticate, and sync nothing — reporting a
// broken calendar server on an instance that has none and needs none. Same
// reasoning as the /.well-known/ 404 in serveWeb.
r.Get("/.well-known/caldav", s.wellKnownCalDAV)
}
// Inbound mail. Delivered by the mail server rather than by a browser, and
// authenticated by the token in the recipient address.
@@ -190,8 +216,13 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
//
// Sin egen kvote frem for postens: en travl integration skal ikke kunne lukke
// for den indgående post, og omvendt.
r.Method(http.MethodPost, "/inbound/hook/{token}",
s.rateLimit(s.hookLimiter, http.HandlerFunc(s.handleInboundHook)))
//
// Opgaver, efter sin egen beskrivelse ovenfor — den skubber en linje tekst ind
// i indbakken — så den findes ikke i noter-udgaven.
if full {
r.Method(http.MethodPost, "/inbound/hook/{token}",
s.rateLimit(s.hookLimiter, http.HandlerFunc(s.handleInboundHook)))
}
// MCP with the token in the query string, for clients that cannot send a
// header. Claude's custom-connector dialog takes a URL and nothing else, so
@@ -218,7 +249,10 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
// Gmail connection with calendar tokens.
r.Get("/oauth/calendar/callback", s.handleCalendarCallback)
})
r.Route("/caldav", s.caldavRoutes)
// Hele CalDAV-træet: otte ruter, der alle læser eller skriver en VTODO.
if full {
r.Route("/caldav", s.caldavRoutes)
}
r.Route("/api/v1", func(r chi.Router) {
r.Use(s.requireCSRF)
@@ -346,7 +380,12 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
})
}
r.Get("/feed", s.handleGetFeed)
// Tokenet til ICS-feedet ovenfor. Et token til et feed, der ikke
// findes, er en indstillingsside, der tilbyder en adresse, intet
// svarer på.
if full {
r.Get("/feed", s.handleGetFeed)
}
r.Route("/notifications", func(r chi.Router) {
r.Get("/", s.handleListNotifications)
@@ -372,7 +411,9 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Patch("/", s.handleUpdateComment)
r.Delete("/", s.handleDeleteComment)
})
r.Post("/feed/rotate", s.handleRotateFeed)
if full {
r.Post("/feed/rotate", s.handleRotateFeed)
}
r.Get("/mail-address", s.handleGetMailAddress)
r.Post("/mail-address/rotate", s.handleRotateMailAddress)
@@ -393,20 +434,24 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Get("/settings", s.handleGetAISettings)
r.Put("/settings", s.handleSetAISettings)
r.Post("/summary", s.handleAISummary)
r.Post("/tasks/{taskID}/split", s.handleAISplit)
// Forslag, ikke skrivninger. De to første svarer med linjer i
// quick-add-syntaks, som fladen viser frem; de to sidste er dét,
// der sker, når nogen siger ja til én af dem.
r.Post("/inbox/tidy", s.handleAITidyInbox)
r.Post("/inbox/tidy/apply", s.handleAIApplyToTask)
if full {
r.Post("/tasks/{taskID}/split", s.handleAISplit)
// Forslag, ikke skrivninger. De to første svarer med linjer i
// quick-add-syntaks, som fladen viser frem; de to sidste er dét,
// der sker, når nogen siger ja til én af dem.
r.Post("/inbox/tidy", s.handleAITidyInbox)
r.Post("/inbox/tidy/apply", s.handleAIApplyToTask)
}
r.Post("/notes/{noteID}/actions", s.handleAINoteActions)
r.Post("/notes/{noteID}/actions/apply", s.handleAICreateFromNote)
// Dagens plan: indstillingen, og en knap til at se den nu. En
// indstilling, man skal vente til i morgen for at se virkningen af,
// er en indstilling, ingen tør slå til.
r.Get("/plan", s.handleGetPlanSettings)
r.Put("/plan", s.handleSetPlanSettings)
r.Post("/plan/now", s.handlePlanNow)
if full {
r.Get("/plan", s.handleGetPlanSettings)
r.Put("/plan", s.handleSetPlanSettings)
r.Post("/plan/now", s.handlePlanNow)
}
// Spørg om sine egne noter og opgaver i almindeligt sprog. Basen
// finder kandidaterne, modellen læser dem — og svaret bærer, hvad
// det er bygget på.
@@ -491,24 +536,38 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Delete("/subscriptions/{id}", s.handleUnsubscribeCalendar)
})
r.Route("/import", func(r chi.Router) {
r.Post("/todoist", s.handleImportTodoist)
r.Post("/csv", s.handleImportCSV)
})
// Begge retninger laver eller læser opgaver: Todoist-importen laver
// dem, CSV-importen laver dem, og de fire opgave-eksporter skriver dem
// ud. De blev alle betjent i noter-udgaven indtil nu — de svarede tomt
// frem for at fejle, hvilket er den stille version af samme fejl.
if full {
r.Route("/import", func(r chi.Router) {
r.Post("/todoist", s.handleImportTodoist)
r.Post("/csv", s.handleImportCSV)
})
}
r.Get("/export/account", s.handleExportAccount)
r.Get("/export/notes.zip", s.handleExportNotes)
r.Get("/export/projects.zip", s.handleExportAllCSV)
r.Get("/export/tasks.ics", s.handleExportAllICS)
r.Get("/export/projects/{projectID}.csv", s.handleExportProject)
r.Get("/export/projects/{projectID}.ics", s.handleExportProjectICS)
if full {
r.Get("/export/projects.zip", s.handleExportAllCSV)
r.Get("/export/tasks.ics", s.handleExportAllICS)
r.Get("/export/projects/{projectID}.csv", s.handleExportProject)
r.Get("/export/projects/{projectID}.ics", s.handleExportProjectICS)
}
r.Route("/templates", func(r chi.Router) {
r.Get("/", s.handleListTemplates)
r.Post("/", s.handleSaveTemplate)
r.Post("/{templateID}/use", s.handleUseTemplate)
r.Delete("/{templateID}", s.handleDeleteTemplate)
})
// En skabelon er et projekt plus sektioner plus opgaver — læst i
// CreateProjectFromTemplate frem for gættet ud fra rutenavnet, som
// kun siger "projekt". Uden opgaver er den et tomt projekt med
// overskrifter i.
if full {
r.Route("/templates", func(r chi.Router) {
r.Get("/", s.handleListTemplates)
r.Post("/", s.handleSaveTemplate)
r.Post("/{templateID}/use", s.handleUseTemplate)
r.Delete("/{templateID}", s.handleDeleteTemplate)
})
}
if full {
r.Delete("/reminders/{reminderID}", s.handleDeleteReminder)
@@ -539,7 +598,13 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Use(s.requireProject(store.RoleViewer))
r.Get("/", s.handleGetProject)
r.Get("/sections", s.handleListSections)
// Sektioner grupperer opgaver inde i et projekt, og det er
// afgjort af skemaet frem for af navnet: `section_id` står på
// `tasks` og findes ikke på `notes`. En sektion i en notesbog
// kan ikke indeholde noget.
if full {
r.Get("/sections", s.handleListSections)
}
r.Get("/members", s.handleListMembers)
// Uden for ejer-gruppen, fordi den nu dækker to ting: ejeren, der
// fjerner nogen, og den, der selv går. Handleren skiller dem ad.
@@ -548,8 +613,10 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Group(func(r chi.Router) {
r.Use(s.requireProject(store.RoleEditor))
r.Post("/sections", s.handleCreateSection)
r.Post("/sections/reorder", s.handleReorderSections)
if full {
r.Post("/sections", s.handleCreateSection)
r.Post("/sections/reorder", s.handleReorderSections)
}
})
r.Group(func(r chi.Router) {
@@ -624,10 +691,12 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Get("/trash/projects", s.handleListTrashedProjects)
r.Post("/trash/projects/{projectID}/restore", s.handleRestoreProject)
r.Route("/sections/{sectionID}", func(r chi.Router) {
r.Patch("/", s.handleUpdateSection)
r.Delete("/", s.handleDeleteSection)
})
if full {
r.Route("/sections/{sectionID}", func(r chi.Router) {
r.Patch("/", s.handleUpdateSection)
r.Delete("/", s.handleDeleteSection)
})
}
if full {
r.Route("/tasks", func(r chi.Router) {
@@ -728,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")
}
}
+96 -5
View File
@@ -1,4 +1,5 @@
import { test, expect } from '@playwright/test';
import { watchForTrouble } from './trouble.js';
/**
* Noter-udgaven, kørt som sit eget program.
@@ -140,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');
@@ -179,3 +180,93 @@ test('navnet står også i teksterne, ikke kun i mærket', async ({ page }) => {
await expect(page.getByRole('button', { name: /^Urd\b/ })).toBeVisible();
await expect(page.getByRole('button', { name: /^Verdande\b/ })).toHaveCount(0);
});
/**
* Hver side, der KAN nås i noter-udgaven, må ikke kalde noget, der er lukket.
*
* Det er den halvdel, en port let glemmer, og den fejler stille: lukkes en rute,
* fladen stadig henter, svarer serveren 404, komponenten fanger det, og siden
* tegner uden den sektion — pænt, tomt, og uden at nogen får at vide, at noget
* manglede. Der er ingen fejlskærm at se.
*
* Målt på svarene frem for på det tegnede: en manglende sektion ser ud som en
* sektion, der ikke var noget i.
*
* Skrevet før ruterne blev lukket, og den fangede SYV — fire af dem fra den første
* port, som havde været i drift i to merges: sidebjælken hentede /filters og
* /labels ved hver navigation, notifikationssiden hentede /ai/plan, og
* notesbogens egen side hentede /tasks. Den sidste var ikke støj: kaldene samles i
* et Promise.all, så 404'eren kastede og siden viste `status = 'failed'` frem for
* notesbogens noter.
*
* Og den fangede én mere, som rettelsen selv lavede: da opgavekaldet holdt op med
* at fejle, kom en uendelig effekt-løkke frem, som 404'eren havde holdt låg på
* (effect_update_depth_exceeded). Derfor læser den konsollen og ikke kun svarene.
*
* Hvad den IKKE dækker, sagt her frem for underforstået: elleve sider plus ét
* projekt. En gruppe (/gruppe/{id}) og en åben note er ikke med, fordi begge
* kræver, at prøven først laver en — og skuffer og dialoger, der åbnes ved et
* klik, er slet ikke besøgt. En grøn her betyder "ingen af de tolv adresser",
* ikke "ingen flade".
*/
test('ingen side kalder en rute, udgaven ikke har', async ({ page }) => {
const trouble = watchForTrouble(page);
// Projektet skal med som en rigtig adresse: /projekt/{id} er en notesbog her,
// og den er den eneste side med en id i stien, der kan nås.
await page.goto('/noter');
const projekter = await (await page.request.get('/api/v1/projects')).json();
const id = (projekter.projects ?? projekter)[0]?.id;
expect(id, 'der skal findes mindst ét projekt at åbne').toBeTruthy();
const sider = [
'/noter',
'/genveje',
'/indstillinger',
'/indstillinger/ai',
'/indstillinger/brugere',
'/indstillinger/data',
'/indstillinger/fejl',
'/indstillinger/historik',
'/indstillinger/integrationer',
'/indstillinger/notifikationer',
'/indstillinger/tokens',
`/projekt/${id}`
];
for (const side of sider) {
await page.goto(side);
// Ventet på at fladen er tegnet, så de kald siden laver ved opstart er
// nået at komme afsted. Uden den her går løkken videre, før svarene er
// kommet, og prøven måler ingenting.
await expect(page.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
await page.waitForLoadState('networkidle');
}
// 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);
}
});
+9 -35
View File
@@ -1,5 +1,6 @@
import { test, expect } from '@playwright/test';
import { USER } from './user.js';
import { watchForTrouble } from './trouble.js';
/**
* The four flows a broken build must not survive: add a task, tick it off, reach
@@ -11,14 +12,6 @@ import { USER } from './user.js';
* precisely the bug this suite exists to catch.
*/
/**
* Collects anything that went wrong quietly: a thrown exception, a console error,
* or an API call that came back a failure.
*
* Failed responses are recorded with their URL and status. "A console error
* happened" is not an actionable test failure — "PATCH /api/v1/tasks/x returned
* 500" is.
*/
/**
* Opens the project header's overflow menu and clicks one of its items.
*
@@ -93,32 +86,6 @@ async function nyNote(side) {
return felt;
}
function watchForTrouble(page) {
const trouble = [];
page.on('console', (message) => {
// The browser logs its own line for every failed fetch. It carries no URL,
// so it is noise next to the response listener below.
if (message.type() === 'error' && !message.text().includes('Failed to load resource')) {
trouble.push(message.text());
}
});
page.on('pageerror', (error) => trouble.push(String(error)));
page.on('response', (response) => {
const url = response.url();
if (!url.includes('/api/') || response.ok()) return;
// A 401 from /auth/me is the app asking "am I signed in?" and being told no,
// which is the correct answer on the sign-in page and after signing out.
// Every other non-ok response is worth failing over — that is what this
// watcher is for.
const path = new URL(url).pathname;
if (path.endsWith('/auth/me') && response.status() === 401) return;
trouble.push(`${response.request().method()} ${path} → ${response.status()}`);
});
return trouble;
}
/**
* Kommendes liste kan se længere frem end en uge.
*
@@ -5531,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');
@@ -5540,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');
});
+38
View File
@@ -0,0 +1,38 @@
/**
* Collects anything that went wrong quietly: a thrown exception, a console error,
* or an API call that came back a failure.
*
* Failed responses are recorded with their URL and status. "A console error
* happened" is not an actionable test failure — "PATCH /api/v1/tasks/x returned
* 500" is.
*
* Udskilt fra smoke.spec.js, så udgave-suiten kan bruge den samme — og den
* fortjente flytningen med det samme: lukker man en rute, fladen stadig kalder,
* står der en 404 på en side, der ser helt rigtig ud, og det her er det eneste,
* der ser den.
*/
export function watchForTrouble(page) {
const trouble = [];
page.on('console', (message) => {
// The browser logs its own line for every failed fetch. It carries no URL,
// so it is noise next to the response listener below.
if (message.type() === 'error' && !message.text().includes('Failed to load resource')) {
trouble.push(message.text());
}
});
page.on('pageerror', (error) => trouble.push(String(error)));
page.on('response', (response) => {
const url = response.url();
if (!url.includes('/api/') || response.ok()) return;
// A 401 from /auth/me is the app asking "am I signed in?" and being told no,
// which is the correct answer on the sign-in page and after signing out.
// Every other non-ok response is worth failing over — that is what this
// watcher is for.
const path = new URL(url).pathname;
if (path.endsWith('/auth/me') && response.status() === 401) return;
trouble.push(`${response.request().method()} ${path} → ${response.status()}`);
});
return trouble;
}
+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();
+13 -6
View File
@@ -73,6 +73,11 @@
// through the connector.
$effect(() => {
app.labelsChanged;
// Ikke i noter-udgaven: begge ruter findes slet ikke der, så de to kald var
// to 404'ere ved hver navigation — opslugt af .catch() og derfor usynlige.
// Fundet af vandringen i edition.spec.js, efter at porten havde været i
// drift i to merges.
if (app.notesOnly) return;
api.listFilters().then((r) => (filters = r.filters)).catch(() => {});
api.listLabels().then((r) => (labels = r.labels)).catch(() => {});
});
@@ -475,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>
+28 -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. */
@@ -343,6 +352,21 @@ class AppState {
* `completed: 'exclude'`.
*/
async loadTasks(params) {
// Noter-udgaven har ingen opgaveruter, så den her ville kaste en 404 — og
// kalderne samler den i et Promise.all, hvor én fejl tager hele siden med.
// Vagten står HER frem for ved de tre kaldesteder, fordi butikken er det
// ene sted, der kender udgaven: en vagt pr. kalder er tre steder at glemme
// den fjerde, og det var præcis sådan den her slap igennem.
if (this.notesOnly) {
// Skrevet kun hvis der ER noget at rydde. En ubetinget tilskrivning
// looper: projektsiden har en effekt, der læser app.tasks og kalder
// herind, så et skriv ved hvert kald er en uendelig runde —
// effect_update_depth_exceeded, ikke en 404. Før vagten kastede kaldet
// på 404'eren og nåede aldrig at skrive, så løkken var skjult bag en
// fejl.
if (this.tasks.length) this.tasks = [];
return this.tasks;
}
const { tasks } = await api.listTasks({
completed: completedView.shown ? 'include' : undefined,
...params
@@ -141,6 +141,13 @@
let startDate = $state('');
$effect(() => {
// Skabeloner er projekt plus sektioner plus opgaver, så ruten findes ikke i
// noter-udgaven — og den her fangede fejlen i en toast, altså en synlig
// fejlbesked på indstillingssiden frem for en tom liste.
if (app.notesOnly) {
loadingTemplates = false;
return;
}
api
.listTemplates()
.then((r) => (templates = r.templates))
@@ -296,7 +296,11 @@
let mailConfigured = $state(null);
$effect(() => {
api.feed().then((r) => (feedURL = r.url)).catch(() => {});
// Feedet er en kalender af opgaver, så adressen findes ikke i
// noter-udgaven.
if (!app.notesOnly) {
api.feed().then((r) => (feedURL = r.url)).catch(() => {});
}
api
.getMailAddress()
.then((r) => {
@@ -92,6 +92,8 @@
let planBusy = $state(false);
$effect(() => {
// Dagens plan er dagens opgaver.
if (app.notesOnly) return;
api
.planSettings()
.then((p) => (plan = p))
+6 -1
View File
@@ -88,9 +88,14 @@
if (!projectId) return;
status = 'loading';
try {
// Sektioner findes ikke i noter-udgaven, hvor et projekt er en notesbog
// — og det er ikke bare støj: Promise.all kaster på den første, der
// fejler, så en 404 herfra satte status = 'failed', og siden viste en
// fejlskærm frem for notesbogens noter. `loadTasks` vogter sig selv i
// butikken, fordi den har tre kaldesteder her.
const [p, s] = await Promise.all([
api.getProject(projectId),
api.listSections(projectId),
app.notesOnly ? Promise.resolve({ sections: [] }) : api.listSections(projectId),
app.loadTasks({ project_id: projectId })
]);
project = p;
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'
}
}
};