98 Commits
Author SHA1 Message Date
kristianwindandClaude Opus 5 fb8abcaa74 fix(docs): nullable er ikke 3.1, og eksemplet hørte til nabofeltet
To fejl i samme blok, begge mine.

`nullable: true` er OpenAPI 3.0. Specen er 3.1, hvor det blev fjernet til
fordel for en type-union, og den havde allerede fortilfældet stående:
`type: [integer, "null"]`. Redocly afviste de to steder som struktur-fejl.

Og min lokale kontrol stillede et svagere spørgsmål end CI's: jeg læste
filen med `yaml.safe_load` og fik "gyldig" tilbage. Den svarede på, om det
er YAML — ikke om det er en gyldig OpenAPI. CI's egen validator kører nu
lokalt, før den kører der.

Den anden fejl fandt kun en ADVARSEL: `example: ["labels"]` tilhørte
sidebar_collapsed, og min indsættelse landede før den, så en strengliste
hang på et heltalsfelt. Ankeret dækkede ikke hele blokken, jeg skød ind
efter. CI fejler kun på fejl, så den var sluppet igennem — på to felter ad
gangen, for sidebar_collapsed stod samtidig uden sit eget eksempel.

Advarsler: 253, som før ændringen. Specen er gyldig.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-08 19:34:05 +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 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 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 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
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 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 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 70d7398d5a docs: skriv det ned, der er blevet bygget
API-dokumentationen har fulgt med hele vejen — der er en prøve, der fejler,
hvis en rute ikke står i openapi.yaml, og den fangede mig fire gange i går.
Brugervejledningen har ikke fulgt med, og det er den, folk læser.

Nyt: en side om beskeder — hvad der udløser en, hvorfor gentagne
rettelser bliver slået sammen, push pr. apparat og mærket på ikonet. En
om at åbne links i en anden browser, som mest af alt handler om, hvad en
webside ikke kan: hverken se hvilke browsere der findes eller vælge
mellem dem. Og en om tællingen af installationer, som ikke stod nogen
steder overhovedet — en melding, der er slået til som udgangspunkt, har
en pligt til at være skrevet ned, også når ingen har spurgt.

Rettet: AI-siden beskrev to funktioner, hvor der nu er syv, og
listen over hvad der sendes hvorhen var derfor forkert som en påstand om
noget privat. Noter-siden manglede deling med en e-mailadresse og hvad
`@Sofie` gør. Mail-siden manglede den anden vej ind i indbakken.
Konfigurationen sagde stadig, at versionstjekket kun kunne se efter.

Og tagbeskeden når nu frem. Den ER annonceringen — skrevet på produktets
eget sprog til dem, der bruger det — og udgivelsen på GitHub blev fyldt
med en autogenereret liste over commit-overskrifter i stedet, som er
skrevet til dem, der læser koden. Nu står teksten øverst og listen under
den. Linjen med linket til udgivelsen selv tages af: en side, der linker
til sig selv, hjælper ingen.

Dokumentationen bygges nu også på en PR. `mkdocs build --strict` fejler
på et link til en side, der ikke findes, og at bygge først efter merge er
at få det at vide på main.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-09 08:52:54 +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 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
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 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
kristianwindandClaude Opus 5 ab77024ec9 feat(beskeder): klokken siger til, når nogen rører noget, der er dit
En delt note blev rettet af en anden, og man opdagede det kun ved at åbne
den. En opgave blev givet til en, og den lå bare der. Det, andre gør ved
det, man deler, skal man kunne se uden at lede efter det.

Klokken i hovedet: ulæste beskeder om noter, der er blevet rettet, delt
med en, eller opgaver, man har fået. Sætningen skrives i browseren, ikke
på serveren, så et engelsk interface får en engelsk klokke.

Beskeder om den samme note bliver slået sammen, mens de er ulæste. En
note gemmes på en tastepause, så en række pr. gem ville lave én person,
der skriver i fem minutter, om til tyve ens linjer. Er beskeden læst, er
den næste rettelse en ny ting at få at vide — ikke en gentagelse.

note_id får sin egen søjle ved siden af project_id og task_id frem for et
generisk (kind, id)-par: de tre er ikke det samme slags nul, og
fremmednøglen — dén, der rydder beskeden op, når noten slettes — kan et
generisk par ikke have.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-08 11:40:43 +02:00
kristianwindandClaude Opus 5 916a0c0224 feat(noter): en delt note tager de noter med, den peger på
Den anden halvdel af den melding, der begyndte det her: linkene i en delt note
førte ingen steder hen, fordi det, de pegede på, ikke var delt. At dele en note
er at dele det, den *siger*, og et link er en del af det.

note_shares får en `linked`-søjle. 0 er en deling, nogen har valgt; 1 er en, der
er udledt af et link. Skellet er hele modellen. Fladen kan sige *hvorfor* en
person har en note — "fulgte med" er en anden sætning end "du delte den" — og de
udledte rækker kan regnes forfra efter hver ændring, uden at røre dem, ejeren
selv har sat.

SyncLinkedShares regner billedet om fra bunden efter enhver af ejerens egne
handlinger: en deling, en fjernet deling, en gemning der har ændret links eller
titel, en sletning. Hele kæden følger med, ikke kun første led, med et loft på
hundrede noter — "del denne ene note" må ikke kunne blive til fire hundrede.
Rollen arves fra den note, man kom fra, og stærkest vinder, når en note kan nås
ad to veje.

Kun ejerens egne noter følger med, og det er ikke en optimering: et link peger
på en titel, og en medredaktør må skrive titler. Kunne et link hente hvad som
helst, kunne den, man har delt med, skrive [[Bankoplysninger]] og læse med.
Derfor slås der kun op blandt ejerens egne noter, og derfor regner kun ejerens
egne gemninger om. Begge dele har en Go-prøve.

Det lukker ikke hullet helt: skriver medredaktøren linket, og gemmer ejeren
bagefter sin egen note, følger den note med dér. Det, der står imod, er
synlighed — linket står som tekst i ejerens egen note, og panelet viser hver
note, der er fulgt med, og hvem der har den.

Udregningen åbner hver af ejerens noter, fordi titlerne er forseglet. Derfor to
værn: har man ikke delt noget, svares der med det samme ud fra ét EXISTS, og en
gemning regner kun om, når links eller titel faktisk har flyttet sig. Uden det
første kostede "Ny note" halvfems millisekunder, og i det hul nåede e2e-prøven
for monospace at taste sin titel ind i den forrige note, som editoren så tegnede
væk.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_013r4DxpUZcSShxfCZZaeMGZ
2026-09-07 22:28:58 +02:00
kristianwindandClaude Opus 5 094ea5cdd8 feat(upcoming): look up to 30 days ahead, and choose how far
Kommendes liste has always been the next seven days and nothing else. Seven days
is the right walk to take in the morning, and it is still what the view opens on
— but "when does this month get busy" is a different question, and the month grid
answers it with shape rather than in order. So the list now takes a horizon: 7,
14 or 30 days, chosen with a switcher beside the view buttons.

Only the list gets one. A week grid is a week and a month grid is a month; there
is no fortnight for them to show, so the switcher is hidden in those modes.

The choice lives in localStorage next to the mode, under its own key. It is the
same kind of choice as list-or-grid — how you want to look, not a fact about the
work — and it should survive a reload without following you to a phone where
thirty day headings is a very long scroll.

Once the strip is longer than a week the day headings carry the week number.
Thirty days repeat every weekday four times, and "mandag 6. okt" alone does not
say which mandag.

Server side the endpoint already took `?days=` and capped it at 31, so the window
needed nothing — but its row limit was a constant 1000 written for a week. A
month asked for with a week's ceiling would truncate the last days silently, and
a day cut off the end of the answer looks exactly like a day with nothing due, so
the limit now follows the window.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01SA1TDmgQZw3AufJqkbwezx
2026-09-07 14:46:49 +02:00
kristianwindandClaude Opus 4.8 9d390d021f docs(site): show all six themes, and put the demo line in English
Adds a gallery of the same Today view in each of the six themes — Northern lights,
Charcoal, Dusk, Daylight, Paper, Contrast — so the range is shown rather than
described, on the landing page and (as the swatch panel) the docs home. The lone
Charcoal Today capture is folded into it.

And the quick-add demo that leads the docs — "betal moms i morgen kl 10 p1 #Firma
@regnskab" — is now English, "file the VAT return tomorrow at 10am p1 #Accounts
@tax", on every page it appears as the line to type: the home, the quick-add page,
the API and MCP examples, and the OpenAPI blurb. The Danish date words on the
quick-add page stay, because documenting the Danish parser is their job.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-04 09:46:50 +02:00
kristianwindandClaude Opus 4.8 1612f53966 docs(site): the Charcoal theme, offline, and the star that comes off
Refreshes the whole screenshot set to the app as it ships now — the three-line
note rows, the lightened calendar — and adds two Charcoal captures, the theme
modelled on Apple Notes, since it is the one worth showing. The landing page and
the docs home both gain it, a "holds through a short network drop" line, and the
correction that a starred or flagged mail now loses its mark once it is a task
rather than keeping it. Six themes now, not five.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-04 09:20:11 +02:00
kristianwindandClaude Opus 4.8 84ca506961 feat(gmail): take the star off a mail once it has become a task
A starred Gmail message became a task and kept its star, so a starred inbox only
grew — the IMAP side has always unflagged what it imports, and Gmail could not,
because the scope was gmail.readonly. This gives Gmail the same one write: after
the task is made, the STARRED label comes off the message, in one batchModify for
the whole sweep. Nothing else is touched — not the body, not other labels, and a
label trigger's label stays where it is, since that label is the person's own.

The scope goes from gmail.readonly to gmail.modify, which is the narrowest scope
Google offers that can remove a label. An existing connection keeps working on its
old read-only grant — the sync still reads, the unstar is a best-effort 403 that is
logged, not fatal — and starts clearing stars once the account is reconnected and
the new grant is in place. It self-heals: a mail that could not be unstarred stays
starred, stays in the next query, and is tried again.

The consent screen now asks for more than it used to; the settings copy, the docs
and the scope table all say modify rather than read-only in the same change.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-04 08:57:07 +02:00
kristianwindandClaude Opus 4.8 55b8e438ad fix(rows): open on the first tap on iPhone, and round the hover highlight
On a touch screen a :hover on the tap target makes iOS spend the first tap
applying the hover and only open on the second — the double-tap Kristian hit
on tasks (and notes). The row hover is gated behind @media (hover: hover)
now, so a touch has no hover to apply and the first tap opens. The note
row's action buttons, which the hover used to reveal, are shown outright on
touch instead. And the task-row hover highlight is a rounded box (border
radius on the row), the same corner as a note in the list.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-03 22:15:23 +02:00
kristianwindandClaude Opus 4.8 05b7dac713 feat(notes): three-line rows like Apple Notes, with a filed line and dividers
Each note is three lines now: the title, then the date and the excerpt,
then where the note is filed — a folder like the menu's groups and the
project's name, the owner's when it is someone else's, or simply "Noter"
when it belongs to no project. The date leads the second line, left
aligned, instead of being pushed to the right of the title. A faint
hairline separates one note from the next, and a month heading is
capitalised ("August 2026", not "august 2026").

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-03 14:52:56 +02:00
kristianwindandClaude Opus 4.8 4ac7180cda feat(notes): drop the disclosure arrow from the note-list headers too
Same as the sidebar: the box folds on a click, the star and people icon
are the marks that mean something, and a date group keeps an empty spacer
so every label still starts at the same place.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-03 14:25:55 +02:00
kristianwindandClaude Opus 4.8 e1683ef09d feat(notes): match the note-list headers to the new heading design
The note list still wore the old faint-uppercase group header beside the
sidebar's bold plain-case one, so the two columns read as two apps. The
group headers are bold, plainly cased and in the reading ink now, at
menu size, and their separator boxes carry the same rounded corner as an
active menu item — the hard rule underneath is gone, the box is the
separation. The disclosure mark stays: unlike a sidebar section, a
time-group has no icon, so it is the only thing that says "this folds".

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-03 14:22:19 +02:00
kristianwindandClaude Opus 4.8 ec147cd1ba feat(sidebar): drop the fold arrows from headers and groups
The chevron beside every section and group was saying "clickable" a
second time next to an icon and a bold name that already said it. Gone
now: a system section folds from anywhere on its heading, and a group
folds from its folder icon while the name still opens the group's page.
No arrow on the rail.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-03 13:34:33 +02:00
kristianwindandClaude Opus 4.8 6cf63113df feat(calendar): lighter grid, per-calendar pastels, today marked at top
The calendar was a grey grid where every event was the same sunken block
edged in a colour you had to already know. Now the day itself is the
brightest surface and each event is a soft, dusty wash of its own
calendar's colour — so a screen of them reads as colour laid on white,
and two calendars are told apart at a glance. Tasks stay outlined cards,
the thing you can pick up, against the solid washes you cannot.

Today is marked where the day is named: its number sits in a filled disc
at the top of the column, the same one the month grid gives it, over a
faint wash down the whole column.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-03 13:09:07 +02:00
kristianwindandClaude Opus 4.8 a9c90ee6f1 feat(ui): unify section and group headers with icons
System sections (Projects, Filters, Labels) and user groups (Work,
Home) now share one design: a bold label at menu size, a leading
folder/funnel/tag/people icon, and an always-visible fold chevron.
Groups drop their colour dot for the same folder icon, so the sidebar
reads as one consistent hierarchy instead of two visual languages.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-03 12:34:07 +02:00
kristianwindandClaude Opus 4.8 2f00246d6f feat(ui): colour the fixed-view icons, lift the notes surfaces, fix the create header
Following the Things pass, four bits of polish:

- The fixed views carry their own colour — amber star for Today, blue tray
  for the Inbox, red Upcoming, violet Waiting on others, teal Notes, indigo
  Calendar — and keep it whether or not they are the active row.
- The active row was all but invisible on the dark sidebar (a half-step of
  surface); it is now an accent-tinted selection that reads in both themes.
- The Projekter heading — the only place to add a project or a group — no
  longer scrolls away with a long list; it is pinned to the top of the
  scroller.
- The note list and the editor lift off the page: white in light, a nuance
  lighter than the ground in dark, so the two read as one writing surface.

Screenshots regenerated.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-03 11:47:17 +02:00
kristianwindandClaude Opus 4.8 cf69cac410 feat(sidebar): icons for the fixed views, bold areas, one straight edge
Toward Things' calm: the fixed views carry icons instead of dots — a star
for Today, a calendar for Upcoming, a person for Waiting on others, a tray
for the Inbox. Areas (the user's groups) are set in bold, plainly cased, so
the eye lands on them the way it lands on an area in Things; the system
sections (Projekter, Filtre, Etiketter) stay quiet. And projects sit at the
same left margin whether or not they are in an area — one straight edge down
the sidebar rather than a staircase, with the bold heading doing the
grouping the indent used to.

Screenshots regenerated to match.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-03 10:02:53 +02:00
kristianwindandClaude Opus 4.8 e94d731c30 docs(site): lead with the Rune, and drop the Todoist comparison from filters
Yggdrasil is self-hosting too, so labelling only the Docker page
"Self-hosting" was misleading and put it above the Rune it was built for.
Reorder "As a Rune" above the Docker page, rename that page "With Docker",
and make the landing hero's primary call to action the Rune, with Docker
beside it.

Also drop the "the grammar is Todoist's" line from the filters page — the
factual import/export interoperability keeps naming Todoist, but the
positioning and soft comparisons do not.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-27 14:05:18 +02:00
kristianwindandClaude Opus 4.8 0aa0ec2989 docs: stop describing verdande as "Todoist Pro"
Claiming to be Todoist Pro is both inaccurate and an invitation to a
trademark complaint. Describe verdande on its own terms — a fast,
self-hosted task manager — and keep the Todoist references to what they
factually are: import/export interop, and a filter grammar close enough
that people coming from there already know it.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-27 13:59:54 +02:00
kristianwindandClaude Opus 4.8 7066e2e06e docs(site): new features, refreshed screenshots and text
The landing page and the docs now show and describe what has landed since
the last site update:

- the delegated-task marker (an arrow to whoever has it) — visible now in
  the board, list and Waiting-on-others shots
- snooze — one seeded task is parked so the board shows a card greyed at
  the foot of its column
- Web Push reminders to iPhone and Mac, dated tasks in a subscribed
  calendar, task durations on the calendar, and per-person note sharing

Screenshots regenerated against the current UI (PNG for docs/README, WebP
for the landing page, from the one capture). "Lately" brought current.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-27 13:00:05 +02:00
kristianwindandClaude Opus 5 9e3f20b050 feat(tasks): snooze — park a task at the bottom until a time
Snooze puts a task out of the way without finishing it or filing it: it sinks to
the bottom of its list and greys, and comes back on its own when its time passes.
No job wakes it — "snoozed" is just snoozed_until being still in the future, read
against the clock each time the list is ordered.

- `snoozed_until` on tasks (migration 0031); the list order sinks a still-snoozed
  task below everything, and it rejoins its place the moment the time passes.
- POST /tasks/{id}/snooze {until} — an RFC3339 time to snooze to, or empty to wake.
  Nothing else about the task moves; a snooze is about when you want to see it, not
  when it is due.
- The task drawer gains quick picks (this evening / tomorrow / next week) and a
  custom time, and a Wake now once it is snoozed. The row dims and carries a
  "snoozed until X" mark, and the list keeps snoozed tasks at the foot — the client
  sorts the same way the server does, so a drag-sorted list does not jump on reload.

Tests: snooze sinks a task below one made after it and waking returns it (Go); and
from the drawer the row greys, shows the mark, and un-greys on wake (e2e). Full
suites green.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-25 10:59:34 +02:00
kristianwindandClaude Opus 5 fcedab0b19 docs(site): add the new features and light screenshots of them
The landing page did not mention two things the app grew — sharing a note with a
person, and task durations — and its screenshots were all in the dark theme.

- Copy: a cell for sharing a note or a page (to a person, or by filing it in a
  project, with the Shared with me group), and one for how long a task takes (drawn
  to length on the calendar, dragged by its foot, kept when moved).

- Two new screenshots in the light theme, at the top of the gallery: the calendar
  drawing timed tasks to their real length, and a note somebody shared gathered
  under Shared with me. English throughout, like the rest of the site — the capture
  account is English, so the interface is.

The generator grew the demo data these need: a handful of timed tasks with lengths
across the week, and a note the colleague shares back to the owner. Every other
screenshot was recaptured from the current build in the same pass, so the whole set
reflects what ships today (the tidied note footer, and the rest).

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-24 10:30:00 +02:00
kristianwindandClaude Opus 5 91671a4f97 feat(notes): share a note with a person, not only through a project
Until now a note was shared exactly one way: file it in a shared project, and
everyone who could read the project could read the note. That is right when the
note belongs with a body of work and wrong when it is just "let Sofie see this" —
it made you stand up a project to hand over a single page.

A note can now be shared with named people directly, at viewer or editor — the
same two roles a project membership uses, so there is one idea of a role in the
program and not two. The paths stack: a note can be loose, filed, shared with
people, or any mix, and access is the most any of them allow.

The store:
  - note_shares (migration 0030): note ↔ user ↔ role, one row per person, owner
    recorded. Visibility (AllNotes, and search via mayTouchNote) widens to include
    notes shared directly with the reader.
  - the effective role folds a direct share on top of the owner and project paths.

The rules, drawn where they protect the owner:
  - read = viewer, edit text = editor, on any path.
  - MOVE a note (change its project) is owner-only: an editor filing it into a
    project the owner is not in would take the owner's own note away from them.
  - DELETE is owner or project-editor — a direct share, however high its role,
    does not reach to throwing the note away.
  - SHARE and UNSHARE are the owner's alone, like a project's invitations.
  - the picker is everyone on the instance (a closed, invited set) minus who is
    already on the note; a made-up id and the owner themselves are refused.

The list:
  - a "Delt med mig" group at the top, collapsible, that appears only when it has
    something in it — the same rule Favoritter follows — so it costs nothing when
    you share nothing. A shared note lives there, not also in a time bucket.
  - every shared note carries a small chip in its owner's avatar colour, the
    project mark's shape said for a person.
  - no star or archive button on a note somebody else owns: those change the note
    itself, and it is not yours to put away.
  - the editor grows a "Del med personer" panel for the owner: who can see it, at
    what role, and an add row drawn from the instance's other people.

The old comment in the notes footer argued against a note having an access list
of its own. That was the right call until the case it did not cover — sharing
without a project — turned out to be the one actually asked for. Updated to match.

Person now carries json tags, so a note's owner serialises as id/name/avatar_color
like every other person in the API rather than as Go field names.

Tests: a shared note reaches the other person marked and owned; a viewer reads but
cannot write; an editor writes but cannot re-share or delete; unshare removes
access and is owner-only; the candidate list excludes who is already on and the
owner; and a made-up id and self are refused. OpenAPI covers the three new routes.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-24 07:36:55 +02:00
kristianwindandClaude Opus 5 b4038356f0 feat(security): trust a forwarded IP only from a proxy, and set COOP
The rate limiter and the audit log key on the caller's address, and that address
was read with chi's middleware.RealIP — which believes X-Forwarded-For from
anyone. So a caller could set it to a new value on every request and get a fresh
password-guess bucket each time, and could write any address they liked into the
log the panel's watchers read. The one control meant for when a password is
already gone was keyed on a number the attacker got to pick.

Now the header is believed only when the machine that *connected* is a trusted
proxy — by default anything on a private network, which is the shape of a reverse
proxy or tunnel sharing the host or the docker bridge. A connection straight off
the internet is trusted for nothing and is keyed on the address it actually came
from. VERDANDE_TRUSTED_PROXIES overrides the set (CIDRs, or "none" to trust no
proxy at all); VERDANDE_REAL_IP_HEADER names the header.

Verified in the current deployment before the change: behind the Cloudflare
Tunnel a spoofed X-Real-IP already got no fresh bucket, because cloudflared
overwrites it. This makes that safety a property of the code, not of the
deployment — expose the instance directly, or put a header-passing proxy in
front, and it would have been a live hole.

Also COOP: Cross-Origin-Opener-Policy: same-origin on every response, isolating
the browsing context. verdande scripts no other window, so there is nothing to
lose and a class of cross-window attacks to close.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-23 23:34:34 +02:00
Kristian Wind 3e8d609316 docs: imaget hentes fra GHCR, og prøven for 'offentlig' var forkert
CI / OpenAPI is valid (push) Successful in 13s
CI / End-to-end (push) Failing after 8m44s
CI / Docker image builds (push) Successful in 1m28s
Release / Build and publish image (push) Failing after 5s
CI / Go (push) Failing after 14m22s
Adressen rettet fem steder tilbage til ghcr.io. Og rune-siden lærte folk at
teste synlighed med `docker --config /tmp/empty pull`, hvilket er den fejl,
der tog instansen ned: en tom config stopper ikke maskinens credential-helper,
så pullet autentificeres uden at se sådan ud og melder succes for et image,
ingen fremmed kan hente. Prøven er nu registrets token-endepunkt med curl.
2026-08-22 00:29:57 +02:00
Kristian WindandClaude Opus 5 20b8c12b4a build: imaget hentes fra vores egen Gitea, og runen peger derind
GitHub er utilgængeligt i ti dage. Koden ligger allerede begge steder; det, der
manglede, var imaget og et sted at bygge det.

**Imaget ligger i Gitea nu**, begge arkitekturer. Kopieret med
`docker buildx imagetools create`, som flytter hele manifest-indekset mellem to
registre uden at hente lagene ned — en `docker pull` på denne maskine ville have
hentet arm64 og efterladt serveren uden en amd64 at køre.

**Og det kan hentes uden legitimation, mens repoet bliver ved med at være
privat.** Det var ikke givet: panelets `PullImage` sætter aldrig `RegistryAuth`,
så et privat register er udelukket, og Gitea gør normalt en container-pakke privat
sammen med det repo, den hører til. Men en pakke bindes kun til et repo, når den
skubbes med et repo-link — og denne er ikke, så dens synlighed følger brugeren
`kw`, som er offentlig.

Bevist frem for antaget: `docker --config /tmp/tom pull` hentede den med samme
digest som GHCR havde. En bar `curl` mod `/v2/…/manifests/…` svarer 401 også for
et læsbart image, fordi registret uddeler et anonymt token først — så den er ikke
prøven, og den kostede en runde at gennemskue.

**Runen er bumpet til version 5.** Ikke fordi felterne ændrede sig, men fordi en
rune ikke opdaterer sig selv: panelet skriver rune-rækken fra en UI-handling, så
ændringen kræver **Runes → verdande → Update**, og katalog-listen caches ti
minutter.

**Et Actions-workflow til Gitea** ligger i `.gitea/workflows/`. Sin egen fil frem
for en delt: `secrets.GITHUB_TOKEN` findes ikke der, registret er et andet, og der
er ingen Releases-side at skrive til. En delt fil ville være fire betingede led,
der hver kun gælder ét af de to steder. Den laver ingen release, fordi
opdateringsnotitsen i appen læser GitHubs — skal den flyttes med, er det
`internal/update`, der skal have en ny adresse.

**Det virker ikke endnu:** der er ingen runner registreret på Gitea. Indtil der er,
bygges og skubbes imaget i hånden herfra.

Dokumentationen og landingssiden siger den nye adresse. Rune-siden er skrevet om,
hvor den fortalte om GHCR-pakkeindstillinger, og siger nu, hvordan man prøver
efter på den måde, der faktisk svarer på spørgsmålet.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-21 16:53:18 +02:00
Kristian WindandClaude Opus 5 c8d67dd07d feat(notes): notekroppe forsegles, og søgningen åbner dem selv
CI / OpenAPI is valid (push) Successful in 38s
CI / Go (push) Failing after 18m36s
CI / End-to-end (push) Failing after 18m54s
CI / Docker image builds (push) Successful in 7m27s
Documentation / build (push) Failing after 7m7s
Documentation / deploy (push) Skipped
Release / Build and publish image (push) Failing after 9m59s
Grunden er den samme som for postkassers kodeord og OAuth-tokens: en
sikkerhedskopi kan hentes gennem fladen, og indtil nu bar hver eneste af dem hver
eneste note i klartekst. Noter er dér, folk skriver kodeord og API-nøgler ned — det
ved vi, fordi der ligger sådan nogle i den installation, det her blev bygget til.

**Både titel og krop.** Titlen *er* første linje, så en forseglet krop ved siden af
en læselig titel ville efterlade begyndelsen af hver eneste note i klartekst.

**Og de to kopier gik med.** Det var derfor det ikke var gjort for længe siden:
`notes_fts` indekserede kroppen, og den genererede `fold`-søjle bar en
translittereret kopi af den. At forsegle kroppen og lade dem stå ville have været
teater — en angriber med filen kunne læse hver note ud af indekset. Begge er væk,
og tabellen er bygget om, fordi SQLite ikke kan fjerne en genereret søjle på plads.

**Søgningen åbner teksten i Go.** Målt frem for gættet: tolv hundrede noter à 3,3 kB
koster 33–43 ms pr. søgning, og fladen venter 250 ms efter sidste tastetryk, før den
spørger — så det ligger inde i pausen. Tiden går ikke med at åbne konvolutterne;
fire megabyte AES-GCM er få millisekunder. Den går med at folde og småskrive hver
krop, og det er lineært: regn med omtrent 350 ms ved ti tusind noter. Dér er det den
forkerte form, og svaret er da et nøglet indeks — hvis pris er, at præfiks- og
delordssøgningen falder bort.

bm25 forsvandt med indekset. Det, der er beholdt, er den halvdel, der betød mest:
titlen vejer ti gange kroppen. Med et loft pr. ord, så en note, der nævner ordet
fyrre gange, ikke slår en, der nævner alle ordene én gang — det er den fejl, en ren
optælling laver, og den er let at lave og svær at få øje på.

**En bagudfyldning ved opstart, ikke efterhånden.** Instansens indstillinger
forsegles, når de skrives, og det er rigtigt for dem. For et arkiv på tolv hundrede
importerede noter, som ingen redigerer igen, betyder "når de skrives" aldrig — og
det er præcis dét arkiv, der er værd at forsegle. Idempotent, så den koster et
gennemløb af id'er hver anden gang, og `updated_at` røres ikke: at lukke en
konvolut om en note er ikke at skrive den, og et stempel med i aften ville kaste
den rækkefølge væk, importen brugte en dag på at hente hjem.

En fejl under bagudfyldningen stopper opstarten. Halvdelen af noterne lukket er
ikke i stykker — `v1:`-præfikset gør, at begge slags kan læses — men en instans,
der starter og lader som om den beskytter noget, den ikke gør, er værre end en, der
siger fra.

Hvad der stadig ligger åbent, står i SECURITY.md og på dokumentationssiden:
opgavetitler, beskrivelser, kommentarer, og `note_links`, som røber hvilke
projekter og opgaver en note peger på, men ikke hvad der står.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 22:40:55 +02:00
Kristian WindandClaude Opus 5 f64b7c1f01 docs: Noter og Kalender havde ingen sider, mens forsiden lovede begge
Landingssiden reklamerer for noter og for en kalender, og dokumentationen havde
hverken en side om den ene eller den anden. Det hul blev skrevet ned i morges og
er det her.

**Noter** siger, hvad der er værd at vide, og hvad der ikke virker: at første linje
*er* titlen og udledes ved hver gemning, at arkivet og papirkurven er to
forskellige ting, at søgningen finder `grøn` når man skriver `gron` og
`keepInventory` når man skriver `inventory` — og at notekroppe ligger i klartekst i
hver sikkerhedskopi, man kan hente. Det sidste hører hjemme dér, hvor nogen står
med en note og overvejer at skrive et kodeord i den.

**Kalender** samler det, der før var spredt: gitteret, ugen som et døgn, træk der
sætter et klokkeslæt, abonnementer på ICS og webcal, og Google-forbindelsen med
det forbehold, den skal have — den er skrevet efter dokumentationen og har aldrig
kørt mod Google. `caldav.md` er nu den anden retning alene: verdandes egne opgaver
læst af noget andet.

En ting, byggeriet lærte: `</>` i inline-kode vælter Pythons html-parser på 3.9
med "we should not get here!". CI kører 3.12 og er ligeglad, men en side, der ikke
kan bygges lokalt, er en side ingen retter. Knappen er beskrevet med ord i stedet.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 21:50:27 +02:00