feat(udgaver): urd — noter-udgaven siger sit eget navn

Verdande og Urd er to af nornerne i Völuspá, og digtet har dem skære
mærker i træ: *skáru á skíði*. Verdande er det der bliver; Urd er det der
blev, og blev gemt. En instans startet uden opgaveruterne er et andet
produkt for den der bruger den, så den siger et andet navn.

Navnet læses fra udgaven i ÉT sted — Config.ProductName/ProductSlug —
frem for at stå skrevet i teksterne. Frontend-bygget er byte-identisk i
begge udgaver; serveren omdøber de to filer, browseren læser før noget
JavaScript kører.

Der er to titler, og det blev først klart, da e2e-prøven sagde det:
skallens <title>, som serveren omdøber, og rutens egen i <svelte:head>,
som appen sætter bagefter og som overskriver den første. Go-prøven var
grøn over de bytes, serveren sendte, mens fanen sagde "Noter · verdande".
Begge prøver spørger nu om begge.

Omdøbningen er ankret på hele strenge frem for på ordet "verdande", fordi
app.html også indeholder localStorage.getItem('verdande:theme'). Omdøbes
præfikset der og ikke i resten af appen, bliver temaet sat fra én nøgle og
læst fra en anden, og det eneste symptom er det hvide blink, det
indlejrede script findes for at undgå. Tre plantede fejl bekræfter, at det
er de rigtige prøver, der fanger det: et flyttet anker i app.html, en bred
søg-erstat, og en applyRebrands uden fejlretur.

Mailen var det vigtigste sted. appName var et felt med en værdi og ingen
læser, og kommentaren ved siden af sagde, at det stod i hver besked — de
tre steder havde ordet skrevet ud i stedet. En invitation fra en
urd-instans går til et andet menneskes indbakke.

Også omdøbt: web-manifestet (og sendt som application/manifest+json — Go
kender ikke .webmanifest og sniffer den til text/plain, hvorefter
browseren ignorerer manifestet uden at sige noget, og installationsknappen
bare ikke er der), mærket i menuen (ᚢ uruz frem for ᚹ wunjo),
opstartsloggen, passkey-promptens visningsnavn, ICS-kalendernavnet og
udseendet, der er opkaldt efter programmet.

Ikonet er IKKE med: icon.svg er stadig ᚹ, og de tre PNG'er genereres fra
den. Det rører binære, genererede filer og hører i sin egen ændring.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
kristianwind
2026-10-07 01:11:25 +02:00
co-authored by Claude Opus 5
parent 7e3d300183
commit 6154e6b247
29 changed files with 821 additions and 41 deletions
+61
View File
@@ -118,3 +118,64 @@ test('opgaveruterne findes slet ikke på serveren', async ({ page }) => {
// ovenstående bestå på en server, der var helt væk.
expect((await page.request.get('/api/v1/notes')).status()).toBe(200);
});
/**
* Navnet.
*
* To udgaver af ét program er to produkter, og det eneste sted en læser afgør
* hvilket af dem de ser på, er navnet: fanen, mærket i menuen, og det navn appen
* lægger sig under på en hjemmeskærm. De tre står i filer, browseren læser FØR
* noget JavaScript kører, så serveren skriver dem om på vejen ud — og derfor skal
* de måles her, på det der faktisk blev sendt, frem for i en funktion.
*/
test('fanen, mærket og manifestet siger alle urd', async ({ page }) => {
// Der er TO titler, og det blev opdaget her frem for i en Go-prøve: skallens,
// som browseren læser før noget JavaScript kører og som serveren skriver om,
// og rutens egen, som appen sætter i <svelte:head> når siden tegner. Den sidste
// overskriver den første — så en prøve, der kun læste den ene, ville være grøn
// over en fane, der sagde "Noter · verdande".
const skal = await (await page.request.get('/')).text();
expect(skal, 'skallens titel, før appen kører').toContain('<title>urd</title>');
await expect(page, 'rutens egen titel, efter appen har tegnet').toHaveTitle(/· urd$/);
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('ᚢ');
const manifest = await (await page.request.get('/manifest.webmanifest')).json();
expect(manifest.name).toBe('urd');
expect(manifest.short_name).toBe('urd');
});
test('manifestet bliver sendt som et manifest, ikke som tekst', async ({ page }) => {
// Go kender ikke .webmanifest, så uden en eksplicit type sniffes den til
// text/plain — og en browser ignorerer så manifestet uden at sige noget.
// Symptomet er at installationsknappen bare ikke er der.
const svar = await page.request.get('/manifest.webmanifest');
expect(svar.headers()['content-type']).toContain('application/manifest+json');
});
test('omdøbningen rørte ikke lagernøglerne', async ({ page }) => {
// Fælden i hele øvelsen: produktnavnet og localStorage-præfikset er samme ord
// i app.html, og de er ikke samme ting. Omdøbes præfikset her og ikke i
// resten af appen, bliver temaet sat fra én nøgle og læst fra en anden, og det
// eneste symptom er det hvide blink, det indlejrede script findes for at
// undgå. Målt på de bytes der blev sendt, ikke på kilden.
const skal = await (await page.request.get('/')).text();
expect(skal).toContain("localStorage.getItem('verdande:theme')");
expect(skal).toContain("localStorage.getItem('verdande:look')");
expect(skal).not.toContain('urd:theme');
});
test('navnet står også i teksterne, ikke kun i mærket', async ({ page }) => {
// `{product}`-pladsholderen, hele vejen gennem i18n og ud på skærmen. Udseendet
// "som du kender det" er opkaldt efter programmet, så det er det kort, der
// skifter navn med udgaven.
await page.goto('/indstillinger');
await expect(page.getByRole('button', { name: /^Urd\b/ })).toBeVisible();
await expect(page.getByRole('button', { name: /^Verdande\b/ })).toHaveCount(0);
});
+32
View File
@@ -5509,3 +5509,35 @@ test('søgefeltet taber ikke bogstaver, og det nyeste svar vinder', async ({ pag
await page.unroute('**/api/v1/notes?*');
expect(trouble).toEqual([]);
});
/**
* The control for the notes edition's renaming, and it belongs here rather than
* beside it: the whole point is that the full edition does NOT go through that
* path, and that can only be measured on an instance which is not the notes one.
*
* A gate asserted in one direction is half a gate. edition.spec.js proves the
* notes edition calls itself urd; without this, a rename that fired in both
* editions would pass there and change the name of this program.
*/
test('the full edition still calls itself verdande', async ({ page }) => {
await page.goto('/');
// Both titles, for the same reason edition.spec.js checks both: the shell's,
// which the browser reads before any script runs, and the route's own, set in
// <svelte:head> once the page draws. Today has no title of its own, so the
// shell's is what stands here — and /noter has one, so it is asked separately.
const shell = await (await page.request.get('/')).text();
expect(shell).toContain('<title>verdande</title>');
await expect(page).toHaveTitle('verdande');
const brand = page.getByRole('navigation', { name: /Hovedmenu|Main menu/ }).locator('.brand');
await expect(brand.locator('.name')).toHaveText('verdande');
// ᚹ wunjo, not ᚢ uruz.
await expect(brand.locator('.rune')).toHaveText('ᚹ');
await page.goto('/noter');
await expect(page).toHaveTitle(/· verdande$/);
const manifest = await (await page.request.get('/manifest.webmanifest')).json();
expect(manifest.name).toBe('verdande');
expect(manifest.description).toBe('Opgaver og projekter, delt.');
});
+1 -1
View File
@@ -64,7 +64,7 @@
{#if notice}
<aside class="notice" role="region" aria-label={t('beacon.noticeTitle')}>
<h2>{t('beacon.noticeTitle')}</h2>
<p>{t('beacon.noticeBody')}</p>
<p>{t('beacon.noticeBody', { product: app.productName })}</p>
<!-- Det, der bliver sendt, med de rigtige værdier i. Ikke et eksempel: det
er selve meldingen, og den kan sammenholdes med indstillingssiden. -->
+1 -1
View File
@@ -38,7 +38,7 @@
</div>
<h2>{t('net.downTitle')}</h2>
<p class="lede">{t('net.downBody')}</p>
<p class="lede">{t('net.downBody', { product: app.productName })}</p>
<button onclick={again} disabled={trying}>
{trying ? t('net.reconnecting') : t('net.downRetry')}
+7 -5
View File
@@ -475,11 +475,13 @@
<nav class="sidebar" aria-label={t('nav.main')}>
<div class="brand">
<!-- Verdande's mark: the rune Wunjo, which is the letter the Norn's name
starts with in the elder futhark. One glyph, no wordmark beside it —
the name is in the tab and everywhere else already. -->
<span class="rune" aria-hidden="true">ᚹ</span>
<span class="name">verdande</span>
<!-- 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>
<span class="name">{app.productSlug}</span>
</div>
<div class="views">
+3 -3
View File
@@ -145,7 +145,7 @@ export const da = {
// læserens mulighed for at efterprøve det.
'beacon.noticeTitle': 'Denne installation melder, at den findes',
'beacon.noticeBody':
'Én gang i døgnet sender Verdande to værdier til projektet, så det kan ses, hvor mange installationer der er. Præcis de her to — intet andet, ingen IP bliver gemt, intet om hvad du bruger det til:',
'Én gang i døgnet sender {product} to værdier til projektet, så det kan ses, hvor mange installationer der er. Præcis de her to — intet andet, ingen IP bliver gemt, intet om hvad du bruger det til:',
'beacon.noticeTo': 'Sendes til {url}',
'beacon.noticeKeep': 'Behold den',
'beacon.noticeStop': 'Slå den fra',
@@ -238,7 +238,7 @@ export const da = {
'net.reconnecting': 'Genopretter forbindelse…',
'net.offline': 'Ingen forbindelse',
'net.downTitle': 'Ingen forbindelse',
'net.downBody': 'Verdande kan ikke nås herfra lige nu. Din tekst er ikke væk — den ligger på serveren, og den kommer igen, når forbindelsen gør.',
'net.downBody': '{product} kan ikke nås herfra lige nu. Din tekst er ikke væk — den ligger på serveren, og den kommer igen, når forbindelsen gør.',
'net.downRetry': 'Prøv igen',
'nav.doItToday': 'Gør det i dag',
@@ -353,7 +353,7 @@ export const da = {
'size.default': 'Standard',
'size.large': 'Stor',
'size.xl': 'Størst',
'look.verdande': 'Verdande',
'look.verdande': '{product}',
'look.verdandeNote': 'Som du kender den.',
'look.rolig': 'Rolig',
'look.roligNote': 'Serif, mere luft. Til at læse og skrive i.',
+3 -3
View File
@@ -142,7 +142,7 @@ export const en = {
// the reader's ability to check it.
'beacon.noticeTitle': 'This installation reports that it exists',
'beacon.noticeBody':
'Once a day Verdande sends two values to the project, so it can see how many installations there are. Exactly these two — nothing else, no IP is stored, nothing about what you use it for:',
'Once a day {product} sends two values to the project, so it can see how many installations there are. Exactly these two — nothing else, no IP is stored, nothing about what you use it for:',
'beacon.noticeTo': 'Sent to {url}',
'beacon.noticeKeep': 'Keep it on',
'beacon.noticeStop': 'Turn it off',
@@ -235,7 +235,7 @@ export const en = {
'net.reconnecting': 'Reconnecting…',
'net.offline': 'No connection',
'net.downTitle': 'No connection',
'net.downBody': 'Verdande cannot be reached from here right now. Nothing you wrote is lost — it is on the server, and it comes back when the connection does.',
'net.downBody': '{product} cannot be reached from here right now. Nothing you wrote is lost — it is on the server, and it comes back when the connection does.',
'net.downRetry': 'Try again',
'nav.doItToday': 'Do it today',
@@ -350,7 +350,7 @@ export const en = {
'size.default': 'Default',
'size.large': 'Large',
'size.xl': 'Largest',
'look.verdande': 'Verdande',
'look.verdande': '{product}',
'look.verdandeNote': 'As you know it.',
'look.rolig': 'Calm',
'look.roligNote': 'Serif, more air. For reading and writing.',
+29
View File
@@ -64,6 +64,35 @@ class AppState {
return this.edition === 'notes';
}
/**
* Navnet instansen kalder sig selv — `Urd` i en sætning, `urd` i et mærke.
*
* To udgaver af ét program, to navne. Grunden til at det læses fra udgaven
* frem for at stå skrevet i teksterne: for den der bruger en instans uden
* opgaveruter er det et andet produkt, og et produkt, der kalder sig sin
* søsters navn, er ikke et produkt — det er et flag, nogen har sat.
*
* Vinduets titel og manifestet står der IKKE. De læses af browseren, før
* noget JavaScript kører, så serveren skriver dem om på vejen ud
* (`internal/httpapi/shell.go`). Gjorde vi det her, ville hver indlæsning
* vise søsterens navn et øjeblik og så rette sig selv.
*/
get productName() {
return this.notesOnly ? 'Urd' : 'Verdande';
}
get productSlug() {
return this.notesOnly ? 'urd' : 'verdande';
}
/**
* Mærket: én rune, det bogstav navnet begynder med i den ældre futhark.
* ᚹ wunjo for Verdande, ᚢ uruz for Urd.
*/
get productRune() {
return this.notesOnly ? '\u16A2' : '\u16B9';
}
/** Transient messages: a failed save, a rolled-back change. */
toasts = $state([]);
connected = $state(false);
+1 -1
View File
@@ -75,7 +75,7 @@
}
</script>
<svelte:head><title>{t('done.title')} — verdande</title></svelte:head>
<svelte:head><title>{t('done.title')} — {app.productSlug}</title></svelte:head>
<div class="view">
<header>
+1 -1
View File
@@ -126,7 +126,7 @@
}
</script>
<svelte:head><title>{group?.name ?? t('group.projects')} — verdande</title></svelte:head>
<svelte:head><title>{group?.name ?? t('group.projects')} — {app.productSlug}</title></svelte:head>
<div class="view">
{#if status === 'denied'}
+4 -1
View File
@@ -379,7 +379,10 @@
<!-- Each card is written in the face it selects, because that is the
only part of the choice a name cannot carry. -->
<span class="look-sample" data-look={option.id} aria-hidden="true">Aa</span>
<span class="theme-name">{t(option.name)}</span>
<!-- The product name is passed to every look, and only one of them has a
place for it: the look named after the program. The other three
carry no placeholder, so the parameter is ignored. -->
<span class="theme-name">{t(option.name, { product: app.productName })}</span>
<span class="theme-note">{t(option.note)}</span>
</button>
{/each}
+5 -1
View File
@@ -12,8 +12,12 @@
* and it reads the token out of the URL itself.
*/
import SignIn from '$lib/components/SignIn.svelte';
// Udgavens navn i titlen. Siden her tegner først, når layoutets `app.load()`
// har svaret, så udgaven er kendt — også uden en session, hvilket er hele
// pointen med den her rute.
import { app } from '$lib/stores.svelte.js';
</script>
<svelte:head><title>Invitation · verdande</title></svelte:head>
<svelte:head><title>Invitation · {app.productSlug}</title></svelte:head>
<SignIn />
+1 -1
View File
@@ -933,7 +933,7 @@
</script>
<svelte:head><title>{t('notes.title')} · verdande</title></svelte:head>
<svelte:head><title>{t('notes.title')} · {app.productSlug}</title></svelte:head>
<svelte:window
onclick={onShareOutside}
+5 -1
View File
@@ -8,8 +8,12 @@
* fill in. Same shape as the invite link, and found the same way.
*/
import SignIn from '$lib/components/SignIn.svelte';
// Udgavens navn i titlen. Siden her tegner først, når layoutets `app.load()`
// har svaret, så udgaven er kendt — også uden en session, hvilket er hele
// pointen med den her rute.
import { app } from '$lib/stores.svelte.js';
</script>
<svelte:head><title>Ny adgangskode · verdande</title></svelte:head>
<svelte:head><title>Ny adgangskode · {app.productSlug}</title></svelte:head>
<SignIn />