diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
index ce14c4e..86285c4 100644
--- a/.gitea/workflows/ci.yml
+++ b/.gitea/workflows/ci.yml
@@ -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.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 395050e..857eb1d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -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'
diff --git a/Dockerfile b/Dockerfile
index abab844..da0bdc8 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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
diff --git a/docs/editions.md b/docs/editions.md
index ff0451f..c3478d1 100644
--- a/docs/editions.md
+++ b/docs/editions.md
@@ -13,6 +13,58 @@ Set `VERDANDE_EDITION=notes` and the instance is urd: the task routes are not
mounted, the sidebar has no way to them, search answers with notes only, and the
program calls itself urd in its tab, its wordmark and its web manifest.
+## Running the notes edition
+
+There is one image. The edition is an environment variable on the container:
+
+```
+docker run -e VERDANDE_EDITION=notes -v urd-data:/data -p 8080:8080 \
+ ghcr.io/kristianwind/verdande:latest
+```
+
+No second image and no rune of its own, deliberately.
+
+The edition is for an operator who wants an instance that genuinely has no task
+routes — the routes are absent from the router, not hidden in the interface. It is
+**not** how urd is normally reached. That is being built as a second face of one
+instance, with its own entry point and its own web manifest, so that both can be
+installed as separate apps while sharing one database, one login and one set of
+links. Until that lands, this page describes the edition only.
+
+Two details that surprise people who look inside:
+
+- **The binary is called `/verdande`** and the database file is `verdande.db`, in
+ both editions. Not an oversight: it is the same binary, and renaming the file per
+ edition would be a migration hazard in exchange for nothing a user sees.
+- **The icons for both editions ship in the one image.** The server decides which
+ to serve at `/icon.svg`, because Safari looks for `apple-touch-icon.png` by name
+ and never reads the manifest, so renaming paths would have left iOS with the
+ wrong mark.
+
+## Moving between the two, measured
+
+A `/data` directory moves between the editions in **both directions, losslessly**.
+Same binary, same schema, same migrations — so this is not a feature that was built,
+it is a property that was checked:
+
+| step | result |
+|---|---|
+| verdande creates a notebook, a note in it, and a task | 1 note, 1 task |
+| the same directory, `VERDANDE_EDITION=notes` | `needs_setup: false` — same account. The note keeps its title, its body **and its `project_id`**. `/api/v1/tasks` → 404 |
+| the same directory, back to `full` | the task is still there; so is the note |
+
+The ownership stayed at the panel's service account through all three.
+
+So the way to move notes is to change the variable. `GET /export/notes.zip` is for
+getting notes **into other programs** — it writes one `.md` per note and carries the
+body and nothing else: no notebook, no sharing, no attachments, no pinned flag. That
+is the right division of labour, because the database already holds those.
+
+!!! note "The task rows stay"
+ An instance run as `notes` still has its tasks in the database; they are simply
+ not served. Harmless on a fresh instance, dead weight on a converted one — and
+ worth knowing before handing a converted instance to somebody else.
+
## Why one codebase and not a fork
The two share everything that is hard: accounts, sessions, passkeys, sharing,
diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go
index 5fa464f..53fe0ed 100644
--- a/internal/httpapi/server.go
+++ b/internal/httpapi/server.go
@@ -83,6 +83,9 @@ type Server struct {
shell []byte
manifest []byte
+ // Icon paths this edition serves from another file. Empty in the full edition.
+ assets map[string]string
+
router chi.Router
}
@@ -125,6 +128,11 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
} else {
s.shell, s.manifest = shell, manifest
}
+
+ s.assets = resolveNotesAssets(web, func(name, alias string) {
+ log.Error("this edition has no icon of its own at that path; it will serve verdande's",
+ "path", name, "wanted", alias)
+ })
}
if s.shell != nil {
@@ -789,6 +797,13 @@ func (s *Server) serveWeb(w http.ResponseWriter, r *http.Request) {
name = "index.html"
}
+ // This edition's own icon, where it has one. Before the Open rather than after,
+ // unlike the shell below: these are real files, so there is no fallback to get
+ // in the way, and resolveNotesAssets has already checked each one exists.
+ if alias, ok := s.assets[name]; ok {
+ name = alias
+ }
+
f, err := s.web.Open(name)
if errors.Is(err, fs.ErrNotExist) {
// A client-side route: hand back the shell and let the app route it.
diff --git a/internal/httpapi/shell.go b/internal/httpapi/shell.go
index 0c6cac0..fc5ae54 100644
--- a/internal/httpapi/shell.go
+++ b/internal/httpapi/shell.go
@@ -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
+}
diff --git a/internal/httpapi/shell_test.go b/internal/httpapi/shell_test.go
index cd117f2..52be2ea 100644
--- a/internal/httpapi/shell_test.go
+++ b/internal/httpapi/shell_test.go
@@ -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")
+ }
+}
diff --git a/web/e2e/edition.spec.js b/web/e2e/edition.spec.js
index a679768..685a0f7 100644
--- a/web/e2e/edition.spec.js
+++ b/web/e2e/edition.spec.js
@@ -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);
+ }
+});
diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js
index 899ae20..e9ca825 100644
--- a/web/e2e/smoke.spec.js
+++ b/web/e2e/smoke.spec.js
@@ -5498,7 +5498,7 @@ test('the full edition still calls itself verdande', async ({ page }) => {
const brand = page.getByRole('navigation', { name: /Hovedmenu|Main menu/ }).locator('.brand');
await expect(brand.locator('.name')).toHaveText('verdande');
- // ᚹ wunjo, not ᚢ uruz.
+ // ᚹ wunjo, not ð.
await expect(brand.locator('.rune')).toHaveText('ᚹ');
await page.goto('/noter');
@@ -5507,4 +5507,11 @@ test('the full edition still calls itself verdande', async ({ page }) => {
const manifest = await (await page.request.get('/manifest.webmanifest')).json();
expect(manifest.name).toBe('verdande');
expect(manifest.description).toBe('Opgaver og projekter, delt.');
+
+ // The other half of the icon alias: this edition must still get Wunjo. Both
+ // sets of files are in the same binary, so a swap that fired in both editions
+ // would pass in edition.spec.js and quietly change this program's mark.
+ const icon = await (await page.request.get('/icon.svg')).text();
+ expect(icon).toContain('M26 15l17');
+ expect(icon).not.toContain('M44 36L27 10');
});
diff --git a/web/scripts/generate-icons.js b/web/scripts/generate-icons.js
index 2674b61..406301a 100644
--- a/web/scripts/generate-icons.js
+++ b/web/scripts/generate-icons.js
@@ -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(
- `${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(
+ `${svg}`
+ );
+ writeFileSync(file, await page.locator('svg').screenshot());
+ console.log(`${file} ${size}×${size}`);
+ }
}
await browser.close();
diff --git a/web/src/lib/components/Sidebar.svelte b/web/src/lib/components/Sidebar.svelte
index fc15bdd..1e2cb44 100644
--- a/web/src/lib/components/Sidebar.svelte
+++ b/web/src/lib/components/Sidebar.svelte
@@ -480,12 +480,14 @@