234 Commits
Author SHA1 Message Date
kristianwindandClaude Opus 5 9155e799b1 fix(prøver): markør-prøverne venter på editoren, før de taster
Runde 3 og 4 af de røde End-to-end var samme årsag. Det faste ophold, jeg
fjernede i forrige commit, skjulte den bag en anden påstand.

`page.keyboard.type` kørte, før editoren var tegnet. `click()` på "Ny note"
vender tilbage, så snart klikket er sendt, mens noten først skal oprettes
på serveren og tegnes bagefter. Så gik det tastede ind i den FORRIGE note
eller ingen steder, og noten blev liggende tom:

* runde 3 fejlede på, at noten ikke fandtes efter genindlæsningen
* runde 4 fejlede på, at der ikke kom nogen PATCH at vente på

To forskellige påstande, én årsag.

Og svaret stod skrevet over `nyNote` i samme fil, før prøverne blev
skrevet:

    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 er runde 3's fejl ord for ord. Men `nyNote` KLIKKER i feltet, og det er
netop det, markør-prøverne ikke må: de måler, at markøren står der, uden at
nogen har sat den. Derfor `nyTomNote` — samme ventetid, uden klikket.

Ankret på <h1> frem for blot et tomt felt: <h1> skrives af samme effekt,
der sætter markøren, så når den er der, er markøren sat. Et tomt felt kan
være et felt, effekten ikke har rørt endnu.

Kontrolleret i begge retninger med 1500 ms plantet på POST /notes: det rå
klik fejler med runde 4's ordlyd, `nyTomNote` består.

E2E: 131 bestod, 0 netafbrydelser.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-08 20:29:59 +02:00
kristianwindandClaude Opus 5 5764271579 fix(prøver): kontrollen venter på gemningen, ikke på et tal
Fjerde røde End-to-end, fjerde reelle årsag, og denne stod skrevet i
forvejen.

"en note med indhold stjæler ikke markøren" brugte `waitForTimeout(1200)`
til at vente på, at noten blev gemt. På en travl runner var 1200 ms ikke
nok: genindlæsningen smed det skrevne væk, og prøven faldt på at noten
ikke FANDTES — `waiting for getByRole({ name: /Allerede skrevet/ })` —
frem for på markøren, som er det den måler.

Kommentaren over `gemt` i toppen af den samme fil siger det ord for ord:

    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.

Jeg skrev præcis det kald, med præcis det tal, under en kommentar der
navngiver begge. Lektien var ikke ny — den var skrevet ned, i filen, lige
over den hjælper der findes for at erstatte det.

Bruger nu `noteGemt(page)`, sat op FØR der skrives: svaret på PATCH'en er
det eneste, der beviser, at serveren har hørt om det.

Kontrolleret i begge retninger med 2500 ms plantet på PATCH /notes/* —
mere end dobbelt så længe som det gamle ophold ventede. Det gamle kald
fejler med CI's ordlyd; `noteGemt` består.

Der står 24 faste ophold tilbage i filen. De er ældre end denne gren, og
nogle venter måske på noget uden et netværkskald, så de hører i deres egen
rettelse — men mønsteret er nu dokumenteret to gange i samme fil.

E2E: 131 bestod, 0 netafbrydelser.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-08 20:12:07 +02:00
kristianwindandClaude Opus 5 8a6329bfc9 fix(prøver): markør-prøverne venter på markøren i stedet for at håbe
CI's End-to-end faldt på "en ny note åbner med markøren i overskriften" med
`Expected: "h1", Received: null`, på en gren hvis eneste ændring siden
forrige grønne kørsel var docs/openapi.yaml. Den kan ikke påvirke en
editor, så fejlen lå i prøven.

Den klikkede "Ny note" og læste markøren i ÉT snapshot med det samme.
`click()` vender tilbage, så snart klikket er sendt, mens markøren sættes
efter en netværksrundtur OG en gentegning. Lokalt er rundturen under et
millisekund, så snapshottet ramte altid; på en travl runner ramte det null.
main er grøn på netop den prøve, så den bestod ved held frem for ved regel
— og den havde ligget og ventet på en travl runner i en fremmed PR, hvor
den ville have lignet dén PRs fejl.

Målt frem for ræsonneret: med 900 ms plantet på POST /notes fejler den
gamle læsning med CI's ordlyd og `expect.poll` består.

Tre af de fire havde samme svaghed. Kontrollen — "en note med indhold
stjæler ikke markøren" — er bevidst IKKE lavet om: den forventer null, så
en poll ville bestå på første læsning uanset hvad koden gjorde. Den ankrer
sig på overskriftens tekst i stedet. Det står som kommentar i filen, så den
ikke bliver "rettet" tilbage.

E2E: 131 bestod, 0 netafbrydelser.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-08 19:53:12 +02:00
kristianwindandClaude Opus 5 956e57f234 feat(opgaver): alarmen kan tales ind, og kan være standard
To halvdele af det samme: at en opgave med et klokkeslæt også kan få en
alarm, uden at man går en omvej hver gang.

Påmindelser har virket længe og skulle sættes én ad gangen. En opgave
dikteret som "i morgen kl 9" fik en tid og INGEN alarm — `CreateReminder`
blev kun kaldt fra sit eget endepunkt, så intet skabte en påmindelse af sig
selv. Derefter skulle man åbne opgaven, finde Påmindelser og vælge et
klokkeslæt, og den omvej er længere end at skrive opgaven.

SÆTNINGEN

Hurtig tilføjelse forstår nu alarmen:

    ring til tandlægen i morgen kl 9 #hjem mind mig en time før

Formerne er `mind mig` / `påmind` / `remind me` / `med alarm`, efterfulgt af
en varighed (`en time før`, `10 min før`, `et kvarter før`, `en halv time
før`, `dagen før`, `to dage før`) eller et klokkeslæt (`mind mig kl 8`).
Bar "mind mig" betyder på selve tidspunktet. Dansk og engelsk i samme
linje, som resten af parseren.

Det gælder uanset hvor stemmen kommer ind: tastaturets diktering, en
genvej, eller noget tredje ender som tekst gennem den samme parser. Derfor
er syntaksen forudsætningen for dem alle frem for én af dem.

Trinnet ligger FØR klokke og dato, og den rækkefølge er hele tricket: "mind
mig en time før" har en varighed i sig og "mind mig kl 8" har en klokke, og
begge læst af tidsparseren først bliver opgavens egen forfaldstid i stedet
for dens alarm.

STANDARDEN

En indstilling under Notifikationer giver opgaver med både dato og
klokkeslæt en alarm uden at blive bedt om det, med et valgfrit varsel.

Den rammer alle tre veje, en opgave kan få et klokkeslæt — oprettet,
redigeret og hurtig-tilføjet. Redigeringsvejen er den halvdel, løftet om
"alle opgaver med en dato og tid" ellers taber: en opgave skrevet nu og
givet en tid i eftermiddag er den samme opgave. CalDAV er bevidst udenfor:
en opgave fra Apple Påmindelser bærer sin egen alarm, og to alarmer for én
opgave er værre end ingen.

Slået FRA for alle, der findes i dag. At tænde den i en migrering ville
give hver eksisterende bruger alarmer, de aldrig har bedt om, på opgaver de
har liggende — og det her er software, andre selv driver.

Og standarden skabte et behov for en udvej, så den fik en: "ingen alarm" i
linjen springer en enkelt opgave over, mens man skriver den, frem for at
oprette den, åbne den og slette alarmen der lige blev lavet. En udtrykkelig
alarm slår også standarden — den lægger sig ikke oven på.

TRE TING LÆSNINGEN FANGEDE

* Fortegnet. Lageret LÆGGER forskydningen til forfaldstiden
  (`due_datetime + offset_min * 60`), så "en time før" er -60. Gættet
  havde givet en alarm en time for sent, og det er den ene retning en
  påmindelse ikke må tage fejl i. Samme konvention hele vejen —
  indstillingen, parseren og kolonnen — for to fortegnsregler for det
  samme begreb viser sig kun som en alarm på et forkert tidspunkt.

* Den stille døde alarm. `DueReminders` vælger forskydningsformen kun
  `AND t.due_datetime IS NOT NULL`. Så "mind mig en time før" på en opgave
  med en dag men ingen klokketid ville have lagt en række, der aldrig
  fyrer. Nu bliver opgaven oprettet, alarmen ikke, og det bliver sagt.
  Det er den ene slags fejl en påmindelse ikke må have, fordi intet
  bagefter afslører den: der står ingen række at savne.

* "-60 min." på skærmen. TaskDetail viste forskydninger råt. Før nu kunne
  de kun laves gennem API'et, så grenen stod reelt urørt — dette er det
  første, der bruger den i praksis. Den skriver nu "1 time før".

PRØVET

Prøverne spørger `DueReminders` frem for om rækken findes: en række med
forkert fortegn findes lige så overbevisende og ringer en time for sent. Et
minut før den er forfalden må alarmen ikke være i listen, et minut efter
skal den.

Hver plantet fejl er kontrolleret til at falde i præcis den prøve, der
findes for den — fortegnet vendt, ordgrænsen fjernet, trinnet flyttet efter
dato, umulig klokke accepteret, dobbelt-vagten slået fra, "ingen alarm"
ignoreret, positivt tal accepteret, råt fortegn vist.

Og prøvens eget navn er en rettelse: de første fejlede i hele suiten men
bestod isoleret. Suiten kører med én arbejder mod ÉN database, og "ring til
tandlægen" findes fire steder i filen, så `.first()` ramte en anden prøves
opgave uden alarm.

Go-suiten grøn. E2E: 131 bestod, 0 netafbrydelser.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-08 19:16:14 +02:00
kristianwindandClaude Opus 5 6178cbbdd8 Merge branch 'main' into markoer-i-titlen
Begge grene føjede prøver til i enden af smoke.spec.js, og begge beholdes:
fire markør-prøver og fire fra tal-en-opgave-ind. Den fælles `/**` ovenfor
dækkede kun den første, så den anden blok har fået sin egen.

Suiten på den løste fletning: 129 bestod, 0 netafbrydelser.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-08 10:06:42 +02:00
kristianwind 475ecbe7ef Merge branch 'afsnit-efter-retur' into markoer-i-titlen 2026-10-08 07:55:04 +02:00
kristianwind 159029675c Merge branch 'afsnit-efter-retur' into tal-en-opgave-ind 2026-10-08 07:49:10 +02:00
kristianwindandClaude Opus 5 b18d9a26e6 fix(noter): afsnit skrevet hurtigt mister ikke deres afstand
Retur-handleren udskyder rettelsen ét hak, fordi der før delingen ikke er
nogen ny blok at rette. Men rettelsen spurgte, hvor markøren stod *nu*, og
et hak er nok: skriver man hurtigt, er næste retur nået igennem først, og så
stod markøren i den NÆSTE blok. Så rettede den den — to gange — og lod den
blok, rettelsen var ment for, stå tilbage som browserens bare <div>. En
<div> læses som brødtekst og er ikke formateret som en, så afstanden mellem
afsnit forsvandt stille, hos netop de brugere der skriver hurtigst.

Målt frem for gættet: "flere markerede afsnit bliver til én monotypeblok"
faldt på en CI-runner med alle tre linjer skrevet rigtigt, hvor "linje et"
var en <div> og "linje to" og "linje tre" var <p>.

Rettelsen er delt efter hvad der er sikkert at røre:

* Der hvor markøren står, gør execCommand det fortsat. Det er den, der
  lægger ændringen i browserens fortryd-historik, og filen vælger den
  bevidst andre steder af samme grund — ⌘Z skal tage afsnittet tilbage,
  ligesom den tager et ord tilbage.
* Den blok, delingen lige skabte, får samme regel uden markør at låne.
  Den dækker overskriften: retur for enden af en overskrift efterlader
  endnu en overskrift i de fleste browsere.
* Og hver <div> i noten bliver fejet op. En <div> er altid browserens egen
  beholder og aldrig nogens valg, så den kan rettes hvor som helst. En <h1>
  bliver IKKE fejet: menuen har en Titel-stil, så en overskrift midt i en
  note kan være en linje, nogen selv har valgt, og den er deres at beholde.

Fejning frem for et håndtag til blokken, og det er målt: når den første
udskudte rettelse bytter tagget, peger den næstes håndtag på en node uden
for dokumentet, og blokken midt i kæden bliver aldrig rettet. Prøven planter
to efterladte blokke og er kontrolleret i tre retninger — ingen rettelse: 2
efterladte <div>, håndtag til blokken: 1, fejning: 0.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-08 07:37:49 +02:00
kristianwindandClaude Opus 5 3768cf946e fix(noter): en ny note åbner med markøren i overskriften
Oprettelse af en note satte ingen markør. Siden kaldte
`document.querySelector('.editor textarea')?.focus()` — og der er ingen
textarea; editoren blev lavet om til en contenteditable, så kaldet har ramt
ingenting lige siden, og `?.` sørgede for, at det aldrig larmede.

Rettelsen ligger i NoteEditor frem for på siden, så begge veje er dækket:
knappen på /noter og den på et projekt, der navigerer til /noter?note=<id>.
Reglen er, at en TOM note sætter markøren i sin overskrift — en note med
indhold rører den ikke, for der er markøren brugerens eget valg.

Fire prøver, og de er kontrolleret i begge retninger: uden kaldet giver den
nye note `Expected: "h1", Received: null`, og med kaldet på enhver note
giver kontrollen `Expected: null, Received: "h1"`.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-08 07:16:22 +02:00
kristianwindandClaude Opus 5 60795126a5 fix(opgaver): skuffens knapper kan ikke tabbes til, mens den er usynlig
`inert` på skuffen, når den er lukket, så dens felt og knapper er ude af
tabrækkefølgen og ude af tilgængelighedstræet.

Det støder mod funktionen selv: et inert element kan ikke fokuseres, og
fokus i samme hug som klikket er præcis det, iOS kræver for at åbne
tastaturet. Attributten sættes reaktivt af `inert={!open}`, og Sveltes
opdateringer er asynkrone, så den er der stadig i det øjeblik handleren
kører.

Derfor fjernes den med hånden i `start()`, før fokus sættes, og den
reaktive binding lægger den tilbage ved lukning. En imperativ DOM-ændring
midt i en deklarativ komponent ser ud som sjusk, og uden en grund skrevet
ved siden af ville den næste læser helt rimeligt rydde den op.

Plantet ved at fjerne linjen: prøven går rød med "markøren skal stå i
skuffens felt efter trykket — Expected: true, Received: false". Linjen
bærer noget.

Luk-knappens `tabindex` er fjernet som overflødig; inert dækker hele træet.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-08 07:04:27 +02:00
kristianwindandClaude Opus 5 664614b924 feat(opgaver): tal en opgave ind — en rund knap i bunden på telefonen
En stor rund knap nederst til højre, inden for tommelfingerens rækkevidde.
Den åbner en skuffe med quick-add-feltet og sætter markøren i det, så
tastaturet kommer frem — og så trykker man mikrofonen på tastaturet.

VI LAVER INGEN TALEGENKENDELSE, og det er et valg frem for en mangel. iOS'
egen diktering kører på enheden på nyere telefoner. En talegenkendelse
bygget ind her ville sende lyden fra et selvhostet program videre til Apple
eller Google — en datavej, der skulle forklares på privatlivssiden, for at
spare ét tryk. Af de tre AI-udbydere, instansen understøtter, har anthropic
i øvrigt slet ingen lyd-API, så serverside-transskription ville også dele
brugerne op efter hvem de har en nøgle hos.

Teksten lander i det felt, der i forvejen viser, hvad parseren forstod, så
"ring til tandlægen i dag kl 14 p1" bliver læst mens man ser på det, og
intet oprettes før man siger ja. Derfor hedder komponenten diktat og ikke
tale: den gør ikke selv noget ved lyden, den stiller feltet frem.

## Det ene, funktionen hænger på

Safari på iOS åbner kun tastaturet, hvis focus() kaldes INDE i den
brugerhandling, der bad om det. QuickAdds `autofocus` sker i en $effect,
altså efter klikket er returneret — det giver feltet en markør og intet
tastatur, og der er ingen fejl nogen steder.

Så skuffen er ALTID monteret og bare skubbet ud af syne med transform og
opacity. Ikke display:none, ikke visibility:hidden, ikke inert — alle tre
gør feltet ufokuserbart. Prøven påstår direkte, at feltet findes FØR
klikket, fordi det er den eneste del af iOS-kravet, en browser kan måles på;
plantet som `{#if open}` går den rød med Expected: 1, Received: 0.

## To kollisioner, hvor jeg kun så den ene

Skuffen lægger et felt mere på siden, og det rammer to ting:

- `data-quickadd`, som skriv-hvor-som-helst-genvejen slår op med
  querySelector — altså FØRSTE match. Set på forhånd og løst med en
  `marked`-prop.

- Det tilgængelige navn. Det så jeg IKKE, og otte prøver gik røde på
  `getByLabel('Ny opgave') resolved to 2 elements`. Det er heller ikke kun
  et prøveproblem: to felter med samme navn læses op to gange af en
  skærmlæser, uden at man kan høre forskel. De to ER forskellige ting — det
  ene er kassen på siden, det andet er det, knappen åbner — så de har hver
  sit navn nu.

## Og en fejl, skærmbilledet fandt

Fremhævningen bag teksten lå på en linje, inputtet ikke havde — en bleg
blok under sætningen. Spejlet stod på `white-space: pre-wrap`, men et
<input type="text"> er enkeltlinje per specifikation og kan ikke ombryde,
uanset hvad reglen siger. Komponentens egen kommentar forudser det ordret:
"same wrapping. Any difference shows up as highlighting that drifts out of
alignment as the line grows."

Sjældent synligt på et bredt felt, konstant på 390px med en dikteret
sætning. Spejlet står nu på `pre` og rulles med inputtets scrollLeft.

Knappen tegnes ikke over 820px — der er feltet fremme hele tiden og et
tastatur at skrive på — og ikke i urd-ansigtet, hvor der ingen opgaver er.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-08 06:50:56 +02:00
kristianwindandClaude Opus 5 9199709aa5 feat(urd): den anden dør — /urd kan installeres som sin egen app
Én instans, én database, ét login — og to ting, der kan installeres.

Et styresystem installerer ikke en URL, det installerer et MANIFEST, og en
installeret apps identitet er manifestets `id`. To manifester på samme
origin med forskellige id'er er to apps; to adresser, der deler ét manifest,
er én app med et bogmærke. Det er hele mekanismen.

  /                     → verdande, ᚹ, /manifest.webmanifest, id "/"
  /urd                  → urd, ð, /urd.webmanifest, id "/urd"

Skallen serveres per dør frem for at blive rettet af JavaScript bagefter,
fordi iOS læser sidens <head> ved "Føj til hjemmeskærm" — og Safari leder
efter apple-touch-icon VED NAVN og har historisk ignoreret manifestets
ikoner helt. Et <head> rettet efter indlæsning er et <head>, der måske ikke
er det, iOS læste.

urds manifest bygges AF verdandes frem for at stå ved siden af det, så de
felter, de faktisk deler — farverne, visningstilstanden, sproget — kommer ét
sted fra og ikke kan drive fra hinanden. Seks nøgler ændres: id, start_url,
scope, de to navne og ikonerne. Et ikon i det byggede manifest, som urd
ikke har en fil til, stopper serveren frem for at gå igennem: symptomet
ville ellers være, at urds installationsprompt stille viste verdandes mærke.

`scope` skrives ud, selvom "/" er hvad specifikationen ville udlede af en
start_url på "/urd". En app, hvis scope ikke dækkede hele origin, ville åbne
sine egne links i en browserfane, første gang nogen navigerede ud af /urd —
og det er en regel, ingen skal behøve at huske, mens de læser koden.

## Ansigtet er en præference, ikke en port

`urdFace` holdes skarpt adskilt fra `notesOnly`, og det er den vigtigste
linje i ændringen:

  notesOnly — serveren HAR ingen opgaveruter. En vagt: hent dem ikke, de
              svarer 404.
  urdFace   — vinduet kom ind ad urd-døren. En præference: ruterne er der,
              dataene er personens egne, kun navnet er et andet.

Slås de sammen, begynder urd-ansigtet at skjule data frem for pynt — en
notesbog ville holde op med at vise sine opgaver, fordi nogen valgte en
anden indgang, og så er det den separate instans i forklædning. En prøve
påstår det modsatte direkte: urd-vinduet får stadig 200 på /api/v1/tasks.

Ansigtet ligger i sessionStorage og ikke localStorage, og det er dét, der
lader de to apps være to apps: de deler origin og dermed localStorage,
cookies og service worker. sessionStorage er per vindue, og en installeret
PWA er sit eget vindue. Det overlever en genindlæsning, hvilket det skal —
efter første navigation står adressen på /noter, og ingen går gennem /urd
igen.

## Undervejs

contentTypeFor slog op på det eksakte navn "manifest.webmanifest", så den
anden dørs manifest blev serveret som HTML i fire minutter. Intet ville have
meldt det: et manifest med forkert type ignoreres af browseren uden fejl, og
det eneste symptom er en installationsknap, der ikke dukker op. Læser nu
endelsen.

Og drift-kontrollen mod app.html dækkede kun udgavens to ankre, ikke dørens
fem — hvilket er grunden til, at dørens fikstur manglede to <link>-linjer,
den rigtige fil har. Fanget alene fordi applyRebrands nægter et anker, den
ikke kan finde. Kontrollen dækker nu begge sæt.

Plantet: et manifest uden `id` giver "the urd manifest has id = <nil>, want
/urd" — altså den værdi, hele designet hviler på.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-07 10:32:25 +02:00
kristianwindandClaude Opus 5 ff988d26bb feat(urd): mærket bliver ð, og frontend-bygget bliver deterministisk
## Mærket

Urds oplagte mærke var ᚢ uruz — bogstavet navnet begynder med i den ældre
futhark, præcis som Verdandes er ᚹ wunjo. Det blev tegnet sådan først.

Men uruz er allerede et af husets egne produkter, og dets egen
`scripts/gen-icons.ts` siger i sin kommentar at den "draws the Uruz rune (ᚢ)
in rune-gold" med "a tall left vertical, a shoulder across the top, and a
shorter" — altså samme glyf, samme konstruktion, kun en anden farve. To af
samme persons apps side om side på en hjemmeskærm, hvilket er nøjagtig det
sted mærket skal kunne skelnes.

ᚦ thurisaz blev forkastet et niveau nede af samme grund: en stav med en
trekant til højre, og det er ᚹ også. Ved 16px er de én form.

Så bogstavet frem for runen: ð, ethet i Urðr. Centreret ved at tælle malede
pixels frem for ud fra path-tallene, fordi en centreret kasse ikke er en
centreret glyf:

  verdande ᚹ   blæk x  53–108  midte 80.5  ·  y  18–131  midte 74.5
  urd      ð   blæk x  39–110  midte 74.5  ·  y  15–134  midte 74.5

Verdandes +5.5 i x er dens egen bevidste forskydning — en tung stav ved
siden af et let flag skal sidde til højre for midten for at se centreret ud.
ð er symmetrisk nok til at undvære den, og de to er enige i y.

Læsbarhed, målt: ð læses fra 32px og op. Ved 16px er det en klat, og det er
ᚹ også — en favicon er ikke det, der skiller to installerede apps i en dock.

`productRune` hedder nu `productMark`, fordi ð ikke er en rune.

## Determinisme

SvelteKits `kit.version.name` er som standard `Date.now()`. Millisekundet
landede i version.json, i index.html og i service-workeren, ændrede
indholdshashen på hver chunk der refererer dem, og dermed deres filnavne. To
bygninger af samme commit havde forskellige filer i sig.

Dockerfilen har i et år sagt at `-trimpath` og en tom buildid "keep the
output reproducible". Flagene gjorde deres arbejde; frontenden i binæren bar
et tidsstempel. Sætningen var falsk, og ingen læste den.

Fastgjort til VERSION, som udgivelsen allerede sender ind, og `dev` når
intet sætter den. Målt ved at bygge to gange og diffe træet, før og efter.
Prisen er at et lokalt `npm run build` ikke længere ligner en ny version for
service-workeren — hvilket kun betyder noget på en udrullet instans, hvor
udgivelsen sender det rigtige mærkat.

Det gør også CI's nye påstand mulig: de to images deler alle lag, så urd
bærer samme binær som verdande frem for en genbygning der tilfældigvis
matcher.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-07 10:10:30 +02:00
kristianwindandClaude Opus 5 8c3aa33f13 fix(udgaver): porten dækkede 29 ruter og skulle dække 63 — og fladen ringede stadig på dørene
Prøven, der skal holde udgaverne adskilt, stod grøn gennem to merges, mens
34 opgaveruter blev betjent af noter-udgaven. Den fejlede ikke: den
sammenligner routeren med en nedskreven liste, og listen var skrevet ud
fra de ruter, nogen havde tænkt på. Den er en DRIFT-kontrol, og den kan
ikke være en fuldstændighedskontrol. Det står nu i prøven selv.

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-07 02:03:40 +02:00
kristianwindandClaude Opus 5 6154e6b247 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]>
2026-10-07 01:11:25 +02:00
kristianwindandClaude Opus 5 3cee9bfb6e feat(udgaver): fladen følger udgaven, og søgningen holder opgaverne inde
Trin 2. Serveren kunne allerede lade være med at montere opgaveruterne;
fladen tilbød dem stadig, så en noter-instans havde links til sider, der
ikke fandtes.

--- hvordan fladen får det at vide ---

`/auth/setup` bærer nu `edition`. Dér og ikke på `/auth/me`: udgaven er en
egenskab ved instansen, ikke ved den, der er logget ind, og fladen skal
kende den, før nogen er. Det er i forvejen kaldet, der spørger "hvad er
det her for en instans", så den følger med uden en ekstra tur.

Butikken henter formen ved siden af kontoen og tier, hvis den fejler: en
401 fra `me` må ikke tage udgaven med sig — login-skærmen er også en flade
i en udgave. Afventet før `loading` slippes, så fladen aldrig tegnes, før
den ved hvilket program den er; tegnet først og rettet bagefter ville vise
opgaver i et sekund på en instans, der ikke har nogen.

`full` som udgangspunkt, fordi det er det, programmet altid har været.

--- hvad der forsvinder ---

Menuens opgaveindgange er udeladt, ikke gråtonet. Forsiden er noterne. En
gemt adresse til /upcoming, /kalender, /uddelegeret, /faerdige, /opgave,
/filter eller /etiket fører til noterne frem for en blindgyde — der er
ikke sket noget forkert, adressen hører bare til et andet program end det,
der kører her. Listen står ét sted i layoutet frem for som en vagt pr.
rute: listen ER beslutningen, og en vagt pr. fil er syv steder at glemme
den ottende.

--- et hul, prøven fandt ---

/search er monteret i begge udgaver, fordi noter og notesbøger skal kunne
findes — og den returnerede opgaver. Ikke teoretisk: en instans lavet om
fra en fuld har opgaverne liggende i databasen endnu, så ⌘K ville være den
ene dør, der stod åben ind til den halvdel, resten af programmet lige
havde lukket. Lukket på serveren, hvor det hører hjemme, og ⌘K spørger
også selv — en tom liste af den rigtige grund og en tom liste ved et
tilfælde ser ens ud.

Prøven har en kontrol: den fulde udgave SKAL finde opgaven, ellers beviser
nullet i noter-udgaven ingenting.

--- prøverne ---

Et nyt Playwright-projekt med sin egen server, sin egen port og sin egen
database. Ingen lånt session: den instans har aldrig set den konto, den
fulde udgaves opsætning lavede. En skakt, der kun er prøvet ved at kalde en
funktion, er prøvet på det, nogen mente — det her måler programmet, som en
bruger møder det.

Seks prøver, heriblandt forskellen på skjult og fraværende målt udefra:
/api/v1/tasks, /labels, /upcoming og /filters skal svare 404, og
/api/v1/notes skal svare 200 — ellers ville de fire bestå på en server,
der bare var væk.

`testIgnore` på chromium-projektet, fordi filen ellers også kørte dér, mod
den fulde udgave, hvor den fejler med beskeder der læser som om skakten
ikke virker. Den rigtige fejl stod i projektnavnet, ikke i påstanden.

Go-suiten grøn. chromium + notes-edition: 110 bestået, 0 fejlet.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-06 23:14:00 +02:00
kristianwindandClaude Opus 5 438095e252 fix(noter): ⌘B gør teksten fed i stedet for at folde sidebjælken
Meldt ind: "command+b viser og skjuler menuen - ikke bold".

Fladen tog ⌘B til sidebjælken, og begrundelsen stod i koden: "works while
typing as well — it moves nothing in the field". Det passer for et
`<input>`, hvor ⌘B ikke gør noget. I en contenteditable ER ⌘B fed tekst,
og `preventDefault()` stoppede browseren i at gøre den.

Sætningen var sand, da den blev skrevet. Så kom en rig editor til, og
begrundelsen holdt op med at passe uden at nogen rørte linjen — samme form
som flere af fælderne i den her kodebase.

Feltet, markøren står i, har førsteret til en tast, det selv bruger. Uden
for et skrivefelt folder genvejen stadig sidebjælken, og prøven måler
begge halvdele: en rettelse, der bare holdt op med at folde, ville være
halvt færdig.

Efterprøvet mod den gamle kode — ordet blev aldrig fedt.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-06 12:28:23 +02:00
kristianwindandClaude Opus 5 3132bba7e5 feat(data): hele kontoens opgaver kan hentes, og en indsat liste bliver til opgaver
To ønsker, meldt sammen.

--- eksport af det hele ---

Eksporten fandtes allerede — konto som JSON, noter som Markdown i en zip,
og pr. projekt CSV i Todoist-format og ICS. Hullet var, at opgaver kun
kunne hentes ÉT PROJEKT AD GANGEN. Med femten projekter er det femten
downloads for at flytte sine egne opgaver, hvilket er en eksport, der
teknisk set findes, og som ingen bruger.

`GET /export/projects.zip` er én CSV pr. projekt. En zip og ikke én bred
fil med en projektkolonne: Todoists egen eksport er en fil pr. projekt, så
det er den form, der importeres i den anden ende — og den form, man kan
hive ét projekt ud af uden at åbne et regneark. Samme begrundelse som
noteeksporten, der er en mappe med Markdown og ikke én lang fil.

`GET /export/tasks.ics` er alle daterede opgaver i én kalender. Ét fil og
ikke en zip, fordi en kalender er lavet til at rumme mange ting, og
projektet følger med hver opgave. Udaterede udelades — en VTODO uden dato
er en linje, de fleste læsere taber på gulvet.

Prøven ser I filerne frem for på statuskoden: en tom zip og en kalender
uden VTODO'er er begge 200 med en troværdig Content-Type, og det er
præcis den form, en fejl i en eksport har. To projekter, ikke ét, så en
løkke der kun eksporterer det sidste ikke består.

--- en indsat liste bliver til opgaver ---

Quick-add er ét felt på én linje, og browseren smider linjeskiftene væk
ved indsætning i et `<input>` — så en kopieret liste blev til én opgave
med punkterne mast sammen.

Nu læses den. Indrykning bærer niveauet, og bredden læses af teksten frem
for at være gættet til to eller fire: en liste fra Apple Noter rykker ind
med ét antal mellemrum, en fra et Markdown-dokument med et andet, og
begge skal give ét niveau. `- `, `*`, `1.` og `1)` fjernes som den
notation, de er. `- [x]` er derimod indhold: en halvt afkrydset liste,
der kommer ind som lutter uløste opgaver, har tabt det, man indsatte den
for.

Teksten røres ikke ud over mærket. Datoer, `#projekt` og `@etiket` læses
af quickadd på serveren, præcis som når linjen var skrevet i hånden — ét
sted at forstå en opgavelinje, ikke to. Derfor tager `/tasks/quick-add`
nu et `parent_id`: en indrykket linje skal parses som enhver anden.

En undertask lever i sin forælders projekt. Et `#projekt` på en indrykket
linje honoreres derfor ikke, og det siges højt som `unknown_project` frem
for at ordet bare forsvinder fra titlen.

Indsætter man i en opgaves undertask-felt, bliver linjerne undertasks
dér. Det er "enten opgaver eller undertasks" afgjort af hvor man
indsætter, uden at nogen skal vælge noget i en menu.

Listen vises, før den oprettes. Fyrre opgaver fra et fejlklik er ikke
noget, man skal opdage bagefter — og visningen er også svaret på "forstod
den indrykningen", stillet mens der stadig er en Fortryd-knap.

--- rettet undervejs ---

Rækkens undertask-tæller stod stille, når en undertask blev tilføjet fra
ruden: rækken sagde 1/2, mens ruden foran viste fire. Tallene bor på
forælderen og regnes ud ved hentning, og serveren sender ikke forælderen
igen, fordi et barn blev lavet. Det gjaldt også den gamle vej, ikke kun
den nye.

Go-suiten grøn. Fuld chromium-suite: 103 bestået, 0 fejlet.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-06 12:27:46 +02:00
kristianwindandClaude Opus 5 b497b76be4 fix(noter): monotype tager hele markeringen, og farvelægningen taber ingen linjer
Meldt ind: "kun den øverste sætning af det jeg markerede blev lavet om til
monotype". Det førte til en anden fejl, der var værre.

--- den meldte ---

Monotype går uden om execCommand, fordi formatBlock pakker et <p> ind i
<pre>'en i stedet for at lave den om. Den vej spurgte efter
`currentBlock()`, og det er ét blok — markørens. Så af en markering over ti
linjers terminaludskrift blev den første linje til kode og resten stod
tilbage som brødtekst.

`selectedBlocks()` læser hele markeringen, og de markerede blokke lægges
sammen til ÉN blok. Ikke én pr. linje: en udskrift, man indsætter, er ét
stykke kode, og tre blokke ved siden af hinanden ser næsten ens ud på
skærmen og bliver til tre hegn i filen.

Vejen ud af monotype tager nu også hele markeringen. `swapTag()` bytter
tagget uden at røre markøren — tekstknuderne flytter med over, så den range
browseren holder på stadig peger på noget i dokumentet, og formatBlock kan
klare resten bagefter.

--- den anden, som prøven fandt ---

`watchForTrouble` meldte "ReferenceError: codeText is not defined" fra et
rekursivt kald, jeg havde glemt at omdøbe. Ved siden af det lå en KOPI af
"hvad er teksten i en blok", inde i editoren, med præcis den <br>-fejl, der
blev rettet i #91: `textContent` på et <br> er den tomme streng.

Den kopi bruges af farvelægningen, som maler resultatet TILBAGE ind i
blokken. Så en kodeblok skrevet med shift+retur fik sine linjer skrevet
sammen i det øjeblik, man klikkede væk fra den — på skærmen, ikke kun i
filen, og uden at nogen havde rørt teksten. Målt mod den gamle kode:
"echo enecho to".

Kommentaren over kopien sagde ordret, at de to læsninger skulle være ét
sted, FORDI en uenighed ville ende i noten. Den var sand, da den blev
skrevet; kopien kom senere. `blockText` i richtext.js er nu det ene sted,
eksporteret og brugt begge steder.

Begge prøver er efterprøvet mod den gamle kode.

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

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-02 22:58:08 +02:00
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
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
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
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
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
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 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
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
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 ff0447317b feat(noter): et indsat billede kan ses helt og kopieres videre
I arket er et billede skaleret ned til spaltens bredde — et foto fra en telefon
er fire tusind pixels bredt — så det, man kigger på, er et miniaturebillede, der
ikke ser ud som et. Der var ingen vej fra det til billedet selv.

Et klik åbner det nu i fuld størrelse, med en knap, der kopierer det, og et link,
der åbner det i sit eget faneblad. Escape lukker og sætter markøren tilbage i
teksten — ellers står fokus på en knap, der ikke findes længere, og den næste
tast går ingen steder.

Klikket ligger i onEditorClick ved siden af notelinket, som er det andet, man kan
klikke på inde i et felt, man ellers skriver i. Billedet bliver stadig markeret
af klikket, og det skal det: det er sådan, det slettes.

To ting, der ikke er til at gætte:

Udklipsholderen tager kun PNG i de fleste browsere, og et foto fra en telefon er
en JPEG. Så et billede, der ikke allerede er PNG, tegnes om over et lærred —
ellers lykkes skrivningen enten ikke, eller den lykkes med noget, ingen kan
indsætte. Prøven indsætter derfor med vilje en JPEG; med en PNG var den kørt
forbi netop den kode.

Og løftet gives videre til ClipboardItem frem for at blive ventet af først.
Safari kræver, at clipboard.write kaldes i selve klikket, og en hentning
imellem gør klikket til fortid.

Ruden ligger uden for .wrap: editoren har sin egen rulning og sin egen
stablingskontekst, og et lag inde i den ville blive klippet af netop det ark, det
skal lægge sig over.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-14 21:54:48 +02:00
kristianwindandClaude Opus 5 1bbd36c79b fix(noter): en søgning i ⌘K efterlader de træffere, den fandt
Paletten viste de noter, der passede, i sin egen liste — og når man valgte en,
åbnede den noten på en side, hvis liste stod helt uændret. De andre træffere var
væk, og det eneste sted, de nogensinde havde været, var en popup, der lige var
lukket.

Feltet i selve notelisten har filtreret hele tiden, og det er prøvet to steder.
Men den søgning, man finder, er den store i toppen, og den er palettens. Så det
er ikke "søgning i noter virker ikke" — det er, at den søgning, man bruger, ikke
efterlod noget.

Adressen bærer nu søgningen. ⌘K sender `?note=…&q=…`, notesiden læser begge, og
listen ved siden af den åbne note er de andre træffere. Den skrives tilbage, når
feltet ændres, så adressen kan gemmes og deles — og frem for alt ikke lyver, når
feltet er tømt.

To effekter, der deler en markør, og hvorfor de læser den med `untrack`:

Den nederste sætter `settled` og kalder `replaceState`. Det vækker den øverste,
og på det tidspunkt har `$page.url` endnu ikke skiftet — så den læste den gamle
adresse, fandt en søgning, der ikke var enig med markøren, og skrev den tilbage
i feltet. Man tømte søgefeltet, og ordet kom igen. Hver effekt skal reagere på
sin egen kilde, adressen og feltet, og aldrig på den fælles markør.

Den fejl fandt jeg kun, fordi prøven fejlede på den sidste linje og
fejlbilledets tilgængelighedstræ stadig viste "Fyrreskov" i feltet. En prøve,
der kun havde tjekket adressen, havde været grøn.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-14 11:10:09 +02:00
kristianwindandClaude Opus 5 7f09b35db5 feat(projekter): en deling siger til, og der er en vej ud af den
Spørgsmålet var, om modtageren ikke burde godkende først. Svaret er nej — men
det, spørgsmålet fik øje på, var rigtigt, og værre end det lød.

Tre ting gjaldt samtidig: man blev tilføjet uden at blive spurgt, man fik ikke
besked, og man kunne ikke gå ud igen. En delt note gav en klokkebesked; et delt
projekt gav ingenting — det stod bare i sidebjælken næste gang, man kiggede,
uden at noget sagde hvornår eller fra hvem. Og handleRemoveMember lå bag
ejer-kravet, så kun ejeren kunne fjerne nogen, også en selv.

Hvert enkelt af de tre er til at leve med. Alle tre sammen gør en deling til
noget, der sker for en.

Ikke et godkendelsestrin: en invitation, der venter på et ja, betyder, at den,
der delte, tror det er gjort, mens arbejdet ligger stille — og noter deles
allerede uden. Det, der manglede, var besked og en udgang.

`project.shared` er ny. Klokkens follow() kunne allerede føre et project_id hen
til projektet, så beskeden bærer sit eget mål og krævede ingen særregel.

Ruten til at fjerne et medlem er flyttet ud af ejer-gruppen, og handleren skiller
nu de to ting ad: ejeren, der fjerner nogen, og den, der selv går. At pege på en
andens medlemskab uden at eje projektet svarer 404 og ikke 403 — "det må du
ikke" bekræfter, at medlemskabet findes. Ejeren kan stadig ikke gå fra sit eget
projekt; det ville være en overdragelse, og den findes ikke endnu.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-14 08:07:46 +02:00
kristianwindandClaude Opus 5 9b21b3eac5 feat(projekter): del et projekt med en, der allerede er her
Et projekt kunne kun deles ved at stave en e-mailadresse. Det er den forkerte
vej at spørge om en kollega, man kender navnet på og sidder i samme rum som —
og beslutningen om, hvorvidt en instans' konti må vises for hinanden, var
allerede taget: `UsersForSharing` har gjort det for noter hele tiden, med
begrundelsen skrevet ved siden af. Der er ingen åben oprettelse, så konti er en
lukket, inviteret mængde, og det er en adressebog frem for en udlevering.
Projekterne bruger nu den samme.

`GET /members` svarer med tre lister frem for én, så panelet åbner færdigt i ét
kald — samme form som notens delingspanel. `POST /invites` tager `user_id` ved
siden af `email`, og de to veje mødes ét sted: en adresse, der viser sig at høre
til en konto, ER den konto, og skal ikke gennem en oprettelsesside, den ikke kan
komme igennem.

De ventende invitationer kom med, fordi den nye liste skabte behovet for dem:
den, der er inviteret og ikke kommet, har ingen konto og står derfor hverken
blandt medlemmerne eller blandt dem, man kan vælge. Før betød det, at man ikke
kunne se sin egen invitation; nu ville det betyde, at man leder efter en person,
der mangler i listen, og inviterer igen. Det andet link virker lige så godt som
det første. De kan trækkes tilbage, for ellers er den ventende liste kun til at
se på.

Tre ting, der kostede en kørsel hver:

`OpenAPI is valid` fangede den nye DELETE-rute, før jeg selv gjorde.

To felter hed "Rolle" på den samme side. Det er ikke kun en prøve, der brokker
sig — en skærmlæser siger "Rolle" to gange uden at kunne skelne. De har nu hver
sin tilstand og hvert sit navn, og den gamle prøves `getByLabel('Rolle')` er
blevet `exact`, fordi den matchede som understreng.

Og e2e-prøven lånte først sin anden person fra prøven ovenfor. Det så jeg kun,
fordi jeg kørte den alene: basen tømmes for hver kørsel, så en prøve, der kun
består, når hele filen kører i rækkefølge, består ikke af sine egne grunde. Den
laver nu selv sin — og viser dermed skiftet: den samme person er først en
ventende adresse og bagefter et navn, der kan vælges.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-13 17:52:03 +02:00
kristianwindandClaude Opus 5 7e981354c2 feat(indstillinger): en indstilling kan findes ved at søge efter den
Indstillingerne er ti faner med syvogtredive afsnit, og den eneste vej til et
bestemt af dem var at huske, hvilken fane det lå under. ⌘K kunne opgaver,
projekter og noter — alt det, man selv har skrevet, og intet af det,
programmet består af.

Ét indeks, to steder det bruges: et felt øverst i indstillingerne og paletten.
To lister over de samme afsnit ville være to steder at glemme et nyt et.

Overskrifterne står ikke i indekset. Hver post peger på den nøgle, afsnittets
egen overskrift allerede bruger, så et afsnit, der bliver omdøbt, følger med af
sig selv, og søgningen kan begge sprog uden at kende nogen af dem.

`words` er derimod skrevet i indekset, og det er med vilje: det er søgeord,
ingen får at se. Det danske afsnit hedder "Tælling af installationer", men det
ord, man kender det ved, er "beacon" — og på samme måde 2fa, imap, webcal,
webhook. De er de samme på begge sprog, hvilket er hele grunden til, at de
virker som søgeord.

Ankrene er nye id'er på afsnittene. Tre af dem stødte sammen med et felt inde i
afsnittet — to ens id'er gør <label for> tvetydig og getElementById tilfældig —
og hedder derfor to-faktor, kalenderfeed og caldav-server.

Springet dertil kan ikke overlades til browseren: siden hentes af ruteren, og
afsnittet findes først, når dens data er hjemme. Der prøves i to sekunder og
gives så op. Blinket bagefter er ikke pynt — man er landet midt i en side med
otte afsnit, man ikke selv rullede hen til.

Prøverne læser filerne frem for at klikke: halvdelen af afsnittene vises kun
under en betingelse, så en prøve, der ledte efter dem i en browser, ville melde
fejl på det, der med rette var skjult.

Og palettens pladsholder sagde "Søg i opgaver og projekter". Den har kunnet
noter længe, og kan nu også indstillinger.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-12 09:56:00 +02:00
kristianwindandClaude Opus 5 61f8887319 fix(integrationer): en adresse kan læses helt, også på en telefon
Feed'et, mailadressen, krogen, curl-eksemplet, CalDAV-serveren og de to
redirect-URI'er lå i skrivebeskyttede <input>. Et input ombryder ikke — det
ruller indeni — så på en telefon stoppede adressen midt i kassen, og resten
fandtes kun ved at trække inde i feltet. Der var ikke engang et rullepanel at
se: feltet så ud som om det indeholdt det, der stod i det. Målt ved 390 px var
kassen 306 px og krogens adresse 634.

De er nu <output>. Det er stadig et felt, en <label for> kan pege på, så
navnene på felterne bliver ved med at høre sammen med dem, og det er tekst,
der ombryder. `user-select: all` beholder det ene, inputtet gjorde bedre: ét
tryk tager hele adressen.

Det samme gjaldt invitationslinket i en delt note, som vises netop når der
ikke er nogen post at sende det med — altså når det er det eneste, der findes.

Prøven nævner felterne ved navn frem for at forespørge på en klasse. Den, der
måler `.field output` og finder nul, består også den dag felterne er lavet om
til noget andet klippet.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-12 09:18:49 +02:00
kristianwindandClaude Opus 5 72637a153a fix(kalender): en abonnementsadresse skubber ikke siden ud over skærmen
Adressen på et kalenderabonnement blev vist i en punktliste uden noget, der
lod den ombryde. En URL er ét ord på et par hundrede tegn uden et mellemrum i,
så den kan ikke bryde af sig selv — og det bredeste element på en side
bestemmer sidens bredde. På en telefon betød det, at hele indholdsspalten fik
et vandret rullepanel, ikke bare den ene linje.

`overflow-wrap: anywhere` er det, resten af indstillingerne allerede bruger til
et token og en hemmelighed; listen her var den, der blev glemt. Samtidig får
den layout frem for at ligge som løse inline-spans: navn, adresse og knap
under hinanden, uden punkttegnets indrykning.

Prøven måler `.content` og ikke dokumentet. Skallen holder selv siden på
skærmens bredde, så en måling på documentElement består med fejlen i behold —
den blev skrevet først og bestod, hvilket er grunden til at kommentaren står
der.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-12 09:11:26 +02:00
kristianwindandClaude Opus 5 6cf184caa6 feat(plan): dagens plan står på I dag, og ikke kun i en besked
Planen var en notifikation og ikke andet. Klokken klippede den af efter
én linje — "2 forfalder i dag, 1 er sprun…" — og klikket førte ingen
steder hen, fordi en plan hverken er en note, en opgave eller et projekt.
Det eneste sted, den landede hel, var push-beskeden på telefonen.

Nu står den som et kort øverst på I dag, dér hvor man kigger, når man
starter dagen. Beskeden er påmindelsen; kortet er stedet, den bliver.

Teksten er gemt for dagen frem for regnet forfra, hver gang nogen kigger.
En model pr. sidevisning er dyr for noget, der ikke har ændret sig — og
vigtigere: en plan, der siger noget andet, når man kigger på den, end den
sagde, da den kom, er værre end begge dele hver for sig. Kortet siger
også, hvornår den blev lavet: en plan fra klokken syv er en anden slags
oplysning klokken fire end klokken otte.

En plan fra i går sendes ikke med. Et kort, der viser gårsdagens plan
under overskriften "i dag", er værre end et tomt kort.

"Forny planen" laver en ny, når dagen har flyttet sig, og sender ikke en
besked om noget, man står og kigger på — den slags lærer folk at ignorere
klokken. Og for den, der har morgenbeskeden slået fra, tilbyder kortet at
lave planen på stedet, så funktionen ikke kun findes for dem, der vil
vækkes.

Klokken fører nu til I dag, og planen fylder de tre linjer, den er, i
stedet for at blive klippet af.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-09 10:22:27 +02:00
kristianwindandClaude Opus 5 5eb689e4c2 feat(links): vælg hvilken browser et link åbner i
Ønsket var en liste over de installerede browsere. Den findes ikke: en
webside kan hverken se, hvilke browsere der er på maskinen — listen er
lukket land, fordi den er et fingeraftryk, der ville følge folk rundt på
nettet — eller bede systemet om at bruge en bestemt af dem.

Det eneste håndtag er de URL-skemaer, browserne selv registrerer:
firefox://open-url, googlechrome://, microsoft-edge:. Dem kan man prøve,
og man kan ikke spørge, om de findes. Så indstillingen er skrevet som
det, den er — et forsøg med et faldback — frem for som et løfte, der
ville holde på telefonen og ikke på en Mac.

Valget står i localStorage, ikke på kontoen. Samme grund som
sidebjælkens bredde: det er en egenskab ved maskinen, man sidder ved.
firefox:// virker på telefonen og gør ingenting på Mac'en, så et valg,
der fulgte kontoen, ville være forkert det ene af de to steder hver gang.

Ét sted i koden frem for ét pr. komponent. Links står i opgavetitler, i
beskrivelser, i noter og i indstillingerne, og en regel, der skulle
gentages hvert af de steder, ville mangle det ene sted, ingen tænkte på.
Kun udadgående links; programmets egne ruter går gennem SvelteKit som før.

To ting, målingen rettede undervejs:

Det skjulte iframe — den kendte opskrift — bliver blokeret af appens egen
Content-Security-Policy, som siger default-src 'self'. Uden e2e-prøven,
der fangede CSP-fejlen i konsollen, ville skemaet aldrig være blevet
forsøgt i produktion, og indstillingen ville have set ud til at virke,
fordi faldbacket åbnede linket alligevel. Nu sættes location i stedet:
et ukendt skema afbryder navigationen frem for at forlade siden.

Og faldbacket troede sig blokeret hver eneste gang. window.open med
noopener returnerer altid null — det står i specifikationen — så
kontrollen af, om fanen blev åbnet, faldt altid ud til "blokeret", og
den aktuelle fane blev navigeret væk i stedet. Håndtaget er det eneste,
der kan kende en blokeret fane fra en åbnet, så det bruges nu, og opener
ryddes bagefter.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-09 08:41:21 +02:00
kristianwindandClaude Opus 5 2210dc082a feat(opdatering): en ny version siger til, i stedet for at vente på at
blive fundet

Tjekket fandtes i forvejen, og svaret stod ét sted: nederst på
indstillingssiden for beskeder. Det er en oplysning, man kun får, hvis
man i forvejen havde mistanke om, at der var noget at se efter — og "der
er kommet en rettelse af noget, du er ramt af" er ikke noget, man gætter
sig til at kigge efter.

Nu går den i klokken, i push og på mærket på ikonet, som alt andet, der
er værd at vide. Og den fører hen, hvor knappen til at genstarte sidder,
frem for ud af programmet.

Én besked pr. version, ikke én pr. time. En påmindelse hver time om det
samme er den slags besked, folk slår fra — og så er den næste, der
betyder noget, også slået fra. Versionen, der er sagt til om, skrives
først, når beskederne er sendt: den anden vej rundt ville en fejl
midtvejs betyde, at halvdelen fik besked og resten aldrig gjorde.

Kun administratorer, af samme grund som versionsfeltet allerede er det:
en besked om, at serveren er bagud, er en besked om en andens arbejde,
hvis man ikke selv kan gøre noget ved den.

Og et tjek, operatøren har slået fra, bliver ikke til en besked ad
bagvejen. VERDANDE_UPDATE_CHECK styrer det hele, som før.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-08 16:55:53 +02:00
Kristian Wind 9fa47822c1 Merge pull request #69 from kristianwind/ai-dagens-plan-2
CI / OpenAPI is valid (push) Successful in 11s
CI / End-to-end (push) Successful in 10m57s
CI / Docker image builds (push) Successful in 2m19s
Release / Build and publish image (push) Failing after 7s
CI / Go (push) Failing after 15m3s
feat(ai): dagens plan, mails der giver mening, og svar på et spørgsmål
2026-09-08 15:15:48 +02:00
Kristian Wind 366bed3a15 Merge pull request #68 from kristianwind/ai-foreslaar-du-bestemmer
feat(ai): forslag, man siger ja til — indbakken og noternes opgaver
2026-09-08 15:06:21 +02:00
kristianwindandClaude Opus 5 8139003b35 feat(ai): dagens plan, mails der giver mening, og svar på et spørgsmål
De tre sidste af de fem. Alle tre samme sted hen: det, man ellers selv
skulle sidde og gøre klokken syv om morgenen.

Dagens plan. Én besked om morgenen om det, der forfalder i dag, og det,
der er sprunget over. Tallene tælles i basen og gives til modellen som
kendsgerninger, den ikke må regne om — en plan, der siger fire opgaver,
hvor der er tre, er værre end ingen plan. Er der ingen model, står
tallene alene, og det er stadig det, man havde brug for at vide. Er der
hverken noget, der forfalder, eller noget, der er sprunget over, kommer
der ingen besked: en morgen uden noget at gøre er en god morgen, og en
besked om ingenting lærer folk at lade være med at åbne beskeder.

Timen er personens egen — klokken syv er syv forskellige steder — og
jobbet kører hver time frem for på et klokkeslæt, så en instans, der sov,
ikke springer dagen over. Flytter man timen, ryger dagens mærke, så man
kan se virkningen i dag i stedet for i morgen.

Mails, der giver mening. Opgaven hed, hvad mailen hed: "Anders Jensen:
SV: SV: Vedr. levering uge 12". Nok til at genkende, ikke nok til at
gøre noget ved. Nu skriver modellen den handling, mailen beder om, og
lader være, hvis den ikke beder om nogen. Ét kald pr. gennemløb frem for
ét pr. mail — femogtyve mails er ellers femogtyve gange ventetid — og
emnet bliver stående i beskrivelsen, så en misforstået mail stadig kan
læses. Falder modellen ud, hedder opgaven afsender og emne som før: en
model, der er nede, må ikke kunne stoppe posten.

Og svar på et spørgsmål. "Hvad lovede jeg Anders i august?" — svaret står
spredt over tre noter, og ingen af dem indeholder ordet "lovede".
Søgningen kræver, at alle ord optræder, hvilket er rigtigt for et
søgefelt og forkert for et spørgsmål, så kandidaterne hentes ord for ord
og de noter, flere ord rammer, kommer øverst. Modellen får kun dem: den
slår ikke selv noget op, så et svar kan ikke handle om nogen andens
noter. Og svaret bærer, hvad det står på — en model, der siger "det
gjorde du den 14.", er kun brugbar, hvis man kan slå op i det, den læste
det i.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-08 15:05:36 +02:00
kristianwindandClaude Opus 5 96e1fa251b fix(beacon): beskeden skubber i stedet for at dække
Den lå som en rude i hjørnet, oven på arbejdet, og dækkede knapperne i
notens fod og i delepanelet — seks e2e-prøver klikkede forgæves på noget,
der lå under den.

Det var ikke kun et prøveproblem. En besked, der er i vejen, bliver
klikket væk uden at blive læst, og så er den uopfordrede oplysning en
formalitet frem for noget, nogen har set. Nu står den øverst i indholdet
og skubber siden ned, indtil den bliver besvaret.

Og e2e beviser den nu frem for at klikke den væk: den første konto skal
møde beskeden, og der skal stå et instance_id og en version i den.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-08 14:51:47 +02:00
kristianwindandClaude Opus 5 40102d6038 feat(ai): forslag, man siger ja til — indbakken og noternes opgaver
To steder, hvor arbejdet ligger og venter på at blive skrevet færdigt.

Indbakken er der, hvor ting lander, når man ikke havde tid til at skrive
dem ordentligt — og derfor også der, hvor de bliver liggende. At åbne
hver enkelt og sætte projekt, dato og prioritet er ti klik pr. linje. Nu
er det ét spørgsmål og et ja pr. linje.

Og et referat er fuldt af sætninger, der ligner opgaver, og af tre, der
er det. Modellen bliver bedt om kun at tage dem, nogen har forpligtet sig
på, og om at citere de ord, den tog dem fra — så et forkert punkt kan
gennemskues frem for bare at være forkert.

Modellen skriver ikke noget. Den foreslår en *linje* i quick-add-syntaks,
og forslaget bliver til noget, når nogen siger ja. Det er ikke
forsigtighed for forsigtighedens skyld: en model, der retter i folks
opgaver direkte, skal have ret hver gang for at være til gavn, hvor en,
der foreslår, skal have ret tit nok til at spare et par tastetryk.

Linjen er quick-add-syntaks frem for et felt pr. egenskab, fordi parseren
findes. Der er én fortolkning af "i morgen" i dette program, og forslaget
går gennem den samme som alt andet — også når linjen er blevet rettet i
hånden, inden den blev accepteret. Og et forslag, skrevet i det sprog man
selv skriver i, kan læses og rettes af den, det bliver stillet til.

Modellen får listen over de projekter, der findes, og må ikke nævne
andre. Et `#Nyt projekt`, den fandt på, ville lave et projekt ved et
uheld, første gang nogen sagde ja — og et gæt må ikke kunne flytte en
opgave hen, hvor ejeren ikke leder efter den. Testet.

Forslagene til indbakken bærer et nummer tilbage til den opgave, de
handler om. Rækkefølgen alene ville også kunne bruges og gør det som
reserve, men en model, der springer en opgave over, ville så flytte alle
de følgende forslag én plads — og "sæt en dato på den her" ville lande på
en anden.

Noten bliver ikke rørt, når der laves opgaver ud af den. Det er
fristende at skrive linket ind i den, så de to peger på hinanden, men en
tekst, et program skriver i uden at blive bedt om det, er en tekst, man
holder op med at stole på. Opgaven bærer i stedet, hvor den kom fra.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-08 14:48:56 +02:00
kristianwindandClaude Opus 5 0cf5c8c281 feat(beacon): sig det uopfordret, og lad være med at fejle i tavshed
Beaconet er slået til som udgangspunkt. Det kan kun forsvares, hvis det
bliver sagt af sig selv — og det blev det ikke. Det stod rigtigt og
fyldestgørende under Indstillinger → Data, som er en side, der bliver
åbnet af dem, der i forvejen har en mistanke om, at der er noget at læse.
For alle andre var "du kan slå den fra" et tilbud til nogen, der ikke
vidste, der var noget at slå fra.

Nu møder en administrator beskeden første gang: hvad der bliver sendt,
med de rigtige værdier i — det rigtige id, den rigtige version, den
rigtige adresse — og to knapper. Værdierne står der frem for ordet
"anonymt", fordi et løfte om telemetri er præcis så meget værd som
læserens mulighed for at efterprøve det.

`keep` er en peger og ikke en bool. En tom krop måtte ikke kunne betyde
"slå det fra": et fravalg, ingen har truffet, er lige så meget et brudt
løfte som et tilvalg, ingen har truffet.

Og tre ting mere, som Yggdrasil har lært på den hårde måde:

Et beacon, der ikke kan nå sin collector, fejlede i fuldstændig tavshed.
Det prøvede igen i morgen, siden sagde "sidst sendt" med en dato, der
bare blev ældre, og ingen opdagede det. Grunden bliver nu skrevet ned og
vist.

En installation, der opdaterer sig selv om natten, meldte gårsdagens
version i op til et døgn, fordi døgnet blev talt fra sidste vellykkede
melding. En version, der er skiftet, sender nu med det samme.

Og det offentliggjorte tal har fået et gulv. Et tal, der falder, kan ikke
offentliggøres som en kendsgerning — en dårlig udrulning, en
DNS-ændring, der stille stopper meldingerne, og rækker, der er væk, ser
alle sammen ud som "færre bruger det nu".

Adressen bliver stående: Verdande melder til verdande.yggdrasilpanel.com,
ikke til panelets egen collector. De to tæller hver sin slags
installation, og en fælles tabel har intet felt til at holde dem ude fra
hinanden.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-08 14:39:14 +02:00
Kristian Wind 445d9de57a Merge pull request #66 from kristianwind/indbakken-har-en-adresse-apps-kan-skubbe-til
CI / OpenAPI is valid (push) Successful in 11s
CI / End-to-end (push) Successful in 11m3s
CI / Docker image builds (push) Successful in 2m17s
Release / Build and publish image (push) Failing after 9s
CI / Go (push) Failing after 15m6s
feat(indbakke): en adresse, andre programmer kan skubbe en opgave til
2026-09-08 14:12:17 +02:00
Kristian Wind d5ad459eba Merge pull request #65 from kristianwind/maerket-paa-ikonet
feat(beskeder): appens ikon bærer tallet på ulæste
2026-09-08 14:04:12 +02:00
kristianwindandClaude Opus 5 d063425cae feat(indbakke): en adresse, andre programmer kan skubbe en opgave til
Der var to veje ind i forvejen, og ingen af dem passer til "gem den her,
mens jeg står med telefonen i hånden". API-tokenet giver adgang til alt
og skal have en JSON-krop skrevet rigtigt; mailadressen virker først, når
nogen har sat en mailserver op, og går gennem en indbakke, der kan være
minutter om det. Det, der manglede, var en URL, man kan smide en linje
tekst efter — fra en genvej på telefonen, et script, en tjeneste, der kan
kalde en URL.

Kroppen læses på tre måder, fordi de tre er dem, man møder: JSON fra en
tjeneste, et formularfelt fra en webhook-formular, og ren tekst fra en
genvej, der bare sender det, man har markeret. Første linje bliver til
opgaven, resten til noten under den. Linjen læses med quick-add-parseren,
så "Ring til Anders i morgen p1 #Firma" betyder det samme her som i
feltet øverst i fladen — dét er hele pointen med at have én parser: den
vej, teksten kom ind ad, ændrer ikke, hvad den betyder.

Sit eget token frem for at låne mailens. De to er den samme slags
hemmelighed, men de slipper ud hver for sig: en URL, der har ligget i en
genvej på en telefon, der blev væk, skal kunne skiftes uden at
mailadressen — som står i andres adressebøger — skifter med.

Og ikke et API-token. Et token herfra kan én ting: lave en opgave i den
persons indbakke. Det er hele forskellen på en hemmelighed, man kan lægge
i et script på en anden maskine, og en, man ikke kan.

Kun POST. En GET ville være nemmere at kalde, og det er præcis
problemet: forudindlæsning i en browser, en linkkontrol i en chat og en
historik, der bliver besøgt igen, ville alle lave opgaver, ingen har
bedt om. Sin egen kvote frem for postens, så en travl integration ikke
kan lukke for den indgående post.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-08 14:03:32 +02:00
kristianwindandClaude Opus 5 b041b32aa2 feat(beskeder): appens ikon bærer tallet på ulæste
Klokken i hovedet siger, hvad der er nyt — men kun til den, der kigger på
fanen. Det halve af pointen med at få besked er at få den, mens man laver
noget andet, og det eneste sted, appen kan sige noget dér, er på sit eget
ikon: mærket i Dock'en på en Mac, på proceslinjen på en pc.

Sat to steder, fordi der er to tilstande. Appen selv, mens den er åben og
kender tallet, og service-workeren, når den ikke er — og det er netop
dér, mærket er det, der siger, at der er sket noget.

Tallet kommer med i push-beskeden frem for at blive talt op af
service-workeren. Den husker ingenting mellem to opvågninger og kunne
derfor kun lægge én til noget, den ikke ved hvad er: et mærke, der tæller
sig selv op og aldrig kommer ned igen. Serveren ved det i forvejen, og en
tælling, der fejler, udelader feltet frem for at sende et nul — så står
mærket, som det stod, i stedet for at blive tørret af en optælling, der
ikke lykkedes.

Ét tal, aldrig en optælling: mærket sættes til hvad der *er*, så det går
ned af sig selv, når beskederne bliver læst. Og det ryddes ved log ud —
det står i Dock'en, også når ingen er logget ind.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-08 12:12:54 +02:00
kristianwindandClaude Opus 5 d8f5f6e236 feat(noter): nævn en person i en note, og den er delt med dem
At skrive nogens navn i en note *er* at ville have dem til at læse den.
Indtil nu var det to handlinger — skriv navnet, åbn panelet, find
personen, del — og den anden blev glemt, så noten stod med en besked til
en, der ikke kunne se den.

Nu deler `@Navn` noten med den, der bliver nævnt, og siger det til dem.
Editoren foreslår personer efter `@`, ligesom `#` foreslår projekter.

Slået op i navnene frem for tolket ud af teksten. Et navn kan have
mellemrum i sig, og et udtryk, der skulle fange dem alle, ville også
fange sætningen bagefter: `@Sofie i morgen` ville blive til en person,
der hed noget i den retning. Her er der ikke noget at gætte — der står et
navn, eller der gør ikke. Det er også dét, der gør det forsvarligt at
dele på: adgang må ikke afhænge af, hvor langt et regulært udtryk valgte
at læse. En mailadresse er ikke en omtale, det længste navn vinder, og
ejefaldets s hører til sætningen.

Kun nye omtaler, sammenlignet med teksten før gemningen på samme måde som
links: en note gemmes ved hver tastepause, så uden sammenligningen var
hver gemning en ny deling og en ny besked om det samme navn.

Kun ejerens omtaler deler. En medredaktør må skrive i noten, men ikke
give den videre — ellers kunne den, ejeren har delt med, skrive et navn
og dermed dele ejerens note med en, ejeren aldrig har hørt om. Det er
den samme regel, panelet følger.

Læser, ikke medredaktør: en omtale siger "se på det her", og det mindste,
der opfylder sætningen, er det rigtige at give. En rolle, ejeren har
hævet i panelet, sænkes ikke igen af en omtale.

Og én besked pr. handling: den, der lige er blevet nævnt, får "nævnte
dig" og ikke også "rettede en note" om den samme gemning.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-08 12:08:12 +02:00
kristianwindandClaude Opus 5 8ee9fbedfb feat(noter): del en note med en, der ikke har en konto endnu
En note kunne deles med dem, der allerede var på instansen — og kun med
dem. Skulle en anden læse med, skulle man først invitere dem ind, vente
på at de oprettede sig, finde noten frem og så dele den. Fire skridt og
en ventetid for det, der er én sætning: lad Sofie se den her.

Nu er der ét felt. Man skriver adressen, og serveren ved, om der er en
konto bag: er der det, bliver det en deling med det samme, og ellers en
invitation, der bliver til delingen i samme øjeblik, kontoen oprettes.
Hvem der har en konto, er ikke noget, man skal vide for at dele en note.

Rækken er den, der allerede fandtes. En invitation bar i forvejen en
e-mail, en rolle og et forseglet token; det eneste, der manglede, var at
kunne sige, hvad den var en invitation *til*. note_id ved siden af
project_id, med fremmednøglen — dén, der rydder invitationen væk, når
noten slettes, inden nogen når at tage imod en invitation til noget, der
ikke er der længere.

Det er ikke ny magt til nogen: enhver, der ejer et projekt, kunne i
forvejen invitere en fremmed ind på instansen pr. e-mail. Kun ejeren kan
invitere til en note, som kun ejeren kan dele den.

De inviterede står i panelet ved siden af dem, der er kommet, og kan
trækkes tilbage. Uden det er en invitation sendt i går usynlig, og så
sender man den igen — endnu et link til den samme indbakke, og kun det
ene af dem til at gøre ugyldigt.

Og delingen siger selv til: den, der får en note delt, får besked om, at
den er der. Klokken sorterer nu på id efter tid, fordi tiden er sekunder
— en note, der bliver delt og rettet i samme håndbevægelse, stod ellers
i den rækkefølge, basen nu engang fandt dem i.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-08 11:58:47 +02:00