Compare commits

...
Author SHA1 Message Date
kristianwindandClaude Opus 5 f34690f41a fix(noter): niveauer i lister, flere linjer i en blok, og en søgning man kan vælge i
Tre ting meldt ind sammen. De to første manglede; den tredje var i stykker.

--- man kunne søge og ikke vælge ---

Regression fra #84. Siden holdt op med at SKRIVE adressen, men blev ved med
at læse den ved hver ændring:

    let asked = $derived($page.url.searchParams.get('note'));
    $effect(() => { if (!asked || selectedId === asked) return; … });

Kom man fra ⌘K med `?note=<id1>`, stod `asked` fast på id1 for altid.
Hvert klik på en anden række satte `selectedId`, effekten så uenigheden og
åbnede id1 igen. Man blev trukket tilbage til den første træffer.

En adresse, der ikke følger med, er ikke en tilstand at holde sig enig med.
Den læses nu ved ankomsten — og der er TO ankomster, hvilket den første
udgave af rettelsen ikke tog højde for: layoutet tegner ingenting, mens
kontoen hentes, så siden monteres efter den første navigation er forbi, og
en lytter registreret der hører den aldrig. `/noter?note=<id>` tastet eller
fulgt udefra åbnede så ingenting. `onMount` og `afterNavigate`, samme
funktion, med `id === selectedId` som værn mod at begge fyrer.

--- en blok kunne ikke gemme flere linjer ---

Shift+retur bliver i blokken, men det browseren lægger ind er et <br>, og
`textContent` på et <br> er den tomme streng. Blokken blev skrevet til
Markdown som én linje: "first linesecond line". Usynligt indtil en
genindlæsning, for på skærmen står linjerne rigtigt hele tiden.

`codeText()` læser blokken rekursivt og gør <br> til et linjeskift.
Rekursivt, fordi indholdet ligger i et <code> inde i <pre>: en filtrering
over <pre>'ens egne børn spørger det ene element om en textContent, der
allerede har tabt linjeskiftet.

--- lister havde ingen vej til niveau to ---

Tab var ikke bundet til noget uden for forslagslisten, så den gjorde det,
Tab gør: flyttede fokus ud af noten. Den rykker nu ind og shift+Tab ud, og
kun inde i en liste — ellers er noten en boks, man ikke kan tabbe ud af.

Og skriveren ville have tabt resultatet: `list()` læste kun en underliste,
der ligger INDE i sit punkt. Lægger browseren den som søskende, og de gør
det uens, blev grenen sprunget over uden lyd. At binde Tab uden den her
halvdel ville have lavet en fejl, hvor indrykkede punkter forsvandt ved
gemningen.

Begge nye prøver måler EFTER en genindlæsning. Før den ser begge dele
rigtige ud på skærmen; det er turen gennem Markdown, der afgør, om det
blev skrevet ned.

Fuld chromium-suite kørt to gange: 92 bestået, 0 fejlet, begge gange.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-22 14:01:15 +02:00
Kristian Wind 1bb2c9cda6 Merge pull request #90 from kristianwind/klokken-paa-telefon
fix(beskeder): klokkens panel bliver inden for skærmen på en telefon
2026-09-17 19:06:03 +02:00
kristianwindandClaude Opus 5 cac6c14a23 fix(beskeder): klokkens panel bliver inden for skærmen på en telefon
Panelet hang under klokken med `right: 0` og en fast bredde på 340 px.
Klokken sidder ikke yderst — der er en temaknap, et mellemrum og en
kantafstand til højre for den — så der er kun omkring 330 px tilbage, og
resten blev skubbet ud over venstre kant og klippet af. Målt til -10 px ved
en skærmbredde på 390; på en smallere telefon mere. Overskriften "Beskeder"
klæbede til kanten uden luft, hvilket er det synlige tegn.

På en telefon hører panelet nu til skærmen i stedet for til klokken: det
spænder fra kant til kant med en margen i hver side, under toplinjen. Samme
knæk ved 820 px som toplinjens egen menuknap, så de to skifter form
samtidig. Det får også `overscroll-behavior: contain`, så det at rulle i
listen ikke trækker siden bagved med.

Toplinjens underkant måles frem for at blive regnet ud. Bredden KUNNE
trækkes fra i CSS, men så ville den her fil kende sine naboers størrelse, og
den næste knap i toplinjen ville lave fejlen om igen uden at sige noget.

--- og fire ustabile prøver mere ---

Fladen tegner hver ændring med det samme og sender kaldet bagefter. Alt, man
kan se, er altså sandt, før serveren har hørt om det, og en prøve, der
genindlæser eller klikker videre i det hul, måler den gamle tilstand.

`en monospace-linje kan forlades` ventede 1200 ms før en genindlæsning.
Datoprøven ventede 400 ms. Et gæt om, hvor hurtig maskinen er, holder indtil
CI er travl — begge fejlede i en fuld kørsel her.

`en gruppe er en side med sine projekter` er den mest lærerige: trækket
virkede, og `toPass` blev grøn på den lokale tilstand, hvorefter klikket
videre hentede en gruppe uden projekter i. Prøvens egen kommentar gætter på
et upålideligt HTML5-træk; det var gemningen, der ikke var nået frem.

Monospace-prøven havde OVENI en anden fejl, som først viste sig, da den
første var lukket: den klikkede "Ny note" og skrev med det samme. `create()`
venter på serverens svar, og imens står den forrige notes editor stadig på
skærmen, så teksten gik ind i den forrige note og den nye blev tom. Ny
hjælper `nyNote()` venter på et tomt felt. Omkring 14 andre steder i filen
har samme mønster; de er ikke skrevet om, fordi de består, og en samlet
omskrivning ville bytte kendte prøver for ukendte.

Det hul findes også for et menneske: klikker man Ny note og skriver straks,
lander de første anslag i den forrige note. Det er millisekunder lokalt og
sekunder på en dårlig forbindelse. Samme familie som #86, men tiden FØR
svaret frem for efter. Noteret som et selvstændigt punkt, ikke rettet her.

`noteGemt()` fra #88 er nu `gemt(side, sti)` med noteGemt, opgaveGemt og
projektGemt ovenpå. Der står 23 faste ventetider tilbage, men ingen af dem
lige før en genindlæsning eller en navigation.

Fuld chromium-suite kørt to gange: 90 bestået, 0 fejlet, begge gange.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-17 18:55:45 +02:00
Kristian Wind f632fb8643 Merge pull request #89 from kristianwind/to-vaern-mere
test(e2e): to genindlæsninger mere venter på gemningen
2026-09-17 13:12:07 +02:00
kristianwindandClaude Opus 5 eabb00ecc2 test(e2e): to genindlæsninger mere venter på gemningen
Samme form som de fire "Gemt"-ventetider, to andre steder — begge lige før
en `page.reload()`, hvilket er der, hvor det koster.

`en note kan skrives fed, og den kommer hel tilbage` havde `blur()` direkte
efterfulgt af en genindlæsning uden noget værn overhovedet. Nåede PATCH'en
ikke frem først, målte prøven en note, der aldrig blev skrevet — og den
prøve findes netop for at fange en omformer, der er uenig med sig selv.

`[[henvisning]]`-prøven havde `waitForTimeout(900)`. Tallet var gættet til
at overleve tastepausen på 700 ms; et gæt om tid måler maskinen frem for
programmet. Det er den konstruktion, den her fil allerede advarer imod.

Begge venter nu på PATCH-svaret gennem noteGemt().

Der står 24 faste ventetider tilbage i filen. De er ikke rørt: flere af dem
venter med vilje (at skrive i et menneskeligt tempo er en rigtig grund), og
at skrive dem om samlet ville bytte kendte prøver for ukendte.

Fuld chromium-suite: 89 bestået, 0 fejlet.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-17 13:02:31 +02:00
Kristian Wind d2b5821e57 Merge pull request #88 from kristianwind/vaern-der-venter-paa-noget
test(e2e): værn, der kan vente på noget
2026-09-17 12:59:26 +02:00
kristianwindandClaude Opus 5 4874f7bbd8 test(e2e): værn, der kan vente på noget
Fire steder ventede prøverne på teksten "Gemt" i notefoden. Den er
hvilestanden: feltet siger "Gemmer" mens en gemning kører og "Gemt"
ellers — altså også før den første gemning overhovedet er begyndt. Alle
fire ventetider var grønne med det samme og ventede på ingenting.

To af dem dækkede over et rigtigt kapløb:

- `en delt note tager de noter med, den peger på` skriver i en note og
  navigerer væk umiddelbart efter. Nåede PATCH'en ikke frem først, var
  teksten væk, da siden kom igen.

- `en gruppe er en side med sine projekter` har samme form et andet sted:
  beskrivelsen tegnes optimistisk — siden sætter teksten på gruppen straks
  og sender PATCH'en bagefter — så kontrollen var grøn, før serveren havde
  hørt om den, og `page.reload()` lige efter hentede den gamle gruppe
  tilbage. Prøven fejlede altså på noget, der ikke var i stykker.

De to sidste ventede på ingenting uden at skade noget, men var stadig
vildledende at læse.

Der er nu én hjælper, `noteGemt(side)`, der venter på selve PATCH-svaret,
med forklaringen på hvorfor "Gemt" ikke duer skrevet ét sted.
Gruppeprøven venter tilsvarende på sin egen PATCH. Hvor rækken i listen
allerede var det rigtige værn, er den overflødige linje væk.

Samme form som reglen i CLAUDE.md om at læse, hvad en kontrol skrev, da
den BESTOD: "venter på gemning" og "matchede den tekst, der altid står
der", er samme grønne.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-17 12:49:15 +02:00
Kristian Wind 8bf4f8fbe4 Merge pull request #87 from kristianwind/et-udfald-siger-det
feat(net): et udfald siger "ingen forbindelse" frem for at bede om kodeordet
2026-09-17 12:38:19 +02:00
kristianwindandClaude Opus 5 84af63316d feat(net): et udfald siger "ingen forbindelse" frem for at bede om kodeordet
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 totalt udfald blev vist som login-skærmen: appen gav
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: det, appen viste, var en langsom login-side.

Forskellen var der allerede i forespørgselslaget. Et netværkssvigt, der
har brugt sit forsøgsvindue op, kastes som `ApiError(0, 'offline')` —
koden laves af klienten selv, netop fordi der aldrig kom en status. En 401
er serveren, der svarer, og det er en rigtig udlogning. Butikken skelner nu
på den kode, og fladen viser NoConnection i stedet for SignIn.

Teksten siger, at data er i behold, for det er det spørgsmål, man faktisk
har, når ens noter forsvinder bag en login-boks.

Prøven afbryder API-kaldene frem for at stoppe serveren: en afbrudt
forespørgsel er præcis det, en manglende DNS-post giver browseren.
Efterprøvet mod den gamle kode — den fejler på "Ingen forbindelse".

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-17 12:29:57 +02:00
Kristian Wind 930522ecf6 Merge pull request #86 from kristianwind/ny-note-taber-ikke-tekst
fix(noter): en ny note taber ikke det, man skriver i den
2026-09-17 12:29:49 +02:00
kristianwindandClaude Opus 5 9ed6f49589 fix(noter): en ny note taber ikke det, man skriver i den
Serveren tømmer `body` på listerækker med vilje, så en tom krop var sidens
tegn på "det her er en række, hent noten hel". En nyoprettet note er også
tom — og hel — så den blev sendt samme vej: editoren blev pillet af
skærmen, noten hentet igen, og editoren sat op på ny.

I det hul er der ingen valgt note, og save() går tavst tilbage uden en.
Skrev man der, forsvandt teksten sporløst: ingen fejl, ingen toast, bare en
tom note. Svaret på oprettelsen var hele noten hele tiden, så opslaget var
også overflødigt.

Det er samtidig grunden til, at søgeprøven var ustabil. Dens værn var en
ventetid på "Gemt", og den tekst er hvilestanden — feltet siger "Gemmer"
mens en gemning kører og "Gemt" ellers, altså også før den første gemning
er begyndt. Prøven fik grønt med det samme og målte ingenting; i CI var
maskinen langsom nok til at ramme hullet bagefter. Den venter nu på et tomt
felt før den skriver, og på rækken i listen bagefter.

Den nye prøve tæller opslag frem for at måle et blink: et blink ville kræve
en prøve, der er hurtigere end programmet, hvilket er præcis den fejl den
lukker. Efterprøvet mod den gamle kode — den fejler på
`expect(fetched).toEqual([])`.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-17 12:20:02 +02:00
kristianwindandClaude Opus 5 de673260a3 docs(site): besøgstælling på verdandetodo.com
Plausible på vores egen maskine. Ingen cookie, ingen gemt adresse, ingen
tredjepart — og derfor ingen samtykkebjælke på en side, der skal sælge noget.

`defer`, så tælleren aldrig er det, der holder siden tilbage.

Det gælder siden, ikke programmet. En installation melder det, der står under
"It counts itself, out loud", og ikke mere; de to ting deler ikke noget.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-16 12:31:00 +02:00
Kristian Wind 804c81f74e Merge pull request #84 from kristianwind/soegefeltet-taber-ikke-bogstaver
CI / OpenAPI is valid (push) Successful in 9s
CI / End-to-end (push) Successful in 9m21s
CI / Docker image builds (push) Successful in 1m47s
Release / Build and publish image (push) Failing after 4s
CI / Go (push) Failing after 14m17s
fix(noter): søgefeltet taber ikke bogstaver, og det nyeste svar vinder
2026-09-16 08:34:59 +02:00
kristianwindandClaude Opus 5 87054ac8ca fix(noter): søgefeltet taber ikke bogstaver, og det nyeste svar vinder
Fra en skærmoptagelse, to billeder i sekundet:

    2,5 s  feltet "verd"      listen ufiltreret
    3,0 s  feltet "nde"       listen filtreret
    6,0 s  feltet "verdande"  listen ufiltreret
    6,5 s  feltet tomt        listen filtreret

To fejl oven i hinanden, og den ene er min fra #79.

Bogstaverne forsvandt, fordi adressen blev skrevet ved hvert tastetryk, så en
søgning kunne gemmes og deles. `replaceState` opdaterer `$page.url` et øjeblik
efter, den kaldes, så to hurtige tastetryk gav en effekt, der læste den *forrige*
adresse, fandt den uenig med det skrevne, og skrev den gamle værdi tilbage i
feltet. `untrack` fjernede sløjfen mellem de to effekter; den fjernede ikke
kapløbet med browserens egen forsinkelse.

En markør, der skal holde to retninger enige, taber kapløbet et sted. Adressen
skrives derfor ikke længere af siden — kun af navigation — og der er én effekt
tilbage, som læser den. Prisen er, at et ryddet søgefelt ikke rydder `q` af
adressen. Det er en mindre løgn end at æde bogstaver.

Den anden fejl har ligget der hele tiden: `load()` havde ingen vagt mod forældede
svar. To hentninger kunne være undervejs på én gang, og den langsomste vandt — så
listen viste svaret på et spørgsmål, der ikke længere blev stillet. Pausen på
250 ms gjorde det sjældnere og kunne ikke gøre det umuligt; det stod endda i
kommentaren ved siden af. Hver hentning har nu et nummer, og kun det nyeste
skrives i listen.

Prøven skriver i et menneskeligt tempo, for det var hastigheden, der afslørede
det, og holder den ene af to hentninger tilbage for at tvinge rækkefølgen om.
Kørt mod den gamle kode fejler den med `Received: ""` — feltet var tømt helt.

Bemærket: "en gruppe er en side med sine projekter" fejlede to fulde kørsler i
træk og bestod på den tredje, og alene. Den er ikke rørt her. Sammen med "en delt
note tager de noter med" i går er der nu to prøver, der flakker.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-16 08:25:10 +02:00
Kristian Wind c62dcc3d7c Merge pull request #83 from kristianwind/opgavens-tekst-kan-laeses
CI / OpenAPI is valid (push) Successful in 11s
CI / End-to-end (push) Successful in 10m47s
CI / Docker image builds (push) Successful in 2m1s
Release / Build and publish image (push) Failing after 6s
CI / Go (push) Failing after 14m57s
fix(opgave): teksten kan læses færdig, også på en telefon
2026-09-15 20:22:38 +02:00
kristianwindandClaude Opus 5 0110a86858 fix(opgave): teksten kan læses færdig, også på en telefon
Rapporteret med et billede af en beskrivelse, der manglede sit første bogstav på
hver linje: "hermostat RU0910301440" og "atteries". Det er ikke manglende tegn —
det er et felt, der er rullet til højre. Og det ligner ikke rulning; det ligner,
at bogstaverne ikke er der.

To ting på én skærm.

Titlen var et <input>, og et input kan ikke ombryde. "tado°: Your Smart
Thermostat needs new batteries" er en almindelig opgavetitel, og på 390 px var
feltet 490 px bredt i en kasse på 356 — halvdelen af overskriften stod uden for
kanten. Den er nu et <textarea>, der vokser med sin tekst. Retur gemmer frem for
at lave en ny linje: en titel er én linje, uanset hvor mange den fylder på
skærmen. Trækkehåndtaget er væk, for højden sættes af teksten, og et håndtag
ville love noget, det næste bogstav retter tilbage.

Beskrivelsen er tit et klippet stykke mail med en URL i. Et <textarea> ombryder
ved mellemrum, og en URL har ingen.

`break-word` og ikke `anywhere`: den første bryder kun et ord, der ikke kan stå på
en linje alene, mens den anden bryder ved først givne lejlighed og delte
"GarageRisteriet" midt i en stavelse. Den lektion står allerede ved h1 på
projektsiden og er betalt én gang.

Prøven måler frem for at beskrive: et felt, der ruller vandret, har en
scrollWidth større end sin clientWidth. Kørt mod den gamle komponent fejler den
med "Opgavens tekst: 490 > 356".

Men kun den ene halvdel. Beskrivelsen løber ikke over i Chromium, heller ikke
uden reglen — det var Safari, den blev set i. Prøven måler derfor den regel frem
for dens virkning, og det står skrevet i prøven, så den næste ved, hvad den
vogter og hvad den ikke gør.

Bemærket undervejs: "en delt note tager de noter med, den peger på" fejlede én
gang ud af to fulde kørsler med "Delt med Mikkel." uden antallet. Den består
alene og i næste fulde kørsel. Den er ikke rørt her, men den flakker.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-15 20:12:28 +02:00
Kristian Wind c868be8f89 Merge pull request #82 from kristianwind/hjemmesiden-er-med
CI / OpenAPI is valid (push) Successful in 9s
CI / End-to-end (push) Successful in 10m19s
CI / Docker image builds (push) Successful in 2m3s
CI / Go (push) Failing after 14m45s
docs(site): landingssiden når op til v0.47 (+ en flakkende prøve)
2026-09-15 14:21:35 +02:00
kristianwindandClaude Opus 5 145fefea02 docs(site): landingssiden når op til v0.47
Den udrullede side på verdandetodo.com er fra 20.-22. august — den matcher en
commit fra før v0.42.15. Repoets egen kopi er fra 4. september. Begge stopper,
hvor "Lately" stopper, og den sagde 0.42.

Ni nye poster: billederne i noter, søgningen der bliver stående, deling af et
projekt ved navn, besked og udgang, søgning i indstillinger, dagens plan som
kort, browservalget, hele AI-laget, versionsbeskeden til administratorer, og
krogen der lader hvad som helst lægge en opgave i indbakken.

De to ældste ryger. Med ni nyere poster er 0.34 og 0.33 ikke "for nylig" længere,
og hele historien står på udgivelsessiden, som afsnittet selv henviser til.

Siden udrulles i hånden — der er ingen arbejdsgang, der gør det. Det er derfor,
den er tre en halv uge bagud, og det er værd at tage stilling til separat.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-15 14:11:26 +02:00
kristianwindandClaude Opus 5 5d911d40fe test(noter): vent på at noten er gemt, før den næste laves
Prøven fra i går fejlede i CI på en PR, der kun rørte dokumentation — altså
flakkede den, og det er min.

Løkken laver tre noter og klikkede "Ny note" igen, uden at vente på at den
forrige gemning var kvitteret. Her på maskinen nåede den altid med; på en
belastet runner gjorde den ikke, og listen manglede noten. Fem sekunders
tålmodighed hjalp ikke, for det var ikke ventetid, der manglede — det var et
signal.

Der ventes nu på "Gemt". Kørt tre gange i træk grønt.

En prøve, der er hurtigere end programmet, måler den maskine, den kører på.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-15 14:11:26 +02:00
kristianwindandClaude Opus 5 9788bbaaf8 docs: siden bliver udgivet, og den siger det, der er bygget
Hjemmesiden har vist en Verdande fra før v0.43. /links/, /beacon/ og
/notifications/ svarede alle 404, måneder efter de blev skrevet.

Årsagen: docs-jobbet byggede på hvert push og udgav kun på en manuel udløsning,
med en note om at det ville køre, "once Pages is enabled". Pages BLEV slået til
— og betingelsen blev aldrig rettet. Siden er dermed bygget fyrre gange og
udgivet nul, uden at noget fejlede, for at bygge var præcis det, jobbet blev
bedt om.

Lektien er ikke den linje. Den er, at "udgiv senere" har brug for noget, der
fejler, når senere kommer. En kontakt, der er slukket og tavs, ligner en kontakt,
der er tændt.

Udgives nu fra main og ved håndsoprækning, aldrig fra en pull request — en fork
kunne ellers udgive hen over siden ved at åbne en.

Dokumentationen manglede ⌘K-ændringen fra i går: at vælge en note fra paletten nu
efterlader de andre træffere i listen ved siden af den. Den står i "Finding",
hvor de to søgefelter i forvejen er beskrevet hver for sig uden at sige, at de nu
er enige.

README'en manglede tolv ting, der er bygget siden tabellen sidst blev rørt — og
forseglingen af noter, som kun stod i docs. At en kopi af databasefilen ikke er
en kopi af noterne, er en overskrift for et selvhostet program, ikke en detalje
på side tre.

GitHub-repoet havde ingen hjemmeside og ingen emner, og beskrivelsen nævnte
hverken noter, kalender eller AI. Alle tre er sat.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-15 14:00:22 +02:00
Kristian Wind f68d6a8c15 Merge pull request #80 from kristianwind/billeder-kan-ses-og-kopieres
CI / OpenAPI is valid (push) Successful in 11s
CI / End-to-end (push) Successful in 12m56s
CI / Docker image builds (push) Successful in 2m29s
CI / Go (push) Failing after 15m40s
Release / Build and publish image (push) Failing after 7s
feat(noter): et indsat billede kan ses helt og kopieres videre
2026-09-14 22:04:38 +02:00
15 changed files with 991 additions and 96 deletions
+18 -10
View File
@@ -1,13 +1,21 @@
name: Documentation
# The docs are built on every push so a broken build is caught immediately, but
# only published once GitHub Pages has been enabled for the repository. Until the
# owner decides to publish, this job builds and stops.
# Built on every push, and published from main.
#
# And on pull requests, where the build is the only check there is: `mkdocs build
# --strict` fails on a link to a page that does not exist, and building only after
# the merge means finding that out on `main`. Nothing is published from a pull
# request — the `deploy` job below runs on a manual dispatch and nothing else.
# The publishing used to wait for a manual dispatch, with a note saying it would
# run "once Pages is enabled". Pages was enabled — and the condition was never
# changed, so the site was built forty times and published none of them. What it
# showed was a Verdande from before v0.43: /links/, /beacon/ and /notifications/
# all answered 404, months after they were written. Nothing failed, because
# building *is* what the job was asked to do.
#
# The lesson is not about this line. It is that "publish later" needs something
# that fails when later arrives — a switch that is off and silent looks exactly
# like a switch that is on.
#
# Pull requests still build and never publish: `mkdocs build --strict` fails on a
# link to a page that does not exist, and building only after the merge means
# finding that out on `main`.
on:
push:
branches: [main]
@@ -54,9 +62,9 @@ jobs:
deploy:
needs: build
runs-on: ubuntu-latest
# Only runs once Pages is enabled. Until then the build above still guards
# against a broken docs tree.
if: github.event_name == 'workflow_dispatch'
# From main, and by hand. Never from a pull request — a fork could otherwise
# publish over the site by opening one.
if: github.event_name != 'pull_request'
environment:
name: github-pages
url: ${{ steps.deploy.outputs.page_url }}
+17 -1
View File
@@ -20,7 +20,8 @@ Which is what a to-do list is.
All six phases are built. See [What works today](#what-works-today) for what is
tested and what is not.
Documentation: [`docs/`](docs/), built with Material for MkDocs.
Documentation: **<https://kristianwind.github.io/verdande/>** — the source is in
[`docs/`](docs/), built with Material for MkDocs and published from `main`.
## Why this exists
@@ -43,6 +44,10 @@ and an MCP endpoint so Claude can work your task list directly.
the text, which shares it with her and tells her. `#project` and
`[[another note]]` are links, and a task shows the notes that mention it. A zip of Markdown goes in — Obsidian, Bear, or Apple Notes through
the exporter in [`tools/`](tools/) — and comes back out the same shape.
- **Notes are sealed at rest.** Title and body are encrypted in the database under
a key in a file beside it — the title too, because a title beside a sealed body
leaves the beginning of every note in the open. A copy of the file is not a copy
of the notes.
- **Drag where it means something.** Reorder a list or a board, file a project under
a group, drop a task on another project, or drop it on another day.
- **Sharing.** Owner, editor and viewer roles. There is no open registration:
@@ -128,6 +133,17 @@ Apple Reminders, forward mail into it, and add it to Claude as a connector.
| OpenAPI 3.1 spec, checked against the router | Done, tested |
| End-to-end smoke tests (Playwright) | Done |
| Documentation, landing page, licence | Done |
| Notes sealed at rest: title and body encrypted in the database | Done, tested |
| `@name` in a note shares it with that person, and tells them | Done, tested |
| Share a project with somebody who already has an account, by name | Done, tested |
| Invitations that have not been taken up stay visible, and can be withdrawn | Done, tested |
| Told when a project is shared with you — and able to leave it | Done, tested |
| Push from other programs: a webhook address per person | Done, tested |
| Today's plan as a card on **I dag**, not only as a notification | Done, tested |
| Search the settings, from the page or from ⌘K | Done, tested |
| Choose which browser a link opens in | Done, tested |
| Images in notes: open full size, copy to the clipboard | Done, tested |
| Install count: two values, once a day, one click to stop | Done, tested |
**Not finished:** Web Push, the AI providers and the Google Calendar API calls are
implemented against their specifications and have only ever been exercised there,
+8
View File
@@ -138,6 +138,14 @@ Search finds a word wherever it is:
body. A word in the title is what the note is about; the same word once in a long
body is a mention.
Two places to search from, and they now agree. The field above the list filters it
in place. **⌘K** searches everything at once — tasks, projects, notes and the
settings — and picking a note from it opens that note *with the rest of the matches
still in the list beside it*. It used to drop you on the ordinary list, so the
other results existed only in the popup you had just closed. The address carries
the search, which also means a search worth keeping can be bookmarked or sent to
somebody.
## Putting notes away
Two different things, and the difference matters:
+67 -11
View File
@@ -819,6 +819,15 @@
color: var(--accent);
}
</style>
<!-- Besøgstælling for denne side — ikke for programmet, som melder det, der
står under "It counts itself". Plausible på vores egen maskine: ingen
cookie, ingen gemt adresse, ingen tredjepart, og derfor ingen bjælke at
møde folk med. `defer`, så tælleren aldrig holder siden tilbage. -->
<script
defer
data-domain="verdandetodo.com"
src="https://plausible.yggdrasilpanel.com/js/script.outbound-links.js"
></script>
</head>
<body>
@@ -1326,6 +1335,64 @@
<a href="https://github.com/kristianwind/verdande/releases">the releases page</a>.
</p>
<ul class="lately">
<li>
<span class="ver">0.47</span>
Click a picture in a note and it opens at full size, with a button that
copies it to the clipboard. In the note it is scaled to the width of the
column — a photo off a phone is four thousand pixels across — so what you
were looking at was a thumbnail that did not look like one.
</li>
<li>
<span class="ver">0.47</span>
Search in ⌘K, pick a note, and the rest of the matches stay in the list
beside it. They used to exist only in the popup you had just closed.
</li>
<li>
<span class="ver">0.46</span>
Share a project with somebody who already has an account by picking their
name, rather than by spelling an email address you would have to know.
Invitations that have not been taken up stay visible so they do not get
sent twice, and can be withdrawn.
</li>
<li>
<span class="ver">0.46</span>
You are told when a project is shared with you, and there is a
<strong>Leave the project</strong> button. Being let in without being
asked, without being told, and with no way out was three things at once.
</li>
<li>
<span class="ver">0.45</span>
Search for a setting instead of remembering which tab it was under — in
the settings themselves and in ⌘K. It knows them by what they do, so
<em>beacon</em> finds the install count even though that is not its name.
</li>
<li>
<span class="ver">0.45</span>
Today's plan is a card on <strong>I dag</strong> rather than only a
message in the morning that was gone if you did not read it at once. And
a link can be told which browser to open in.
</li>
<li>
<span class="ver">0.44</span>
An optional AI layer, on your own key: it tidies the inbox, pulls the
tasks out of a meeting note, writes a morning plan, turns
<em>"SV: SV: Vedr. levering uge 12"</em> into what the mail actually asks
for, and answers questions about your own notes with links to what the
answer rests on. Every one of them is a line you accept, edit or refuse —
it suggests, it never writes.
</li>
<li>
<span class="ver">0.44</span>
Administrators are told in the bell when a new version is out, once per
version, and the message leads to the button that restarts.
</li>
<li>
<span class="ver">0.43</span>
Anything that can call a URL can put a task in your inbox — a shortcut on
your phone, a script, a service. Notes can be handed to somebody who has
no account yet, and writing <code>@Sofie</code> in a note shares it with
her and tells her. Unread counts show on the app's icon in the Dock.
</li>
<li>
<span class="ver">0.42</span>
A sixth theme, <strong>Charcoal</strong> — a warm near-black after Apple
@@ -1362,17 +1429,6 @@
body to move it. A single note can be shared with one person, and a
<code>[[note]]</code> is a link you follow inline.
</li>
<li>
<span class="ver">0.34</span>
Sections can be dragged into the order the work actually happens in. The
sidebar keeps its head still while the projects scroll.
</li>
<li>
<span class="ver">0.33</span>
Notes grew an archive, selection of many at once, pictures you can paste
or drag in, a source view, and a copy button on code blocks. Search
stopped answering by date and started answering by relevance.
</li>
</ul>
</section>
+507 -16
View File
@@ -38,6 +38,61 @@ async function projectAction(page, name) {
await item.click();
}
/**
* Venter på, at en gemning faktisk er nået frem til serveren.
*
* Hele fladen tegner en ændring med det samme og sender kaldet bagefter — en note
* står i listen, et projekt hopper ind under sin gruppe, en beskrivelse står på
* siden — så alt, hvad man kan se, er sandt, før serveren har hørt om det. En
* prøve, der genindlæser eller klikker videre i det hul, måler den gamle tilstand
* og fejler på noget, der ikke er i stykker.
*
* Og teksten "Gemt" i notefoden duer ikke som værn: den er hvilestanden. Feltet
* siger "Gemmer" mens en gemning kører og "Gemt" ellers — altså også før den
* første overhovedet er begyndt. En ventetid på den er grøn med det samme og
* venter på ingenting. Det gjorde fem prøver i den her fil ustabile.
*
* En fast ventetid er den samme fejl med et tal foran: `waitForTimeout(1200)` er
* et gæt om, hvor hurtig maskinen er, og det gæt holder indtil CI er travl.
*
* Svaret på PATCH'en er det eneste, der beviser, at serveren har hørt om det.
* Skal kaldes FØR den handling, der udløser gemningen — ellers er svaret måske
* allerede kommet og gået.
*/
function gemt(side, sti) {
return side.waitForResponse(
(r) => r.request().method() === 'PATCH' && sti.test(new URL(r.url()).pathname) && r.ok()
);
}
const noteGemt = (side) => gemt(side, /\/notes\/[^/]+$/);
const opgaveGemt = (side) => gemt(side, /\/tasks\/[^/]+$/);
const projektGemt = (side) => gemt(side, /\/projects\/[^/]+$/);
/**
* Laver en ny note og giver feltet tilbage, når det er DEN nye, der skrives i.
*
* `create()` venter på serverens svar, før noten er oprettet, og imens står den
* forrige notes editor stadig på skærmen. Et klik på "Ny note" efterfulgt af
* tastetryk med det samme skriver derfor i den forrige note — teksten havner det
* forkerte sted, og den nye note bliver liggende tom. Efter en genindlæsning
* findes den titel, prøven leder efter, slet ikke.
*
* Det tomme felt er værnet. Det er samme form som resten af filen: alt kan ses,
* før serveren har hørt om det, så det, der kan ventes på, er tilstanden bagefter.
*
* Bemærk at hullet også findes for et menneske: klikker man Ny note og skriver
* straks, lander de første anslag i den forrige note. Det er hurtigt på en lokal
* maskine og sekunder på en dårlig forbindelse. Noteret som et selvstændigt punkt.
*/
async function nyNote(side) {
await side.getByRole('button', { name: 'Ny note' }).click();
const felt = side.getByRole('textbox', { name: 'Notens tekst' });
await expect(felt).toHaveText('');
await felt.click();
return felt;
}
function watchForTrouble(page) {
const trouble = [];
page.on('console', (message) => {
@@ -1683,6 +1738,11 @@ test('en gruppe er en side med sine projekter, en beskrivelse og filer', async (
// HTML5 drag occasionally does not register the first time under CI load, and a
// single dragTo then leaves the group empty and the page assertion failing for a
// reason that has nothing to do with what the test is about.
// Sidebjælken viser flytningen med det samme og sender PATCH'en bagefter, så
// `nested` er sand, før serveren har hørt om den. Klikker prøven videre i det
// hul, henter gruppesiden en gruppe uden projekter i — og fejler på noget, der
// ikke er i stykker. Det er samme form som beskrivelsen længere nede.
const flyttet = projektGemt(page);
const nested = sidebar.locator('.folder > a').filter({ hasText: 'Drejebænk' });
await expect(async () => {
await sidebar
@@ -1690,6 +1750,7 @@ test('en gruppe er en side med sine projekter, en beskrivelse og filer', async (
.dragTo(sidebar.getByRole('link', { name: 'Værksted' }));
await expect(nested).toHaveCount(1, { timeout: 1000 });
}).toPass({ timeout: 10000 });
await flyttet;
await sidebar.getByRole('link', { name: 'Værksted' }).click();
await expect(page.getByRole('heading', { name: 'Værksted', level: 1 })).toBeVisible();
@@ -1703,8 +1764,21 @@ test('en gruppe er en side med sine projekter, en beskrivelse og filer', async (
const about = page.getByLabel('Om gruppen');
await expect(about).toBeVisible();
await about.fill('Alt der larmer og støver.');
// Gemningen skal være nået frem, FØR der genindlæses. Beskrivelsen tegnes
// optimistisk — siden sætter teksten på gruppen med det samme og sender PATCH'en
// bagefter — så en kontrol på teksten er grøn, før serveren har hørt om den. En
// reload i det hul henter den gamle gruppe tilbage, og prøven fejler på noget,
// der ikke er i stykker. Svaret er det, der kan ventes på.
const gemt = page.waitForResponse(
(r) =>
r.request().method() === 'PATCH' &&
new URL(r.url()).pathname.includes('/project-groups/') &&
r.ok()
);
await about.blur();
await expect(page.getByText('Alt der larmer og støver.')).toBeVisible();
await gemt;
// And it survives a reload, which is the difference between the page keeping up
// and the server taking it.
@@ -2724,7 +2798,9 @@ test('noteeditoren er rich text, og teksten overlever turen til Markdown og tilb
// And the round trip, which is the thing that can quietly ruin a note: what is
// stored is Markdown, and reopening it has to give back the same document. A
// converter that disagrees with itself reshapes a note a little on every save.
const rundtur = noteGemt(page);
await page_.blur();
await rundtur;
await page.reload();
await page.getByRole('button', { name: /Møde med Anders/ }).click();
@@ -2911,8 +2987,9 @@ test('note links i en delt note fører til den rigtige note, eller siger hvorfor
await body.click();
await gæst.keyboard.press('Control+End');
await gæst.keyboard.type('\n[[Linneas egne maal]]\n[[Noget der aldrig blev skrevet]]');
const gemt = noteGemt(gæst);
await body.blur();
await expect(gæst.locator('footer .hint')).toHaveText('Gemt');
await gemt;
await gæst.goto(noterURL);
await gæst.locator('.notes button.row').filter({ hasText: /^Faelles aftale/ }).click();
@@ -2975,10 +3052,33 @@ test('en delt note tager de noter med, den peger på', async ({ browser, page })
'Aftale om levering\nse [[Prisliste i kroner]]'
]) {
await page.getByRole('button', { name: 'Ny note' }).click();
await page.getByRole('textbox', { name: 'Notens tekst' }).click();
const felt = page.getByRole('textbox', { name: 'Notens tekst' });
// Tomt felt før der skrives, og rækken i listen bagefter. IKKE "Gemt" i
// foden: den tekst er hvilestanden — der står "Gemmer" mens en gemning
// kører og "Gemt" ellers, altså også før den første er begyndt. En ventetid
// på den er grøn med det samme og venter på ingenting.
await expect(felt).toHaveText('');
await felt.click();
await page.keyboard.type(text);
await page.getByRole('textbox', { name: 'Notens tekst' }).blur();
await expect(page.locator('footer .hint')).toHaveText('Gemt');
await felt.blur();
const titel = text.split('\n')[0];
// Rækkens egen titel, og kun den. To fælder ligger på vejen hertil, og jeg
// gik i begge:
//
// `filter({ hasText: 'Aftale om levering' })` med en STRENG matcher
// delstreng og uden hensyn til store og små bogstaver — den fandt også
// "Fælles aftale om leveringen" fra en tidligere prøve, for databasen deles
// af hele suiten.
//
// `.notes` alene rækker for langt: en `[[henvisning]]` i editoren er også
// tekst med den titel i, så et opslag på teksten fandt tre.
//
// `button.row strong` er rækkens titelelement, og en forankret regex er både
// eksakt og versalfølsom. Titlerne her er almindelige ord uden
// regex-tegn — står der en dag en parentes i en af dem, skal den undviges.
await expect(
page.locator('.notes button.row strong').filter({ hasText: new RegExp(`^${titel}$`) })
).toHaveCount(1);
}
await page.locator('.notes button.row').filter({ hasText: /^Aftale om levering/ }).click();
@@ -3064,8 +3164,9 @@ test('</> sætter markeringen som kode, og kilden har sin egen knap', async ({ p
// Og den anden knap gør stadig det, den altid har gjort: viser filen, med
// baktikkerne i, som er beviset på at koden nåede hele vejen ned.
const kildeGemt = noteGemt(page);
await body.blur();
await expect(page.locator('footer .hint')).toHaveText('Gemt');
await kildeGemt;
await page.getByRole('button', { name: /Vis kilden/ }).click();
await expect(page.locator('textarea.source')).toHaveValue(/`/);
@@ -3137,8 +3238,11 @@ test('[[ foreslår en note, og linket lander i noten', async ({ page }) => {
await expect(inline).toBeVisible();
// It survives the round-trip through Markdown and a reload.
// Gemningen, ikke en ventetid på 900 ms. Tallet var gættet til at overleve
// tastepausen på 700 ms, og et gæt om tid måler maskinen frem for programmet.
const linketGemt = noteGemt(page);
await ed.blur();
await page.waitForTimeout(900);
await linketGemt;
await page.reload();
await page.getByRole('button', { name: /Mine planer/ }).click();
await expect(ed.locator('a.notelink', { hasText: 'Rejseplan til Berlin' })).toBeVisible();
@@ -3675,9 +3779,7 @@ test('en monospace-linje kan forlades — på retur og gennem menuen', async ({
const trouble = watchForTrouble(page);
await page.goto('/noter');
await page.getByRole('button', { name: 'Ny note' }).click();
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
await ed.click();
const ed = await nyNote(page);
await page.keyboard.type('Titel');
await page.keyboard.press('Enter');
@@ -3713,8 +3815,7 @@ test('en monospace-linje kan forlades — på retur og gennem menuen', async ({
// Det overlever turen gennem Markdown. En ren note for sig, så prøven ikke
// afhænger af alt det ovenfor: kode, retur, brødtekst — gem, hent igen.
await page.getByRole('button', { name: 'Ny note' }).click();
await ed.click();
await nyNote(page);
await page.keyboard.type('Rundtur');
await page.keyboard.press('Enter');
await page.getByRole('button', { name: 'Formatér' }).click();
@@ -3722,8 +3823,9 @@ test('en monospace-linje kan forlades — på retur og gennem menuen', async ({
await page.keyboard.type('docker pull');
await page.keyboard.press('Enter');
await page.keyboard.type('og så videre i almindelig skrift');
const rundturGemt = noteGemt(page);
await ed.blur();
await page.waitForTimeout(1200);
await rundturGemt;
await page.reload();
await page.getByRole('button', { name: /Rundtur/ }).click();
await expect(ed.locator('pre')).toContainText('docker pull');
@@ -4025,9 +4127,10 @@ test('en opgave kan få et klokkeslæt, og beholder det når den flyttes', async
// Og datoen kan rettes bagefter uden at tage timen med sig.
const date = page.locator('#due');
const day = await date.inputValue();
const datoGemt = opgaveGemt(page);
await date.fill(day);
await date.blur();
await page.waitForTimeout(400);
await datoGemt;
await page.reload();
await page.getByText('ringe til tandlægen').click();
await expect(
@@ -4656,9 +4759,22 @@ test('en søgning i ⌘K efterlader de træffere, den fandt', async ({ page }) =
// Gemt ved at forlade feltet, som resten af fladen gør det — der er ingen
// Gem-knap at huske.
//
// Der ventes på et TOMT tekstfelt, før der skrives. Uden det kan udfyldningen
// ramme den forrige notes felt, mens den nye er undervejs, og så bliver teksten
// enten skrevet på den forkerte note eller tabt undervejs.
//
// Og der ventes IKKE på "Gemt": den tekst er hvilestanden, ikke et signal.
// Feltet siger "Gemmer" mens en gemning kører og "Gemt" ellers — altså også før
// den første gemning overhovedet er begyndt. En prøve, der venter på den, får
// grønt med det samme og måler ingenting. Det var den fejl, der gjorde den her
// prøve ustabil: rettelsen var en ventetid, der ikke kunne vente på noget.
// Rækken i listen er det rigtige tegn — den kommer først, når serveren har
// svaret.
for (const title of ['Fyrreskov nord', 'Fyrreskov syd', 'Egeskov']) {
await page.getByRole('button', { name: 'Ny note' }).click();
const body = page.getByLabel('Notens tekst');
await expect(body).toHaveText('');
await body.fill(`${title}\n\nnoget om den`);
await body.blur();
await expect(page.locator('.list').getByText(title, { exact: true })).toBeVisible();
@@ -4681,14 +4797,249 @@ test('en søgning i ⌘K efterlader de træffere, den fandt', async ({ page }) =
await expect(list.getByText('Fyrreskov syd')).toBeVisible();
await expect(list.getByText('Egeskov')).toHaveCount(0);
// Og adressen lyver ikke, når feltet tømmes: uden det ville et genbesøg hente
// en søgning tilbage, man selv havde ryddet væk.
// Og de træffere kan ÅBNES. Adressen bærer stadig `?note=` på den, ⌘K åbnede,
// og den blev læst igen hver gang noget ændrede sig — så et klik på en anden
// række satte `selectedId`, effekten så den uenig med adressen, og trak én
// tilbage til den første. Man kunne søge og ikke vælge.
await list.getByText('Fyrreskov syd').click();
await expect(page.getByLabel('Notens tekst')).toContainText('Fyrreskov syd');
await expect(page.getByLabel('Notens tekst')).not.toContainText('Fyrreskov nord');
// Og tilbage til den, adressen stadig nævner. Det tilfælde er grunden til, at
// åbningen hænger på navigationen og ikke på adressens værdi: `?note=` peger
// stadig på "Fyrreskov nord", så en rettelse, der blot læste den én gang og
// huskede værdien, ville se den uændret og lade være med at åbne noget. Man
// kunne så ikke komme tilbage til den note, man kom fra.
await page.getByRole('button', { name: /Søg/ }).click();
await palette.getByRole('textbox').fill('Fyrreskov nord');
await palette.locator('li button', { hasText: 'Fyrreskov nord' }).click();
await expect(page.getByLabel('Notens tekst')).toContainText('Fyrreskov nord');
// Feltet kan tømmes, og listen kommer hel igen. Adressen beholder sit `q` —
// den skrives kun af navigation. Den første udgave skrev den ved hvert
// tastetryk, så den kunne deles, og det kostede bogstaver ud af feltet; se
// prøven nedenfor.
await page.getByPlaceholder('Søg i noter').fill('');
await expect(page).not.toHaveURL(/[?&]q=/);
await expect(list.getByText('Egeskov')).toBeVisible();
expect(trouble).toEqual([]);
});
/**
* En ny note åbnes med det samme, uden at editoren forsvinder undervejs.
*
* Serveren tømmer `body` på listerækker med vilje, og siden brugte den tomme krop
* som tegn på "det her er en række, hent noten hel". En nyoprettet note er også
* tom — og hel — så den blev sendt samme vej: editoren blev pillet af skærmen,
* noten hentet igen, og editoren sat op på ny.
*
* Det hul var ikke kun et blink. Mens det stod åbent, var der ingen valgt note, og
* gemningen går tavst tilbage uden en. Skrev man i det sekund, var teksten væk —
* ingen fejl, ingen toast, bare en tom note. Det er også det, der gjorde
* søgeprøven ovenfor ustabil: i CI var maskinen langsom nok til at ramme hullet.
*
* Prøven tæller opslagene frem for at måle et blink. Et blink er en tidsting og
* ville kræve, at prøven var hurtigere end programmet — præcis den fejl, den er
* skrevet for at lukke. Et opslag, der ikke sker, er et tal.
*/
test('en ny note hentes ikke igen, når den lige er lavet', async ({ page }) => {
const trouble = watchForTrouble(page);
const fetched = [];
page.on('request', (r) => {
// Selve noten, ikke dens delinger: /notes/<id> og ikke /notes/<id>/shares.
const m = new URL(r.url()).pathname.match(/^\/api\/v1\/notes\/([0-9a-f-]+)$/);
if (r.method() === 'GET' && m) fetched.push(m[1]);
});
await page.goto('/noter');
await page.getByRole('button', { name: 'Ny note' }).click();
// Editoren er der, og den er tom — ikke væk og på vej tilbage.
const body = page.getByLabel('Notens tekst');
await expect(body).toHaveText('');
await body.fill('Grantræ\n\nnoget om det');
await body.blur();
await expect(page.locator('.list').getByText('Grantræ', { exact: true })).toBeVisible();
// Noten blev aldrig hentet igen: svaret på oprettelsen var den hele.
expect(fetched).toEqual([]);
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);
});
/**
* Klokkens panel bliver inden for skærmen på en telefon.
*
* Panelet hang under klokken og var 340 px bredt. Klokken sidder tættere på højre
* kant end det — der er en temaknap og en kantafstand til højre for den — så
* panelet blev skubbet ud over venstre side og klippet af. På en telefon stod der
* flere linjer tekst uden for skærmen, og overskriften "Beskeder" lå klods op ad
* kanten uden luft, hvilket er tegnet på, at der mangler noget til venstre for
* den.
*
* Målt på kasserne frem for på et skærmbillede: "ser rigtigt ud" er ikke en
* påstand, en prøve kan efterprøve, men "venstre kant er ikke negativ" er. Begge
* kanter tjekkes — en rettelse, der skubber panelet ind fra venstre ved at lade
* det stikke ud til højre, er ikke en rettelse.
*/
test('klokkens panel bliver inden for skærmen på en telefon', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/');
await page.getByRole('button', { name: /^Beskeder/ }).click();
const panel = page.getByRole('dialog', { name: 'Beskeder' });
await expect(panel).toBeVisible();
const kasse = await panel.boundingBox();
expect(kasse.x, 'panelet hænger ud over venstre kant').toBeGreaterThanOrEqual(0);
expect(kasse.x + kasse.width, 'panelet hænger ud over højre kant').toBeLessThanOrEqual(390);
// Og der er luft til overskriften. Et panel, der lige akkurat begynder ved nul,
// men hvis tekst gør det samme, er stadig klippet — det var netop sådan, det så
// ud på telefonen.
const overskrift = panel.getByText('Beskeder', { exact: true });
const tekst = await overskrift.boundingBox();
expect(tekst.x, 'overskriften klæber til kanten').toBeGreaterThan(kasse.x);
expect(trouble).toEqual([]);
});
/**
* En punktliste kan have niveauer, og de overlever gemningen.
*
* Tab rykker ind og shift+Tab ud, som i ethvert andet program med lister. Det var
* ikke bundet til noget: Tab uden for forslagslisten gjorde det, browseren gør med
* Tab, altså flyttede fokus ud af noten. Der var ingen vej til et niveau to.
*
* Omformeren kunne begge veje i forvejen — indrykning bærer niveauet i Markdown —
* men skriveren læste kun en underliste, der ligger INDE i sit punkt. Lægger
* browseren den som søskende, og det gør de uens, blev grenen sprunget over uden
* lyd: punktet forsvandt ved gemningen. Begge former læses nu.
*
* Målt efter en genindlæsning. Før den står indrykningen på skærmen uanset hvad —
* det er turen gennem Markdown, der afgør, om den blev skrevet ned.
*/
test('en punktliste kan have niveauer, der overlever gemningen', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
await nyNote(page);
await page.keyboard.type('Indkøb');
await page.keyboard.press('Enter');
await page.getByRole('button', { name: 'Formatér' }).click();
await page.getByRole('menuitem', { name: 'Punktliste' }).click();
await page.keyboard.type('Frugt');
await page.keyboard.press('Enter');
await page.keyboard.type('Aebler');
await page.keyboard.press('Tab');
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
// Niveauet er der med det samme: en liste inde i en liste.
await expect(ed.locator('ul ul li')).toHaveText(['Aebler']);
const gemt = noteGemt(page);
await ed.blur();
await gemt;
await page.reload();
await page.getByRole('button', { name: /Indkøb/ }).click();
// Og efter turen gennem Markdown står det der stadig.
await expect(ed.locator('ul ul li')).toHaveText(['Aebler']);
await expect(ed.locator('ul > li').first()).toContainText('Frugt');
// Og tilbage igen: shift+Tab er vejen ud, ellers er niveauet en envejsdør.
await ed.locator('ul ul li').click();
await page.keyboard.press('Shift+Tab');
await expect(ed.locator('ul ul li')).toHaveCount(0);
expect(trouble).toEqual([]);
});
/**
* En kodeblok med flere linjer kommer tilbage med dem alle.
*
* Shift+retur bliver i blokken, som prøven ovenfor viser — men det, browseren
* lægger ind, er et <br>, og `textContent` på et <br> er den tomme streng. Blokken
* blev derfor skrevet til Markdown som én linje: "linje etlinje to". Man kunne
* skrive flere linjer og ikke gemme dem.
*
* Målt efter en genindlæsning og ikke før. Før gemningen står begge linjer der —
* det er turen gennem Markdown, der taber dem, og det er præcis den, prøven
* ovenfor stopper lige før.
*/
test('en kodeblok med flere linjer overlever gemningen', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
await nyNote(page);
await page.keyboard.type('Opskrift');
await page.keyboard.press('Enter');
await page.getByRole('button', { name: 'Formatér' }).click();
await page.getByRole('menuitem', { name: 'Monotype' }).click();
await page.keyboard.type('first line');
await page.keyboard.press('Shift+Enter');
await page.keyboard.type('second line');
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
const gemt = noteGemt(page);
await ed.blur();
await gemt;
await page.reload();
await page.getByRole('button', { name: /Opskrift/ }).click();
// Én blok, to linjer. Teksten læses rå frem for gennem `toHaveText`, som
// normaliserer mellemrum væk — og linjeskiftet ER det, der måles.
//
// Og på <code>, ikke på <pre>: kopiér-knappen ligger inde i blokken for at
// kunne sidde i hjørnet af den, så `textContent` på <pre> har ordet "Kopiér"
// med. Det er samme fælde, som selve fejlen handler om — hvad der tæller med i
// en blocks tekst — bare fra den anden side.
const blok = ed.locator('pre');
await expect(blok).toHaveCount(1);
expect(await blok.locator('code').evaluate((el) => el.textContent.trim())).toBe(
'first line\nsecond line'
);
expect(trouble).toEqual([]);
});
/**
* Et indsat billede kan ses i fuld størrelse og kopieres videre.
*
@@ -4749,3 +5100,143 @@ test('et indsat billede kan åbnes stort og kopieres', async ({ page, context })
expect(trouble).toEqual([]);
});
/**
* En opgaves tekst kan læses færdig, også på en telefon.
*
* Rapporteret fra en telefon, med et billede af en beskrivelse, der manglede sit
* første bogstav på hver linje: "hermostat RU0910301440" og "atteries". Det er
* ikke manglende tegn — det er et felt, der er rullet til højre. En beskrivelse er
* tit et klippet stykke mail med en URL i, et <textarea> ombryder ved mellemrum,
* og en URL har ingen.
*
* Og titlen var et <input>, som ikke kan ombryde overhovedet. "tado°: Your Smart
* Thermostat needs new batteries" er en almindelig opgavetitel, og halvdelen af
* den stod uden for kanten.
*/
test('en opgaves tekst kan læses færdig på en telefon', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/');
const langTitel = 'tado°: Your Smart Thermostat needs new batteries';
// "i dag" for at den lander på siden, prøven står på — parseren klipper det ud
// af titlen igen, så den hedder præcis det, den skal.
await page.getByLabel('Ny opgave').fill(`${langTitel} i dag`);
await page.getByLabel('Ny opgave').press('Enter');
await page.getByText('tado°', { exact: false }).first().click();
const titel = page.getByLabel('Opgavens tekst');
await expect(titel).toBeVisible();
await expect(titel).toHaveValue(langTitel);
// Beskrivelsen: en URL uden et eneste mellemrum at bryde ved.
const url = 'https://link.tado.com/emails/8/ttid/1-a37f2c9b4e6d8a1f0c3b5e7d9a2f4c6b8e0d1a3f5c7b9e2d4a6f8c0b3e5d7a9f';
const beskrivelse = page.getByLabel('Beskrivelse');
await beskrivelse.fill(`Thermostat RU0910301440\nBatteries\n${url}`);
await beskrivelse.blur();
// Målt frem for beskrevet: et felt, der ruller vandret, har en scrollWidth
// større end sin clientWidth. Det var dét, der klippede venstre kant.
//
// Felterne måles gennem deres egne lokatorer og ikke med en forespørgsel på
// aria-label: beskrivelsen har et <label for>, ikke et aria-label, så en
// forespørgsel fandt ingenting og ville have bestået tomt.
const spild = [];
for (const [navn, felt] of [
['Opgavens tekst', titel],
['Beskrivelse', beskrivelse]
]) {
const m = await felt.evaluate((el) => ({ ind: el.scrollWidth, ude: el.clientWidth }));
if (m.ind - m.ude > 1) spild.push(`${navn}: ${m.ind} > ${m.ude}`);
}
expect(spild).toEqual([]);
// Beskrivelsen løber ikke over i Chromium, heller ikke uden reglen — det var
// Safari, den blev set i, og det er en anden motor. Prøven måler derfor reglen
// selv frem for dens virkning. Det er svagere, og det står her, så den næste
// ved det: den vogter mod at reglen bliver slettet, ikke mod at den er forkert.
await expect(beskrivelse).toHaveCSS('overflow-wrap', 'break-word');
// Og titlen er blevet høj nok til at rumme sig selv frem for at klippe.
const linjer = await titel.evaluate((el) => ({
hoejde: el.clientHeight,
linje: parseFloat(getComputedStyle(el).lineHeight)
}));
expect(linjer.hoejde).toBeGreaterThan(linjer.linje * 1.5);
expect(trouble).toEqual([]);
});
/**
* Søgefeltet taber ikke bogstaver, og listen viser det, der blev spurgt om.
*
* Fra en skærmoptagelse, to billeder i sekundet:
*
* 2,5 s feltet "verd" listen ufiltreret
* 3,0 s feltet "nde" listen filtreret
* 6,0 s feltet "verdande" listen ufiltreret
* 6,5 s feltet tomt listen filtreret
*
* To fejl oven i hinanden.
*
* Bogstaverne forsvandt, fordi adressen blev skrevet ved hvert tastetryk:
* `replaceState` opdaterer `$page.url` et øjeblik efter, den kaldes, så en effekt
* læste den forrige adresse og skrev den gamle værdi tilbage i feltet.
*
* Og resultaterne landede for sent, fordi to hentninger kunne være undervejs på
* én gang uden at nogen holdt styr på, hvilken der gjaldt. Den langsomste vandt.
*/
test('søgefeltet taber ikke bogstaver, og det nyeste svar vinder', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
// Sin egen note at finde. Et ord fra en anden prøve ville gøre den her
// afhængig af rækkefølgen, og den kobling har allerede narret mig i denne fil.
await page.getByRole('button', { name: 'Ny note' }).click();
const krop = page.getByLabel('Notens tekst');
await krop.fill('Kaffemølle\n\nnoget om den');
await krop.blur();
// Rækken i listen er værnet. "Gemt" stod her før og er hvilestanden — se noteGemt().
await expect(page.locator('.list').getByText('Kaffemølle', { exact: true })).toBeVisible();
// Skrevet i et tempo, et menneske kan skrive i. Det var hastigheden, der
// afslørede det: en pause mellem hvert bogstav skjuler kapløbet helt.
const felt = page.getByPlaceholder('Søg i noter');
await felt.click();
await page.keyboard.type('kaffemølle', { delay: 40 });
await expect(felt).toHaveValue('kaffemølle');
// Og det bliver stående. Fejlen viste sig et halvt sekund efter, ikke med det
// samme — adressen skal nå at komme tilbage, før den kan skrive over noget.
await page.waitForTimeout(1500);
await expect(felt).toHaveValue('kaffemølle');
// Det nyeste svar vinder, også når det kommer først.
//
// Den første søgning holdes tilbage, den anden slippes igennem. Uden vagten
// lander den gamle sidst og overskriver listen med svaret på et spørgsmål, der
// ikke længere bliver stillet.
let held;
await page.route('**/api/v1/notes?*', async (route) => {
const q = new URL(route.request().url()).searchParams.get('q');
if (q === 'aaaaa' && !held) {
held = route;
return; // holdes, indtil den næste er kommet igennem
}
await route.continue();
});
await felt.fill('');
await felt.fill('aaaaa');
await page.waitForTimeout(500);
await felt.fill('kaffemølle');
await page.waitForTimeout(900);
if (held) await held.continue();
await page.waitForTimeout(700);
// Feltet og listen er enige: det, der står, er det, der blev spurgt om.
await expect(felt).toHaveValue('kaffemølle');
await expect(page.locator('.list').getByText('Kaffemølle', { exact: true })).toBeVisible();
await page.unroute('**/api/v1/notes?*');
expect(trouble).toEqual([]);
});
@@ -0,0 +1,95 @@
<script>
/**
* What the app shows when it could not reach the server at all.
*
* Before this, an outage rendered the sign-in screen: the boot asks who you
* are, the request never gets an answer, `user` stays null, and null means
* "logged out" everywhere else. So the app blamed the session for the network
* being down, and offered a password box that could not possibly work. That is
* worse than an error message, because it looks like it might work — people
* type the password, get nothing, and conclude their account is broken.
*
* It happened for real on 17 September 2026: the instance's DNS record was
* deleted, and what the app showed was a slow-loading login page.
*
* The text says the data is safe, because that is the question somebody
* actually has when their notes vanish behind a login box.
*/
import { t } from '$lib/i18n.svelte.js';
import { app } from '$lib/stores.svelte.js';
let trying = $state(false);
async function again() {
trying = true;
try {
await app.load();
} finally {
trying = false;
}
}
</script>
<div class="screen">
<div class="card">
<div class="brand">
<span class="rune" aria-hidden="true">ᚹ</span>
<h1>verdande</h1>
</div>
<h2>{t('net.downTitle')}</h2>
<p class="lede">{t('net.downBody')}</p>
<button onclick={again} disabled={trying}>
{trying ? t('net.reconnecting') : t('net.downRetry')}
</button>
</div>
</div>
<style>
.screen {
height: 100dvh;
display: grid;
place-items: center;
padding: var(--s4);
background: var(--ground);
}
.card {
width: 100%;
max-width: 340px;
display: flex;
flex-direction: column;
gap: var(--s4);
text-align: center;
}
.brand {
display: flex;
align-items: center;
gap: var(--s3);
justify-content: center;
}
.rune {
font-size: var(--text-2xl);
color: var(--accent);
line-height: 1;
}
h1 {
font-size: var(--text-xl);
margin: 0;
}
h2 {
font-size: var(--text-lg);
margin: 0;
}
.lede {
margin: 0;
color: var(--ink-soft);
line-height: 1.5;
}
</style>
+20
View File
@@ -788,6 +788,17 @@
});
}
/**
* Står markøren i en punktliste eller en nummereret liste?
*
* Spurgt gennem kommandotilstanden frem for ved at gå op gennem træet: det er
* den samme kilde, værktøjslinjen allerede markerer knappen efter, så de to kan
* ikke blive uenige om, hvad der er en liste.
*/
const inList = () =>
document.queryCommandState('insertUnorderedList') ||
document.queryCommandState('insertOrderedList');
function onkeydown(event) {
// The suggestion list owns the arrows and return while it is open, the way
// every other completion does. Escape closes it without choosing.
@@ -816,6 +827,15 @@
}
}
// Tab rykker et punkt ind og ud, som i enhver anden liste. Kun i en liste:
// alle andre steder er Tab den tast, der flytter fokus videre, og en editor,
// der stjæler den, er en editor, man ikke kan komme ud af med tastaturet.
if (event.key === 'Tab' && inList()) {
event.preventDefault();
apply(event.shiftKey ? 'outdent' : 'indent');
return;
}
if (event.key === 'Enter' && !event.shiftKey) {
// A code block is the one place the browser keeps the caret inside on
// Enter rather than starting a new block. Step out of it ourselves, or a
+43 -2
View File
@@ -20,6 +20,31 @@
let open = $state(false);
let wrap = $state(null);
/**
* Hvor panelet begynder på en telefon — målt, ikke regnet ud.
*
* På en bred skærm hænger panelet under klokken og hører til den. På en telefon
* kan det ikke: klokken sidder tættere på højre kant end panelet er bredt, så et
* panel, der er stillet op efter klokken, skydes ud over venstre side og bliver
* klippet. Der stod tre en halv linje tekst uden for skærmen.
*
* Bredden kunne trækkes fra i CSS — der er en temaknap, et mellemrum og en
* kantafstand til højre for klokken — men så ville den her fil kende sine
* naboers størrelse, og næste knap i toplinjen ville lave fejlen om igen uden
* at sige noget. Toplinjens underkant måles i stedet, og så spænder panelet over
* skærmen med en kant i hver side.
*/
let below = $state(0);
function place() {
if (wrap) below = Math.round(wrap.getBoundingClientRect().bottom);
}
function toggle() {
open = !open;
if (open) place();
}
const KINDS = {
assigned: 'notif.assigned',
'note.changed': 'notif.noteChanged',
@@ -72,6 +97,7 @@
<svelte:window
onclick={outside}
onresize={() => open && place()}
onkeydown={(e) => {
if (e.key === 'Escape' && open) open = false;
}}
@@ -81,7 +107,7 @@
<button
class="bell"
class:on={open}
onclick={() => (open = !open)}
onclick={toggle}
aria-expanded={open}
aria-haspopup="dialog"
aria-label={app.unread
@@ -101,7 +127,7 @@
</button>
{#if open}
<div class="panel" role="dialog" aria-label={t('notif.title')}>
<div class="panel" role="dialog" aria-label={t('notif.title')} style="--below: {below}px">
<header>
<span>{t('notif.title')}</span>
{#if app.unread}
@@ -193,6 +219,21 @@
box-shadow: var(--shadow-lg, 0 8px 30px rgb(0 0 0 / 0.25));
}
/* På en telefon hører panelet til skærmen, ikke til klokken. Se `below` i
skriptet for hvorfor det måles frem for at blive regnet ud. Samme knæk som
toplinjens egen menuknap, så de to skifter form samtidig. */
@media (max-width: 820px) {
.panel {
position: fixed;
top: calc(var(--below) + 6px);
left: var(--s3);
right: var(--s3);
width: auto;
max-height: calc(100dvh - var(--below) - var(--s3) * 2);
overscroll-behavior: contain;
}
}
.panel header {
display: flex;
align-items: center;
+56 -2
View File
@@ -171,6 +171,32 @@
* took is said out loud, because a title that edits itself without a word is
* worse than one that does nothing.
*/
/**
* Lader et felt være lige så højt som sin tekst.
*
* `height: auto` først, og så `scrollHeight`: uden nulstillingen kan feltet kun
* vokse, for scrollHeight på et felt, der allerede er for højt, er dets egen
* højde.
*/
function grow(node) {
const fit = () => {
node.style.height = 'auto';
node.style.height = `${node.scrollHeight}px`;
};
fit();
node.addEventListener('input', fit);
// Bredden skifter, når ruden gør — og en tekst, der fyldte to linjer, fylder
// tre på en smallere skærm uden at der er rørt ved den.
const watch = new ResizeObserver(fit);
watch.observe(node);
return {
destroy() {
node.removeEventListener('input', fit);
watch.disconnect();
}
};
}
async function saveContent() {
if (!task) return;
const trimmed = content.trim();
@@ -418,13 +444,27 @@
<div class="scroll">
<div class="field">
<input
<!-- Et felt frem for en linje. En opgavetitel er tit længere end en telefon
er bred — "tado°: Your Smart Thermostat needs new batteries" er en
almindelig en — og et <input> kan ikke ombryde: det ruller, og så står
halvdelen af overskriften uden for kanten. -->
<textarea
class="title"
rows="1"
use:focusOnMount
use:grow
bind:value={content}
onblur={saveContent}
onkeydown={(e) => {
// Retur gemmer frem for at lave en ny linje. En titel er én linje,
// uanset hvor mange den fylder på skærmen.
if (e.key === 'Enter') {
e.preventDefault();
e.currentTarget.blur();
}
}}
aria-label={t('detail.text')}
/>
></textarea>
</div>
<div class="field">
@@ -894,6 +934,16 @@
resize: vertical;
font-family: inherit;
line-height: 1.5;
/* En beskrivelse er tit et klippet stykke mail med en URL i. Et <textarea>
ombryder ved mellemrum, og en URL har ingen — så feltet rullede vandret,
og på en telefon stod teksten forskudt med venstre kant klippet af. Det
ser ikke ud som rulning; det ser ud som manglende bogstaver.
`break-word` og ikke `anywhere`: den første bryder kun et ord, der ikke kan
stå på en linje alene, mens den anden bryder ved først givne lejlighed og
delte "GarageRisteriet" midt i en stavelse. Se h1 på projektets side — den
lektion er betalt én gang. */
overflow-wrap: break-word;
}
/* The title is the one field that should not look like a field until you are in
@@ -905,6 +955,10 @@
background: transparent;
border-color: transparent;
padding-left: 0;
/* Højden sættes af `grow`, så håndtaget ville love noget, det ikke kan
holde: trækker man i det, retter det næste bogstav det tilbage. */
resize: none;
overflow: hidden;
}
.title:hover {
+3
View File
@@ -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 ------------------------------------------------
+3
View File
@@ -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 ------------------------------------------------
+44 -8
View File
@@ -314,6 +314,41 @@ const BLOCK_TAGS = /^(P|H[1-6]|UL|OL|LI|PRE|BLOCKQUOTE|DIV)$/;
/** Whether an element holds blocks, in which case reading it as one line loses them. */
const holdsBlocks = (el) => [...el.children].some((c) => BLOCK_TAGS.test(c.tagName));
/**
* Teksten i en kodeblok, med linjeskiftene i behold.
*
* `textContent` er ikke nok, og det er ikke en detalje: et <br> har den tomme
* streng som textContent, og <br> er præcis det, browseren lægger ind, når man
* trykker shift+retur i en blok. Blokken blev derfor skrevet ned som én linje —
* "first linesecond line" — så man kunne skrive flere linjer og ikke gemme dem.
* Det er usynligt indtil en genindlæsning, for på skærmen står de rigtigt hele
* tiden.
*
* Kopiér-knappen springes over. Den ligger inde i <pre> for at kunne sidde i
* hjørnet af den, og ellers stod ordet "Kopiér" i hver eneste kodeblok efter hver
* gemning.
*
* Rekursivt, fordi indholdet ligger i et <code> inde i blokken: en filtrering over
* <pre>'ens egne børn ser kun det ene element og spørger det om en textContent,
* der allerede har tabt linjeskiftet.
*/
function codeText(node) {
let out = '';
for (const n of node.childNodes) {
if (n.nodeType !== Node.ELEMENT_NODE) {
out += n.textContent ?? '';
continue;
}
if (n.classList?.contains('copy')) continue;
if (n.tagName === 'BR') {
out += '\n';
continue;
}
out += codeText(n);
}
return out;
}
export function htmlToMarkdown(root) {
const lines = [];
@@ -366,14 +401,7 @@ export function htmlToMarkdown(root) {
// else and an indent does not.
const lang = child.getAttribute('data-lang') ?? '';
emit('```' + lang);
// Knapper og andet, editoren har lagt oven på blokken, hører ikke til
// i filen. Kopiér-knappen ligger inde i <pre> for at kunne placeres i
// hjørnet af den, og `textContent` ville ellers skrive ordet "Kopiér"
// ind i hver eneste kodeblok, hver gang noten blev gemt.
const code = [...child.childNodes]
.filter((n) => n.nodeType !== Node.ELEMENT_NODE || !n.classList?.contains('copy'))
.map((n) => n.textContent ?? '')
.join('');
const code = codeText(child);
for (const l of code.replace(/\n$/, '').split('\n')) {
emit(l);
}
@@ -408,6 +436,14 @@ export function htmlToMarkdown(root) {
const pad = ' '.repeat(depth);
for (const li of el.children) {
// En underliste, der ligger som SØSKENDE til punktet frem for inde i det.
// Browserne er uenige om, hvilken af de to former en indrykning laver, og
// de to ser ens ud på skærmen — men den her løkke sprang søskendeformen
// over uden lyd, så et indrykket punkt ville forsvinde ved gemningen.
if (li.tagName === 'UL' || li.tagName === 'OL') {
list(li, depth + 1, emit);
continue;
}
if (li.tagName !== 'LI') continue;
// Punktets egen tekst er alt undtagen de lister, der ligger under det.
+19
View File
@@ -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;
}
+5
View File
@@ -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 @@
<div class="booting"></div>
{:else if standalone}
{@render children()}
{:else if app.unreachable}
<!-- Ikke login-skærmen: en total udfald er ikke en udløbet session, og et
kodeord hjælper ikke mod en manglende forbindelse. -->
<NoConnection />
{:else if !app.user}
<SignIn />
{:else}
+86 -46
View File
@@ -14,8 +14,8 @@
import { api, humanMessage } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
import { page } from '$app/stores';
import { untrack } from 'svelte';
import { goto, replaceState } from '$app/navigation';
import { onMount, untrack } from 'svelte';
import { afterNavigate, goto } from '$app/navigation';
import { t, tag } from '$lib/i18n.svelte.js';
import { NOTE, startDrag } from '$lib/dnd.js';
import { colorVar } from '$lib/colors.js';
@@ -485,65 +485,94 @@
});
/**
* En søgning, man kommer med, og en søgning, man kan gå videre med.
* En søgning, man kommer med.
*
* ⌘K viste noter, der passede, i sin egen liste — og når man valgte en, åbnede
* den noten på en side, hvis liste stod uændret. Så var de andre træffere væk,
* og det eneste sted, de havde været, var en popup, der lige var lukket. "Jeg
* får kun præsenteret en popup med resultater, og trykker jeg retur, kommer jeg
* tilbage til den normale liste."
* ⌘K sender `?note=…&q=…`, så listen ved siden af den åbne note er de andre
* træffere frem for den almindelige liste.
*
* Nu bærer adressen søgningen, så listen ved siden af den åbne note er de andre
* træffere. Og den skrives tilbage, når feltet ændres, så adressen kan gemmes og
* deles — og frem for alt ikke lyver, når man har tømt feltet.
* Læst, aldrig skrevet. Den første udgave skrev også søgningen tilbage i
* adressen ved hvert tastetryk, så den kunne gemmes og deles — og det kostede
* bogstaver. `replaceState` opdaterer `$page.url` et øjeblik efter, den kaldes,
* så to hurtige tastetryk gav en effekt, der læste den *forrige* adresse, fandt
* den uenig med det skrevne, og skrev den gamle værdi tilbage i feltet. Man
* skrev "verd", og der stod "nde". Målt på en skærmoptagelse, ikke gættet.
*
* `settled` er det, de to retninger er blevet enige om — og den læses med
* `untrack`, hvilket er hele forskellen på at virke og ikke.
*
* Uden den: den nederste effekt sætter `settled`, det vækker den øverste, og
* `replaceState` har endnu ikke opdateret `$page.url`. Den øverste læser derfor
* den *gamle* adresse, finder en søgning, der ikke er enig med `settled`, og
* skriver den tilbage i feltet. Man tømmer søgefeltet, og ordet kommer igen.
* Hver effekt skal reagere på sin egen kilde — adressen og feltet — og aldrig
* på den fælles markør.
* En markør, der skal holde to retninger enige, er en markør, der taber
* kapløbet et sted. Adressen skrives derfor kun af navigation, og det her er
* den eneste, der læser den.
*/
let settled = $state(null);
let cameWith = $state(null);
$effect(() => {
const inURL = $page.url.searchParams.get('q') ?? '';
if (inURL === untrack(() => settled)) return;
settled = inURL;
if (inURL === untrack(() => cameWith)) return;
cameWith = inURL;
draftQuery = inURL;
query = inURL;
});
$effect(() => {
const now = query;
if (now === untrack(() => settled)) return;
settled = now;
const url = new URL(untrack(() => $page.url));
if (now) url.searchParams.set('q', now);
else url.searchParams.delete('q');
replaceState(url, {});
});
// Arriving from somewhere that names a note — a task's panel, a project's page —
// opens it rather than dropping the person on a list to find it again.
let asked = $derived($page.url.searchParams.get('note'));
$effect(() => {
if (!asked || selectedId === asked) return;
const found = notes.find((n) => n.id === asked);
/**
* En note, man kommer MED — en besked fra klokken, et link fra en opgave, en
* træffer i ⌘K. Den åbnes, så man ikke bliver sat af på en liste og skal finde
* den igen.
*
* Bundet til selve navigationen og ikke til adressens indhold, og det er hele
* pointen. Siden skriver aldrig adressen — det blev fjernet, fordi det kostede
* bogstaver ud af søgefeltet — så `?note=` bliver stående på den, man kom med,
* uanset hvad man klikker bagefter. En effekt, der læste den igen, hver gang
* noget ændrede sig, så derfor `selectedId` uenig med adressen og åbnede den
* gamle igen: man kunne søge og ikke vælge, for hvert klik på en anden række
* sprang tilbage til den første.
*
* En adresse, der ikke følger med, er ikke en tilstand at holde sig enig med.
* Den er en besked, der kommer én gang — så den læses ved ankomsten frem for
* ved hver ændring.
*/
function openFromURL(url) {
const id = url?.searchParams.get('note');
if (!id || id === selectedId) return;
const found = notes.find((n) => n.id === id);
if (found) open(found);
else api.note(asked).then(open).catch(() => {});
});
else api.note(id).then(open).catch(() => {});
}
// To veje ind, fordi der er to måder at ankomme på, og hver af dem er blind for
// den anden.
//
// `afterNavigate` alene rækker ikke: layoutet tegner ingenting, mens kontoen
// hentes, så den her side bliver monteret EFTER den første navigation er forbi.
// En lytter, der registreres der, hører den aldrig — `/noter?note=<id>` tastet
// eller fulgt udefra åbnede så ingenting.
//
// Og `onMount` alene rækker heller ikke: går man til en anden note inde i
// programmet, monteres siden ikke om.
onMount(() => openFromURL($page.url));
afterNavigate((nav) => openFromURL(nav?.to?.url ?? $page.url));
/**
* Hvilken hentning der gælder.
*
* To søgninger kan være undervejs på én gang, og den langsomste kan lande
* sidst. Uden det her betød det, at listen viste svaret på et spørgsmål, der
* ikke længere blev stillet — på optagelsen stod resultaterne for "verdande" i
* listen, efter feltet var tømt. Pausen på 250 ms gjorde det sjældnere og kunne
* ikke gøre det umuligt.
*/
let newest = 0;
async function load(q) {
const mine = ++newest;
try {
const params = q ? { q } : showArchive ? { archived: '1' } : {};
notes = (await api.notes(params)).notes ?? [];
const got = (await api.notes(params)).notes ?? [];
// Et svar, der er blevet overhalet, kastes væk. Det er ikke forkert —
// det er bare ikke længere svaret på det, der blev spurgt om.
if (mine !== newest) return;
notes = got;
status = 'ready';
if (selectedId && !notes.some((n) => n.id === selectedId)) open(notes[0] ?? null);
} catch (e) {
if (mine !== newest) return;
status = 'failed';
app.toast(humanMessage(e));
}
@@ -560,7 +589,17 @@
* Derfor hentes den hele her. Rækken vises med det samme, så det ikke føles som
* at vente; teksten kommer, når den er der.
*/
async function open(note) {
/**
* `whole` siger, at noten allerede er hel, og at der ikke skal hentes.
*
* En tom krop bruges ellers som tegn på, at det her er en listerække — serveren
* tømmer `body` på dem med vilje. Men en *nyoprettet* note er også tom, og den
* er hel. Uden det her blev den lukket ud som en halv note: editoren blev pillet
* af skærmen, noten hentet igen, og editoren sat op på ny. I det hul står
* `selected` som null, og `save()` går tavst tilbage uden at gemme — så tekst
* skrevet der forsvandt sporløst.
*/
async function open(note, { whole = false } = {}) {
clearTimeout(timer);
if (!note) {
selectedId = null;
@@ -576,9 +615,9 @@
// id'et være det samme begge gange, så den anden aldrig blev vist. Man ville
// sidde med en tom note, der lige havde haft en titel.
selectedId = note.id;
if (note.body) {
if (whole || note.body) {
selected = note;
draft = note.body;
draft = note.body ?? '';
return;
}
selected = null;
@@ -690,7 +729,8 @@
try {
const note = await api.createNote({ body: '' });
notes = [note, ...notes];
open(note);
// Svaret er hele noten, så den skal ikke hentes igen — se open().
await open(note, { whole: true });
document.querySelector('.editor textarea')?.focus();
} catch (e) {
app.toast(humanMessage(e));