diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js
index 63ca5de..4c9fa6a 100644
--- a/web/e2e/smoke.spec.js
+++ b/web/e2e/smoke.spec.js
@@ -4748,6 +4748,40 @@ test('en ny note hentes ikke igen, når den lige er lavet', async ({ page }) =>
expect(trouble).toEqual([]);
});
+/**
+ * Et totalt udfald siger "ingen forbindelse" — ikke "log ind".
+ *
+ * Opstarten spørger serveren, hvem man er. Når svaret aldrig kommer, står `user`
+ * som null, og null betyder "logget ud" alle andre steder i programmet — så et
+ * udfald blev vist som login-skærmen. Appen gav altså sessionen skylden for, at
+ * nettet var nede, og tilbød et kodeordsfelt, der umuligt kunne virke.
+ *
+ * Det er værre end en fejlbesked, fordi det ligner noget, der kunne virke: man
+ * taster kodeordet, der sker ingenting, og man konkluderer, at kontoen er i
+ * stykker. Det skete i virkeligheden 17. september 2026, da instansens DNS-post
+ * blev slettet.
+ *
+ * Prøven afbryder API-kaldene frem for at stoppe serveren: en afbrudt forespørgsel
+ * er præcis det, en manglende DNS-post giver browseren — fetch afviser, uden at der
+ * nogensinde kom en status. En 500 ville være serveren, der svarer, og det er den
+ * anden sag.
+ */
+test('et udfald siger ingen forbindelse frem for at bede om kodeordet', async ({ page }) => {
+ await page.route('**/api/v1/**', (route) => route.abort());
+
+ await page.goto('/');
+
+ // Prøvetiden skal kunne rumme hele forsøgsvinduet: forespørgselslaget prøver
+ // igen i femten sekunder, før det kalder det et udfald. Det er med vilje —
+ // et blink på nettet må ikke smide folk ud — så prøven må vente det ud.
+ await expect(page.getByText('Ingen forbindelse', { exact: true })).toBeVisible({
+ timeout: 25000
+ });
+
+ // Og ikke kodeordsfeltet. Det er hele pointen.
+ await expect(page.getByLabel('Adgangskode')).toHaveCount(0);
+});
+
/**
* Et indsat billede kan ses i fuld størrelse og kopieres videre.
*
diff --git a/web/src/lib/components/NoConnection.svelte b/web/src/lib/components/NoConnection.svelte
new file mode 100644
index 0000000..4d32576
--- /dev/null
+++ b/web/src/lib/components/NoConnection.svelte
@@ -0,0 +1,95 @@
+
+
+
+
+
+ ᚹ
+
verdande
+
+
+
{t('net.downTitle')}
+
{t('net.downBody')}
+
+
+
+
+
+
diff --git a/web/src/lib/locales/da.js b/web/src/lib/locales/da.js
index a04d027..e03a5fd 100644
--- a/web/src/lib/locales/da.js
+++ b/web/src/lib/locales/da.js
@@ -237,6 +237,9 @@ export const da = {
'nav.offlineHint': 'Ændringer fra andre vises ikke lige nu',
'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.downRetry': 'Prøv igen',
'nav.doItToday': 'Gør det i dag',
// --- the sidebar's own sections ------------------------------------------------
diff --git a/web/src/lib/locales/en.js b/web/src/lib/locales/en.js
index 6f6e55b..37b9422 100644
--- a/web/src/lib/locales/en.js
+++ b/web/src/lib/locales/en.js
@@ -234,6 +234,9 @@ export const en = {
'nav.offlineHint': 'Changes from other people are not showing right now',
'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.downRetry': 'Try again',
'nav.doItToday': 'Do it today',
// --- the sidebar's own sections ------------------------------------------------
diff --git a/web/src/lib/stores.svelte.js b/web/src/lib/stores.svelte.js
index 4c1712b..a5f7430 100644
--- a/web/src/lib/stores.svelte.js
+++ b/web/src/lib/stores.svelte.js
@@ -29,6 +29,20 @@ class AppState {
people = $state([]);
tasks = $state([]);
loading = $state(true);
+
+ /**
+ * True when the boot never reached the server — as opposed to reaching it and
+ * being told the session is gone.
+ *
+ * The two are indistinguishable from `user === null`, and they are opposite
+ * things to the person looking at the screen: one means "log ind", the other
+ * means "der er intet at logge ind på herfra, og et kodeord hjælper ikke". A
+ * total outage used to show the login screen, so the app blamed the user's
+ * session for the network being down — and a login page that cannot possibly
+ * work is worse than an error, because it looks like it might.
+ */
+ unreachable = $state(false);
+
/** Transient messages: a failed save, a rolled-back change. */
toasts = $state([]);
connected = $state(false);
@@ -96,6 +110,7 @@ class AppState {
this.loading = true;
try {
this.user = await api.me();
+ this.unreachable = false;
// The interface's language follows the account, so it is set the moment
// the session is known — before anything has drawn a string.
i18n.set(this.user.locale);
@@ -111,6 +126,10 @@ class AppState {
this.loadNotifications();
} catch (e) {
this.user = null;
+ // `offline` is the one code the request layer makes up itself: it is what
+ // is thrown when the retry window ran out without any status ever
+ // arriving. A 401 is the server answering, and that is a real sign-out.
+ this.unreachable = e?.code === 'offline';
} finally {
this.loading = false;
}
diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte
index c8070b2..17f67df 100644
--- a/web/src/routes/+layout.svelte
+++ b/web/src/routes/+layout.svelte
@@ -8,6 +8,7 @@
import Sidebar from '$lib/components/Sidebar.svelte';
import CommandPalette from '$lib/components/CommandPalette.svelte';
import SignIn from '$lib/components/SignIn.svelte';
+ import NoConnection from '$lib/components/NoConnection.svelte';
import TaskDetail from '$lib/components/TaskDetail.svelte';
import { t } from '$lib/i18n.svelte.js';
import { setBadge } from '$lib/badge.js';
@@ -166,6 +167,10 @@
{:else if standalone}
{@render children()}
+{:else if app.unreachable}
+
+
{:else if !app.user}
{:else}