Compare commits

...
8 Commits
Author SHA1 Message Date
Kristian WindandClaude Opus 5 cab4e703f5 fix(sikkerhed): CalDAV lod en viewer skrive, og Web Push kunne ringe indad
Release / Build and publish image (push) Failing after 10m0s
To huller fundet ved en gennemgang efter dagens arbejde. Begge har en prøve, der
er set fejle før rettelsen.

CalDAV PUT afgjorde adgang på det projekt, klienten *nævnte i adressen*, og tog
derefter opgavens id fra den samme adresse. Intet bandt de to sammen. En viewer
på et delt projekt kunne pege på et projekt, de selv ejer — indbakken, som alle
har og alle må redigere — og lægge den delte opgaves id i filnavnet. Projekt-
tjekket gik godt på indbakken, og skrivningen landede på en opgave, de kun må
læse. GetTask lukkede det ikke: den svarer "må denne person se det", og det må en
viewer. `CanEdit` er spørgsmålet, og det er det samme, handleUpdateTask altid har
stillet — API-døren har spurgt hele tiden, CalDAV-døren spurgte ikke.

Opgaven skal nu også ligge, hvor adressen siger. Uden det kan en redaktør på to
projekter nå det ene gennem det andets adresse; intet eskaleres af det i sig
selv, men en adresse, der har lov at lyve om hvor noget bor, er sådan sagen
ovenfor kom ind.

Web Push blev leveret med en almindelig http.Client. Adressen kommer fra
browserens abonnement — altså fra den, der er logget ind — og serveren POSTer til
den på et skema, de selv vælger: sæt en påmindelse et minut ude, og jobbet går
igen hvert minut. Tilmeldingen afviser en privat adresse, og den kontrol er ikke
nok alene: den kører på URL'en som skrevet, og et navn, den godkender, kan svare
127.0.0.1 næste gang det slås op. Det er præcis den sætning, `internal/safedial`
selv står og advarer om. Leveringen går gennem safedial nu, som tjekker den
opslåede adresse ved forbindelsen og hvert viderestillingshop på samme måde.
Kontrollen ved tilmelding bliver, fordi den siger nej, mens nogen kigger på
skærmen.

Og en hærdning: /inbound/mail har fået et loft. Det er den svagere af de to
offentlige skriveveje — beaconet skriver en række for en fremmed, denne skriver
ingenting uden en 256-bit adresse, man skal kende i forvejen — men adressen deles
med mailservere og står på en indstillingsside, og når den først er kendt, var der
intet loft på, hvad der kunne laves med den. Tallet er sat generøst: modtageren er
mailserveren, én adresse der leverer det hele.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 15:15:28 +02:00
Kristian WindandClaude Opus 5 43f8c0ea85 feat(site): landingssiden siger hvad der findes nu, og siger det med kanter
Release / Build and publish image (push) Failing after 10m0s
Siden stod fra før kalenderen, noternes billeder og arkiv, sikkerhedsarbejdet og
beaconet. Nu står de der: søgning der læser dansk, kalenderen, Venter på andre,
gentagelser, AI slået fra som udgangspunkt, andenfaktor, at ingen skriver sig
selv op, og beaconet — sagt som det er, tændt fra start og med ét klik til at
slukke.

Google Kalender står med sit forbehold på selve kortet. Den er skrevet efter
dokumentationen og har aldrig kørt mod Google, og en landingsside, der siger
noget andet, er den ene sætning, resten af siden ikke kan bære.

Ikke-målene løj om posten: "No mail integration beyond Gmail and the forwarding
address" har været forkert, siden IMAP kom til. iCloud og Fastmail virker. Det,
der faktisk ikke virker, er Outlook, og det er nu det, der står.

Designet: en bjælke der bliver stående, fordi siden er 5.600 px og vejen ud kun lå
i toppen. Fire fakta under knapperne, hvor en landingsside plejer at sætte
logoer, den ikke har gjort sig fortjent til. Kort med egne kanter frem for celler
i ét gitter — ti celler i tre søjler er et hul i tegningen, atten kort i tre
grupper er ikke. To paneler under "Running it", fordi de er alternativer og ikke
trin. Ikke-målene som en liste, der kan tælles. Og tre skærmbilleder, der lå i
site/screenshots uden at være brugt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 14:57:15 +02:00
Kristian WindandClaude Opus 5 8ecefbbdc2 fix(notes): rullebjælken svævede midt i arket
Det er `.page`, der ruller — og det var også `.page`, der blev smallere for at
holde linjelængden nede. Så bjælken fulgte med ind til tekstsøjlens kant og stod
med en håndflade tom rude til højre for sig, en tredjedel inde i ruden.

Grænsen sættes som polstring i stedet for som bredde. Elementet er lige så bredt
som ruden — bjælken bliver ved kanten, hvor den hører til — og teksten er
nøjagtig lige så bred som før. `max()` mod nul, fordi en smal rude ellers ville få
negativ polstring, og `border-box` er sat globalt, så bredden er hele boksen og
ikke boksen plus polstringen; uden det ville det give en vandret rullebjælke.

Kildevisningen deler `.page` med editoren og havde samme fejl. Den følger med.

Målt: elementet slutter nu på rudens indholdskant i stedet for 74 px inde, og
`scrollWidth - clientWidth` er nul.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 14:57:15 +02:00
Kristian WindandClaude Opus 5 6b4448b245 fix(settings): AI, Gmail og kalenderen delte én række og slettede hinanden
Meldt ind som "AI-informationerne gemmes ikke". De blev gemt. De blev taget
bagefter.

`user_settings` blev oprettet med `user_id` som hele sin primærnøgle — én række
per person, uanset scope — og upserten skrev `scope` om ved konflikt. Der er tre
scopes i live: `ai`, `gmail` og `calendar`. De delte den ene række, så hver
skrivning slettede de to andre, og hver eneste svarede 204. Det virker begge
veje: at forbinde kalenderen slettede Gmail-forbindelsen, og Gmail slettede
kalenderen.

Fælden var set komme to gange. 0011 gav instansens indstillinger deres egen
tabel delvis af den grund og skrev advarslen ned med rene ord; 0014 flyttede
postkasserne ud og skrev, at advarslen "nær var trådt i for anden gang". Tredje
gang var kalenderen, som lagde et `calendar`-scope ved siden af de to andre.

Lektien værd at holde fast i: en advarsel i en migration bliver læst af den, der
redigerer *den* migration, og det var ingen. Formen er rettet her, så der ikke er
noget tilbage at advare om — `PRIMARY KEY (user_id, scope)`, og upserten rammer
den nøgle.

Tabellen bygges om, fordi SQLite ikke kan ændre en primærnøgle på plads. De rækker,
der findes, kopieres som de er: hver person har højst én, og den holder det scope,
der vandt sidst. Det, de tidligere skrivninger tog, er overskrevet og kan ikke
hentes tilbage her — efter opdateringen skal AI-udbyderen sættes op igen, og
Gmail-forbindelsen tjekkes.

To prøver: én mod store-laget, hvor formen var fejlen, og én gennem handlerne,
hvor den blev set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 14:56:48 +02:00
Kristian WindandClaude Opus 5 e46aaa462a feat(projects): sektioner kan trækkes op og ned, og båndet står ikke længere skævt
Rækkefølgen af sektioner *er* planen — "Planlægning" står før "I gang", fordi det
er den vej arbejdet går — og indtil nu kunne den kun laves om ved at slette en
sektion og lave den igen, hvilket smider dens opgaver ud i det usektionerede
område på vejen.

Hele overskriften er håndtaget. En sektionsoverskrift indeholder ikke noget, der
selv bliver trukket, så der er ingen geste at stjæle — hvilket er grunden til, at
sidebjælkens rækker skulle have et greb, og grunden til at disse ikke skal. Ikke
draggable mens den omdøbes: et draggable element lader ikke markøren vælge tekst
i det felt, det indeholder.

Serversiden er hele listen på én gang som projekter og grupper, og af samme
grund: et projekt har en håndfuld sektioner, så 0, 1, 2 … er én lille transaktion
uden præcision at løbe tør for, og en liste kan ikke ende halvt anvendt.
`ReorderSections` er scopet til projektet og ikke til en ejer — en sektion har
ingen ejer, den hører til et projekt, og om du må rykke rundt er et spørgsmål om
det projekt, som handleren allerede har stillet. Id'er fra et andet projekt
springes over af WHERE-klausulen; der er en prøve på præcis det, fordi ingenting
ellers kontrollerer id'erne enkeltvis.

Båndet stod fire pixels skævt. Det var trukket ud med --s2 og polstret med --s3 —
samme idé gjort med to forskellige tal — så det blev otte pixels bredere end
listen i hver side, og teksten landede fire pixels uden for den kolonne, rækkerne
holder. Til venstre var det usynligt. Til højre var det Omdøb og Slet, der stod
fire pixels forbi alt over og under sig, hvilket er præcis langt nok til at ligne
en fejl og ikke langt nok til at se hvorfor. En række er `.row` med
`padding: var(--s2)` inde i en boks uden, så båndet har nu samme polstring og
ingen margen.

Board-visningen er ikke rørt: der er kolonnerne vandrette, og det er en anden
geste.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 14:56:26 +02:00
Kristian WindandClaude Opus 5 98bfa216d6 fix(look): mærket og de faste visninger fulgte med op, når listen rullede
Hele sidebjælken rullede, så runen og I dag forsvandt over kanten, så snart der
var projekter nok — og de er dem, man er på vej hen til oftest. Alt under de
faste visninger ligger i en rulleboks nu, så rullebjælken begynder under dem.

En boks indeni frem for `position: sticky` udenom: et klæbende hoved efterlader
rullebjælken i fuld højde ved siden af sig, og det var netop den, der skulle
starte længere nede. Snippet-erklæringerne bliver liggende udenfor — de tegner
ikke noget selv, men de kaldes oppe fra .views, og en snippet erklæret indeni
hører kun til indeni.

Chevronen for en mappe dækkede gruppens prik med et felt i --surface. På den
sænkede bjælke er det en lys firkant, og en foldet gruppe har sin chevron fremme
permanent, så klodsen stod der hele tiden. Prikken tones ud i stedet.

Det kostede en fælde: en `opacity` under 1 laver en stakkontekst og maler
elementet, som var det placeret med `z-index: 0`. Prikken lagde sig dermed oven
på knappen, den lige var forsvundet for, og tog klikket — usynlig og stadig i
vejen, så gruppen ikke kunne foldes. Ét lag op på knappen, og rækkefølgen er
som før.

Prøven for vandret rul måler nu begge bokse. Bjælken selv er `overflow: hidden`,
så en for bred række ville blive klippet dér og aldrig nå at tælle med — den
ville bestå af den forkerte grund.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 14:56:01 +02:00
Kristian WindandClaude Opus 5 6c3d66108b fix(look): stregen under hurtig tilføjelse havde ingen luft under sig
Release / Build and publish image (push) Failing after 9m56s
Den skiller "skriv en ny" fra "det, der allerede er". Uden afstand sad den første
sektion klods op ad den, så den læste som en kant om sektionen frem for som en
afslutning på feltet over. En streg skal have plads på begge sider for at kunne
ses som en streg.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 11:36:23 +02:00
Kristian WindandClaude Opus 5 1437667483 fix(notes): stjerne og arkiv står oven på hinanden
Release / Build and publish image (push) Failing after 9m56s
To knapper ved siden af hinanden er toogfyrre pixels taget fra en liste, der er
tre hundrede bred — og de er det, man rører en gang imellem, mens titlen og
uddraget er det, man læser hele tiden. I en søjle koster de toogtyve.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 11:23:21 +02:00
23 changed files with 1667 additions and 376 deletions
+30
View File
@@ -2049,6 +2049,36 @@ paths:
"404": { $ref: "#/components/responses/NotFound" }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/projects/{projectID}/sections/reorder:
parameters:
- { name: projectID, in: path, required: true, schema: { type: string } }
post:
tags: [projects]
summary: Set the order the project's sections appear in. Editors and owners.
description: |
The whole list at once, for the same reasons as `/projects/reorder`: a
project has a handful of sections, so sending the order you want is
simpler than midpoints and cannot land half-applied.
Ids belonging to another project are skipped rather than refused.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [ids]
properties:
ids:
type: array
maxItems: 500
items: { type: string }
description: Section ids, top to bottom.
responses:
"204": { description: Reordered. }
"404": { $ref: "#/components/responses/NotFound" }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/sections/{sectionID}:
parameters:
- { name: sectionID, in: path, required: true, schema: { type: string } }
+27
View File
@@ -231,6 +231,33 @@ func (s *Server) handleCalDAVPut(w http.ResponseWriter, r *http.Request) {
existing, getErr := s.db.GetTask(r.Context(), taskID, user.ID)
if getErr == nil {
// The task decides, not the URL.
//
// The check above is about the project the *client named*, and a client
// names both halves of this address. Asking the project for permission and
// then taking the task id from the same string ties nothing together: a
// viewer on a shared project could name a project of their own — the Inbox,
// which everybody owns and may edit — and put the shared task's id in the
// filename. The project check passed on the Inbox and the write landed on a
// task they may only read.
//
// GetTask does not close it either: it answers "may this person see it",
// and a viewer may. `CanEdit` is the question, and it is the same one
// handleUpdateTask asks — the API door has always asked it.
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
if err != nil || !role.CanEdit() {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
// And it has to live where the address says it does. Without this an editor
// on two projects can reach either through the other's URL — nothing is
// escalated by that on its own, but a URL allowed to lie about where a
// resource lives is how the case above got in.
if existing.ProjectID != projectID {
http.NotFound(w, r)
return
}
// An update. Only the fields a task client can express are touched;
// everything else the task carries stays as it is.
content := parsed.Summary
+152
View File
@@ -250,6 +250,116 @@ func (ts *testServer) apiToken(t *testing.T, userID string) string {
return token
}
// The promise of sharing has to hold at every door, not just the API's.
//
// TestViewerCannotMutateThroughTheAPI asserts a viewer cannot write, and it
// asserts it against /api/v1. CalDAV is a second door into the same tasks, and it
// authorised differently: it checked the caller's standing in the project *named
// in the URL* and then took the task id from the URL as well. Nothing tied the
// two together.
//
// So a viewer on somebody else's project could name a project of their own —
// their Inbox, which everybody owns and everybody may edit — and put the other
// project's task id in the filename. The project check passed on the Inbox, and
// the write landed on a task in a project they may only read.
func TestAViewerCannotWriteThroughCalDAV(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.newUser(t, "viewer@example.dk", "Viewer")
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"})
projectID := project["id"].(string)
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "en opgave", "project_id": projectID,
})
taskID := task["id"].(string)
if resp, body := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites", map[string]any{
"email": "viewer@example.dk", "role": "viewer",
}); resp.StatusCode != http.StatusOK {
t.Fatalf("invite: %d %v", resp.StatusCode, body)
}
them, err := ts.db.UserByEmail(t.Context(), "viewer@example.dk")
if err != nil {
t.Fatal(err)
}
token := ts.apiToken(t, them.ID)
// Their own Inbox: a project they own and may edit, and the one every account
// has. It is what makes the URL check pass.
projects, err := ts.db.ListProjects(t.Context(), them.ID, false)
if err != nil {
t.Fatal(err)
}
var inbox string
for _, p := range projects {
if p.IsInbox {
inbox = p.ID
}
}
if inbox == "" {
t.Fatal("the viewer has no inbox")
}
vtodo := "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:" + taskID +
"\r\nSUMMARY:kapret\r\nSTATUS:COMPLETED\r\nEND:VTODO\r\nEND:VCALENDAR\r\n"
resp := ts.dav(t, "PUT", "/caldav/"+them.ID+"/"+inbox+"/"+taskID+".ics",
token, them.Email, vtodo)
if resp.StatusCode < 400 {
t.Errorf("a viewer wrote through CalDAV: status %d", resp.StatusCode)
}
// And the task is as its owner left it. The status alone is not the assertion:
// the question is whether anything moved.
_, after := ts.do(t, "GET", "/api/v1/tasks/"+taskID, nil)
if after["content"] != "en opgave" {
t.Errorf("the task was renamed by a viewer: %v", after["content"])
}
if after["completed"] != false {
t.Error("the task was completed by a viewer")
}
}
// A task may only be addressed under the project it is actually in.
//
// The same hole seen from the other side: an editor on two projects could reach a
// task in one of them through the other's URL. Nothing is escalated by it — they
// may edit both — but it is the check that was missing, and a URL that lies about
// where a resource lives is how the viewer case got in.
func TestCalDAVRefusesATaskFromAnotherProject(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
user, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
if err != nil {
t.Fatal(err)
}
token := ts.apiToken(t, user.ID)
_, one := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Et"})
_, two := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "To"})
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "hører til Et", "project_id": one["id"].(string),
})
taskID := task["id"].(string)
vtodo := "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:" + taskID +
"\r\nSUMMARY:flyttet i smug\r\nEND:VTODO\r\nEND:VCALENDAR\r\n"
resp := ts.dav(t, "PUT", "/caldav/"+user.ID+"/"+two["id"].(string)+"/"+taskID+".ics",
token, user.Email, vtodo)
if resp.StatusCode < 400 {
t.Errorf("a task was written under the wrong project: status %d", resp.StatusCode)
}
_, after := ts.do(t, "GET", "/api/v1/tasks/"+taskID, nil)
if after["content"] != "hører til Et" {
t.Errorf("the task changed: %v", after["content"])
}
}
func TestCalDAVRequiresCredentials(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
@@ -562,6 +672,48 @@ func TestAIKeyIsNeverReturned(t *testing.T) {
}
}
// Connecting something else must not delete the AI provider.
//
// Reported from use as "the AI settings are not saved": they were saved, and then
// taken. `user_settings` had `user_id` as its whole primary key, and the upsert
// renamed `scope` on conflict — so the `ai`, `gmail` and `calendar` scopes shared
// one row and each write deleted the other two. Every write returned 204.
//
// Through the handlers, because that is where it was seen. The shape is asserted
// against the store as well: see TestOnePersonCanHaveSettingsForMoreThanOneThing.
func TestTheAIProviderSurvivesConnectingSomethingElse(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.do(t, "PUT", "/api/v1/ai/settings", map[string]any{
"provider": "anthropic", "model": "claude-sonnet-5", "api_key": "sk-hemmelig-nøgle",
})
user, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
if err != nil {
t.Fatal(err)
}
// What the calendar and the mailbox handlers do when a handshake starts: write
// their own scope for the same person.
for _, scope := range []string{"calendar", "gmail"} {
if err := ts.db.SetUserSettings(t.Context(), user.ID, scope,
map[string]any{"pkce_state": "s-" + scope}); err != nil {
t.Fatalf("save %s: %v", scope, err)
}
}
_, body := ts.do(t, "GET", "/api/v1/ai/settings", nil)
if body["provider"] != "anthropic" {
t.Errorf("the provider is %v after connecting two other things, want anthropic", body["provider"])
}
if body["model"] != "claude-sonnet-5" {
t.Errorf("the model is %v", body["model"])
}
if body["has_key"] != true {
t.Error("the key was taken with it")
}
}
func TestGmailSettings(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
+28
View File
@@ -356,6 +356,34 @@ func (s *Server) handleCreateSection(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, sectionJSON{sec.ID, sec.ProjectID, sec.Name, sec.SortOrder})
}
// handleReorderSections sets the order of the sections inside one project.
//
// Under /projects/{projectID}/ rather than beside the other section routes, which
// hang off a section id: the thing being changed here is the project's list, not
// any one section in it. That also puts it behind the same project permission
// check as everything else in this subtree, instead of resolving a section to its
// project one id at a time and hoping they all named the same one.
func (s *Server) handleReorderSections(w http.ResponseWriter, r *http.Request) {
var req reorderProjectsRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
// The same ceiling as the projects list, and a bound on the transaction rather
// than an opinion about how many sections a project may have.
if len(req.IDs) > 500 {
writeFieldErrors(w, map[string]string{"ids": "at most 500 at a time"})
return
}
projectID := chi.URLParam(r, "projectID")
if err := s.db.ReorderSections(r.Context(), projectID, req.IDs); err != nil {
s.internal(w, r, "reorder sections", err)
return
}
s.activity(r, projectID, "", "section.updated", nil)
w.WriteHeader(http.StatusNoContent)
}
// sectionAccess resolves a section to its project and checks the caller may edit
// it. Sections have no permissions of their own — they belong to a project, and
// that is where access is decided.
+65
View File
@@ -327,6 +327,71 @@ func TestAProjectCanHaveSeveralSections(t *testing.T) {
}
}
// Sections can be dragged into another order, and the order is the project's.
//
// The list is what a project's plan *is* — "Planlægning" before "I gang" is the
// order the work happens in — and until this endpoint existed the only way to
// change it was to delete a section and make it again, which moves its tasks to
// the unsectioned area on the way past.
func TestSectionsCanBeReordered(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Ombygning"})
id := project["id"].(string)
ids := map[string]string{}
names := []string{"Planlægning", "I gang", "Til gennemsyn"}
for _, name := range names {
_, body := ts.do(t, "POST", "/api/v1/projects/"+id+"/sections", map[string]any{"name": name})
ids[name] = body["id"].(string)
}
want := []string{"Til gennemsyn", "Planlægning", "I gang"}
order := make([]string, len(want))
for i, name := range want {
order[i] = ids[name]
}
resp, body := ts.do(t, "POST", "/api/v1/projects/"+id+"/sections/reorder",
map[string]any{"ids": order})
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("reorder: status %d, body %v", resp.StatusCode, body)
}
_, listed := ts.do(t, "GET", "/api/v1/projects/"+id+"/sections", nil)
got, _ := listed["sections"].([]any)
if len(got) != len(want) {
t.Fatalf("the project has %d sections, want %d", len(got), len(want))
}
for i, raw := range got {
if name := raw.(map[string]any)["name"]; name != want[i] {
t.Errorf("section %d is %v, want %q", i, name, want[i])
}
}
// A section belonging to somebody else's project is not moved by naming it
// here. The ids in the body are not checked one by one — the WHERE clause is
// what refuses them — so this is the assertion that it actually does.
_, other := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Sommerhus"})
otherID := other["id"].(string)
_, foreign := ts.do(t, "POST", "/api/v1/projects/"+otherID+"/sections",
map[string]any{"name": "Havearbejde"})
foreignID := foreign["id"].(string)
resp, _ = ts.do(t, "POST", "/api/v1/projects/"+id+"/sections/reorder",
map[string]any{"ids": []string{foreignID}})
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("reorder with a foreign id: status %d", resp.StatusCode)
}
_, stillThere := ts.do(t, "GET", "/api/v1/projects/"+otherID+"/sections", nil)
elsewhere, _ := stillThere["sections"].([]any)
if len(elsewhere) != 1 || elsewhere[0].(map[string]any)["name"] != "Havearbejde" {
t.Errorf("the other project's section did not survive: %v", elsewhere)
}
}
// A project can be created straight into a group.
//
// `group_id` was declared on the request type, documented on it, and applied by
+16 -1
View File
@@ -63,6 +63,7 @@ type Server struct {
// the endpoints that check a secret are limited separately and more tightly
// than the rest of the API.
loginLimiter *limiter
mailLimiter *limiter
resetLimiter *limiter
// Second factors are counted against the *account*, not the address. An
// address bucket is defeated by anybody with more than one address, which is
@@ -85,6 +86,11 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
mail: mail.New(cfg.SMTP, cfg.BaseURL, log),
hub: realtime.NewHub(log),
loginLimiter: newLimiter(10, 15*time.Minute),
// Generous on purpose: the caller is the mail server, one address delivering
// everything, and a personal instance gets a handful of forwarded mails a
// day. Sixty a minute never touches real delivery and still puts a ceiling
// on the one public write path that has none.
mailLimiter: newLimiter(60, time.Minute),
resetLimiter: newLimiter(5, time.Hour),
// Tighter than the password bucket, and for a whole hour. Somebody typing
// their own code gets it right on the first or second try; ten in an hour is
@@ -122,7 +128,15 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
// Inbound mail. Delivered by the mail server rather than by a browser, and
// authenticated by the token in the recipient address.
r.Post("/inbound/mail", s.handleInboundMail)
//
// Rate limited for the same reason the beacon is: it is reachable by anybody
// who can route to this instance. It is the weaker case of the two — the
// beacon writes a row for a stranger, this one writes nothing without a
// 256-bit address it has to already know — but an address is shared with mail
// servers and printed on a settings page, and once it is known there was no
// ceiling at all on what could be made with it.
r.Method(http.MethodPost, "/inbound/mail",
s.rateLimit(s.mailLimiter, http.HandlerFunc(s.handleInboundMail)))
// MCP with the token in the query string, for clients that cannot send a
// header. Claude's custom-connector dialog takes a URL and nothing else, so
@@ -426,6 +440,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Group(func(r chi.Router) {
r.Use(s.requireProject(store.RoleEditor))
r.Post("/sections", s.handleCreateSection)
r.Post("/sections/reorder", s.handleReorderSections)
})
r.Group(func(r chi.Router) {
+15 -1
View File
@@ -32,6 +32,8 @@ import (
"net/url"
"strings"
"time"
"github.com/kristianwind/verdande/internal/safedial"
)
type Subscription struct {
@@ -101,7 +103,19 @@ func Send(ctx context.Context, sub Subscription, payload Payload, vapid VAPID) e
req.Header.Set("Urgency", "normal")
req.Header.Set("Authorization", "vapid t="+token+", k="+vapid.Public)
resp, err := (&http.Client{Timeout: 15 * time.Second}).Do(req)
// safedial, not a plain client. The endpoint comes from the browser's
// subscription, which is to say from whoever is signed in, and this server
// POSTs to it on a schedule they choose — set a reminder a minute out and the
// job goes again every minute.
//
// The subscribe handler already refuses a private address, and that check is
// not enough on its own: it runs on the URL as written, and a name it approves
// can answer 127.0.0.1 the next time it is looked up. safedial checks the
// *resolved* address at connect time, which is the only moment the address
// that will actually be used is known — and it checks every redirect hop the
// same way. The parse-time check stays, because it says no while somebody is
// looking at the screen.
resp, err := safedial.Client(15 * time.Second).Do(req)
if err != nil {
return err
}
+66
View File
@@ -0,0 +1,66 @@
package push
import (
"context"
"crypto/ecdh"
"crypto/rand"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// A subscription must not be able to point the server at its own network.
//
// The endpoint is whatever the browser handed over, which is to say whatever the
// person signed in handed over, and this server POSTs to it on a schedule they
// choose. The subscribe handler refuses a private address when it is written
// down — and that check runs on the URL as written, so a name it approves can
// answer 127.0.0.1 the next time it is looked up. This is the other half: the
// address that will actually be dialled, checked at the moment of dialling.
func TestSendRefusesToReachInside(t *testing.T) {
reached := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reached = true
w.WriteHeader(http.StatusCreated)
}))
defer srv.Close()
// httptest listens on 127.0.0.1, which is exactly the destination in question.
if !strings.Contains(srv.URL, "127.0.0.1") {
t.Skipf("the test server is not on loopback: %s", srv.URL)
}
err := Send(context.Background(), testSubscription(t, srv.URL), Payload{Title: "hej"}, testVAPID(t))
if err == nil {
t.Fatal("Send reached a loopback address without complaining")
}
if reached {
t.Error("the request arrived: the wall let it through and only the answer failed")
}
}
func testSubscription(t *testing.T, endpoint string) Subscription {
t.Helper()
key, err := ecdh.P256().GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
auth := make([]byte, 16)
if _, err := rand.Read(auth); err != nil {
t.Fatal(err)
}
return Subscription{
Endpoint: endpoint,
P256dh: b64.EncodeToString(key.PublicKey().Bytes()),
Auth: b64.EncodeToString(auth),
}
}
func testVAPID(t *testing.T) VAPID {
t.Helper()
pub, priv, err := GenerateVAPIDKeys()
if err != nil {
t.Fatal(err)
}
return VAPID{Public: pub, Private: priv, Subject: "mailto:kristian@example.dk"}
}
+5 -1
View File
@@ -195,10 +195,14 @@ func TestAGmailConnectedBeforeTheMoveSurvivesIt(t *testing.T) {
}
values := `{"refresh_token":"` + sealed + `","trigger":"label","label":"Verdande",` +
`"email":"kw@nolimit.dk","seen":["abc","def"],"project_id":"p-1"}`
// The conflict target is (user_id, scope) since 0025 — before that the table
// held one row per person and the upsert renamed its scope, which is the bug
// 0025 exists to undo. Nothing conflicts here; the clause only has to be legal
// against the schema this test is running on.
if _, err := db.ExecContext(context.Background(), `
INSERT INTO user_settings (user_id, scope, values_json, updated_at)
VALUES (?, 'gmail', ?, 0)
ON CONFLICT (user_id) DO UPDATE SET scope = 'gmail', values_json = excluded.values_json`,
ON CONFLICT (user_id, scope) DO UPDATE SET values_json = excluded.values_json`,
userID, values); err != nil {
t.Fatal(err)
}
@@ -0,0 +1,37 @@
-- One row per person *and scope*, which is what the table always meant.
--
-- `user_settings` was created with `user_id` as its whole primary key, and its
-- upsert rewrote `scope` on conflict. So a person had exactly one settings row,
-- and whichever scope was written last was the only one that survived: saving an
-- AI provider deleted the Gmail connection, and connecting a calendar deleted the
-- AI provider. Nothing reported it — the write succeeded, and the loss was in the
-- row it landed on.
--
-- This was seen coming twice. Migration 0011 gave the instance's settings their
-- own table partly for this reason and wrote the warning down in as many words;
-- 0014 moved mailboxes out and said the warning "was nearly walked into a second
-- time". The third time was the calendar, which added a `calendar` scope beside
-- `ai` and `gmail` — and walked in.
--
-- The lesson worth keeping: a warning in a migration is read by whoever is editing
-- *that* migration, and nobody was. The shape is fixed here so there is nothing
-- left to warn about.
--
-- SQLite cannot change a primary key in place, so the table is rebuilt. Existing
-- rows are copied as they are: each person has at most one, and it holds whatever
-- scope won last. What the earlier writes lost is already gone and cannot be
-- recovered here — the values were overwritten, not orphaned.
CREATE TABLE user_settings_new (
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
scope TEXT NOT NULL DEFAULT 'general',
values_json TEXT NOT NULL DEFAULT '{}',
updated_at INTEGER NOT NULL,
PRIMARY KEY (user_id, scope)
);
INSERT INTO user_settings_new (user_id, scope, values_json, updated_at)
SELECT user_id, scope, values_json, updated_at FROM user_settings;
DROP TABLE user_settings;
ALTER TABLE user_settings_new RENAME TO user_settings;
+6 -2
View File
@@ -391,11 +391,15 @@ func (db *DB) SetUserSettings(ctx context.Context, userID, scope string, values
if err != nil {
return err
}
// On (user_id, scope), which is the key since 0025. It used to be on `user_id`
// alone and set `scope = excluded.scope` — so writing one scope did not add a
// row beside the others, it took theirs and renamed it. The `ai`, `gmail` and
// `calendar` settings each deleted the other two, and the write reported
// success every time.
_, err = db.ExecContext(ctx, `
INSERT INTO user_settings (user_id, scope, values_json, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT (user_id) DO UPDATE SET
scope = excluded.scope,
ON CONFLICT (user_id, scope) DO UPDATE SET
values_json = excluded.values_json,
updated_at = excluded.updated_at`,
userID, scope, string(raw), time.Now().Unix())
+29
View File
@@ -646,6 +646,35 @@ func (db *DB) ReorderProjects(ctx context.Context, userID string, ids []string)
})
}
// ReorderSections writes the order of one project's sections, top to bottom.
//
// The whole list at once, the same as ReorderProjects and for the same reasons: a
// project has a handful of sections, so writing 0, 1, 2 … is one small
// transaction that cannot run out of precision, and a list is impossible to get
// into the half-ordered state a sequence of individual moves leaves behind when
// one of them fails.
//
// Scoped to the project rather than to a user. A section has no owner of its own —
// it belongs to a project, and whether the caller may rearrange it is a question
// about that project, which the handler has already asked. Ids belonging to some
// other project are skipped by the WHERE clause rather than refused, so a stale
// list from a client that has just moved a section elsewhere cannot rearrange a
// project the caller was never looking at.
func (db *DB) ReorderSections(ctx context.Context, projectID string, ids []string) error {
return db.Tx(ctx, func(tx *sql.Tx) error {
now := time.Now().Unix()
for i, id := range ids {
if _, err := tx.ExecContext(ctx,
`UPDATE sections SET sort_order = ?, updated_at = ?
WHERE id = ? AND project_id = ? AND deleted_at IS NULL`,
float64(i), now, id, projectID); err != nil {
return err
}
}
return nil
})
}
// SectionByName resolves a "/name" from quick add within one project.
//
// Case-insensitive, and scoped to the project the task is landing in — a section
+62
View File
@@ -124,3 +124,65 @@ func sealedStore(t *testing.T) (*DB, string) {
}
return db, user.ID
}
// Three integrations, one person, and none of them may delete the others.
//
// `user_settings` had `user_id` as its whole primary key and an upsert that
// rewrote `scope` on conflict, so every person had exactly one settings row and
// the last write won the lot: saving an AI provider deleted the Gmail connection,
// and connecting a calendar deleted the AI provider. The write reported success
// each time, which is why it was reported as "the AI settings are not saved"
// rather than as "connecting the calendar deleted them".
//
// Written against the store rather than the handlers, because the shape was the
// bug: every caller was correct on its own.
func TestOnePersonCanHaveSettingsForMoreThanOneThing(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
want := map[string]map[string]any{
"ai": {"provider": "anthropic", "model": "claude-sonnet-5", "api_key": "sk-ant-xxx"},
"gmail": {"refresh_token": "1//0-gmail", "label": "Verdande"},
"calendar": {"refresh_token": "1//0-calendar", "calendar_id": "primary"},
}
// In this order on purpose: the AI settings are written first and read last,
// so a scope that steals the row is caught by the one written earliest.
for _, scope := range []string{"ai", "gmail", "calendar"} {
if err := db.SetUserSettings(ctx, userID, scope, want[scope]); err != nil {
t.Fatalf("save %s: %v", scope, err)
}
}
for _, scope := range []string{"ai", "gmail", "calendar"} {
got, err := db.UserSettings(ctx, userID, scope)
if err != nil {
t.Fatalf("read %s: %v", scope, err)
}
for key, value := range want[scope] {
if got[key] != value {
t.Errorf("%s.%s is %v, want %v", scope, key, got[key], value)
}
}
}
// And a second write to one scope updates it rather than adding another row —
// the upsert still has to upsert.
if err := db.SetUserSettings(ctx, userID, "ai", map[string]any{"provider": "openai"}); err != nil {
t.Fatal(err)
}
var rows int
if err := db.QueryRowContext(ctx,
`SELECT count(*) FROM user_settings WHERE user_id = ?`, userID).Scan(&rows); err != nil {
t.Fatal(err)
}
if rows != 3 {
t.Errorf("the person has %d settings rows, want 3", rows)
}
back, err := db.UserSettings(ctx, userID, "ai")
if err != nil {
t.Fatal(err)
}
if back["provider"] != "openai" {
t.Errorf("the second write did not land: %v", back["provider"])
}
}
+465 -81
View File
@@ -6,12 +6,12 @@
<title>verdande — self-hosted tasks, shared</title>
<meta
name="description"
content="Self-hosted tasks and projects, shared. One Go binary with SQLite inside it. Your mail as an inbox, CalDAV, passkeys, and an MCP connector for Claude."
content="Self-hosted tasks, notes and projects, shared. One Go binary with SQLite inside it. Your mail as an inbox, a CalDAV server, notes in Markdown, passkeys, and an MCP connector for Claude."
/>
<meta name="theme-color" content="#111312" />
<meta property="og:title" content="verdande" />
<meta property="og:description" content="Self-hosted tasks and projects, shared. One binary, SQLite inside it." />
<meta property="og:description" content="Self-hosted tasks, notes and projects, shared. One binary, SQLite inside it." />
<meta property="og:type" content="website" />
<meta property="og:image" content="screenshots/today.webp" />
<meta property="og:image:alt" content="The Today view: projects and filters beside a list of what is due." />
@@ -76,10 +76,10 @@
}
.theme-toggle {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 10;
/* In the bar now rather than floating over the corner. Fixed, it sat
on top of whatever happened to scroll under it — on a narrow window
that was the first screenshot's own corner. */
flex: none;
width: 2.25rem;
height: 2.25rem;
display: grid;
@@ -141,10 +141,86 @@
padding: 0 1.5rem;
}
/* --- the bar that stays -------------------------------------------
A page this long had its only way out at the very top: somebody who
read as far as "What it will not do" had to scroll back past nine
screenshots to reach the documentation. The bar is the way out from
wherever you are.
`sticky` and not `fixed`: sticky keeps its space in the flow, so
nothing below has to be pushed down by a number that then has to be
kept in step with the bar's own height.
Full width, with the wrap *inside* it. Put the other way round, the
background would end where the column ends and the page would scroll
past it on both sides. */
.topbar {
position: sticky;
top: 0;
z-index: 20;
background: var(--ground);
border-bottom: 1px solid var(--line);
}
.bar {
display: flex;
align-items: center;
gap: 1.25rem;
padding: 0.625rem 1.5rem;
}
.bar-brand {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--ink);
text-decoration: none;
font-weight: 560;
letter-spacing: -0.01em;
}
.bar-brand .rune {
color: var(--accent);
font-size: 1.125rem;
line-height: 1;
}
.bar-links {
display: flex;
align-items: center;
gap: 1.25rem;
margin-left: auto;
font-size: 0.875rem;
}
.bar-links a {
color: var(--ink-muted);
text-decoration: none;
}
.bar-links a:hover {
color: var(--ink);
}
/* The three in-page links go first on a narrow screen: they are the ones
a phone can reach by scrolling anyway. Documentation and Source cannot
be reached any other way, so they stay. */
@media (max-width: 640px) {
.bar-links .inpage {
display: none;
}
}
/* Or the bar covers the heading you just jumped to. */
:target {
scroll-margin-top: 4.5rem;
}
/* --- hero --- */
header {
padding: 6rem 0 4rem;
.hero {
padding: 5rem 0 3.5rem;
text-align: center;
}
@@ -172,7 +248,7 @@
}
.norn {
margin: 2rem auto 0;
margin: 0 auto 1.75rem;
max-width: 34rem;
font-size: 0.9375rem;
color: var(--ink-faint);
@@ -289,44 +365,104 @@
/* --- features --- */
/* A hairline between sections rather than under every heading: the page
is one column, and the line says where one subject stops. */
section {
padding: 3rem 0;
padding: 3.5rem 0;
border-top: 1px solid var(--line);
}
h2 {
display: flex;
align-items: baseline;
gap: 0.75rem;
font-size: 1.5rem;
font-weight: 560;
letter-spacing: -0.02em;
margin: 0 0 2rem;
}
/* A short accent rule in front of every h2. The sections had nothing to
mark them but their own words, and at a glance the page read as one
long run of paragraphs. */
h2::before {
content: '';
flex: none;
width: 1.125rem;
height: 2px;
border-radius: 1px;
background: var(--accent);
transform: translateY(-0.35em);
}
/* Three groups rather than eighteen cards in a row. Grouped, the list is
read as three answers — what it does, what it talks to, what it
promises — and any of the three can be skipped. */
.group-label {
margin: 2.5rem 0 1rem;
font-size: 0.75rem;
font-weight: 560;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--ink-faint);
}
.group-label:first-of-type {
margin-top: 0;
}
/* Cards with their own edges, not cells in one table.
The table shared a hairline grid, which meant the last row had to be
full or it was a hole in the drawing — and it was a hole, for as long
as there were ten of them. A card that stands alone on its row is a
card; an empty cell is a mistake. */
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 1px;
background: var(--line);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
overflow: hidden;
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
gap: 0.875rem;
}
.cell {
background: var(--ground);
padding: 1.5rem;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
padding: 1.125rem 1.25rem 1.25rem;
transition: border-color 150ms ease;
}
.cell:hover {
border-color: var(--line-strong);
}
.cell h3 {
margin: 0 0 0.5rem;
font-size: 1rem;
margin: 0 0 0.4375rem;
font-size: 0.9375rem;
font-weight: 560;
}
.cell p {
margin: 0;
font-size: 0.9375rem;
font-size: 0.875rem;
color: var(--ink-muted);
}
.cell code {
font-family: var(--font-mono);
font-size: 0.8125em;
color: var(--ink);
}
/* A card that has something to admit rather than something to offer.
It is not a footnote and it is not fine print: it is the same size as
everything else, and it says the same thing the README says. */
.cell .caveat {
margin: 0.5rem 0 0;
padding-top: 0.5rem;
border-top: 1px solid var(--line);
font-size: 0.8125rem;
color: var(--ink-faint);
}
/* --- install --- */
pre {
@@ -374,6 +510,109 @@
color: var(--ink-muted);
}
/* Four facts under the buttons, in the place where a landing page usually
puts logos it has not earned. They are the four questions somebody
self-hosting asks before anything else. */
ul.facts {
list-style: none;
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.5rem 1.25rem;
margin: 2rem 0 0;
padding: 0;
font-size: 0.8125rem;
color: var(--ink-faint);
}
ul.facts li {
display: flex;
align-items: center;
gap: 1.25rem;
}
/* The separator belongs between two facts, not after the last one — so it
is drawn by the item that follows it. */
ul.facts li + li::before {
content: '';
width: 3px;
height: 3px;
border-radius: 50%;
background: var(--line-strong);
}
/* On a phone the row wraps, and a separator drawn by the item that follows
it lands at the *start* of the new line — a dot with nothing before it.
No separators there; the wrap is the separation. */
@media (max-width: 640px) {
ul.facts {
gap: 0.375rem 1.125rem;
}
ul.facts li + li::before {
display: none;
}
}
/* Two ways to run it, side by side, because they are alternatives rather
than steps. Stacked, the second read as what you do after the first. */
.panels {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(19rem, 1fr));
gap: 0.875rem;
}
.panel {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
padding: 1.25rem;
}
.panel h3 {
margin: 0 0 0.5rem;
font-size: 0.9375rem;
font-weight: 560;
}
/* Sunk rather than raised: inside a card, a block in --surface would be
the same colour as the card it sits in. */
.panel pre {
background: var(--ground);
margin: 1rem 0 0;
}
.panel p {
margin: 0;
font-size: 0.875rem;
color: var(--ink-muted);
}
/* The non-goals were one paragraph of seven sentences, which is the shape
nobody reads. Each is its own line now, and they can be counted. */
ul.nogo {
list-style: none;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
gap: 0.5rem 1.75rem;
margin: 0;
padding: 0;
font-size: 0.9375rem;
color: var(--ink-muted);
}
ul.nogo li {
position: relative;
padding-left: 1.25rem;
}
ul.nogo li::before {
content: '—';
position: absolute;
left: 0;
color: var(--ink-faint);
}
.demo-note {
margin: 1rem 0 0;
font-family: var(--font);
@@ -524,27 +763,38 @@
</head>
<body>
<button class="theme-toggle" type="button" aria-label="Switch between light and dark" title="Light or dark">
<span aria-hidden="true">◐</span>
</button>
<div class="wrap">
<header>
<div class="topbar">
<div class="wrap bar">
<a class="bar-brand" href="#top">
<span class="rune" aria-hidden="true">ᚹ</span>
verdande
</a>
<nav class="bar-links" aria-label="Sections">
<a class="inpage" href="#screens">Screens</a>
<a class="inpage" href="#features">Features</a>
<a class="inpage" href="#running">Running it</a>
<a href="https://kristianwind.github.io/verdande/">Documentation</a>
<a href="https://github.com/kristianwind/verdande">Source</a>
<button class="theme-toggle" type="button" aria-label="Switch between light and dark" title="Light or dark">
<span aria-hidden="true">◐</span>
</button>
</nav>
</div>
</div>
<div class="wrap" id="top">
<header class="hero">
<span class="mark" aria-hidden="true">ᚹ</span>
<h1>verdande</h1>
<p class="lede">
Self-hosted tasks and projects, shared with the people you share the work with.
One binary, SQLite inside it.
Self-hosted tasks, notes and projects, shared with the people you share the work
with. One binary, SQLite inside it.
</p>
<p class="rune-note">
Built to be deployed as a <strong>Rune</strong> in
<a href="https://github.com/kristianwind/yggdrasil">Yggdrasil Panel</a> — upload
the manifest, fill in two fields, press start. It also runs anywhere Docker does.
</p>
<p class="norn">
Verdande is one of the three Norns — the one who spins <em>that which is
becoming</em>. Not what has been, and not what is fated to be: the thing you are
in the middle of. Which is what a to-do list is.
</p>
<div class="actions">
<a class="button primary" href="https://kristianwind.github.io/verdande/self-hosting/">
@@ -553,6 +803,14 @@
<a class="button" href="https://github.com/kristianwind/verdande">Source</a>
<a class="button" href="https://kristianwind.github.io/verdande/">Documentation</a>
</div>
<ul class="facts">
<li>One Go binary</li>
<li>SQLite inside it</li>
<li>amd64 and arm64</li>
<li>No account anywhere but yours</li>
<li>MIT</li>
</ul>
</header>
<!-- No screenshot: the quick-add parser is the thing worth showing, and it
@@ -584,7 +842,7 @@
</div>
</div>
<section>
<section id="screens">
<h2>What it looks like</h2>
<figure class="shot only-dark">
<img
@@ -669,11 +927,43 @@
/>
<figcaption>What has been done on the server, across every project.</figcaption>
</figure>
<figure class="shot">
<img
src="screenshots/delegated.webp"
alt="Waiting on others: tasks that have been handed to somebody else, grouped under the person who has them."
width="1400"
height="900"
loading="lazy"
/>
<figcaption>Waiting on others — grouped by who has it.</figcaption>
</figure>
<figure class="shot">
<img
src="screenshots/notes-source.webp"
alt="A note with the source view open: the same note as Markdown, with headings, a list, a quotation, a wiki link and a fenced code block."
width="1400"
height="900"
loading="lazy"
/>
<figcaption>The same note as Markdown. What is on disk is what you see.</figcaption>
</figure>
<figure class="shot">
<img
src="screenshots/settings.webp"
alt="The integrations settings page: connecting Gmail with an OAuth client, and below it the calendar feed address."
width="1400"
height="900"
loading="lazy"
/>
<figcaption>Integrations: your mailbox, your calendar feed, your address.</figcaption>
</figure>
</div>
</section>
<section>
<section id="features">
<h2>What it does</h2>
<p class="group-label">Doing the work</p>
<div class="grid">
<div class="cell">
<h3>Reads what you type</h3>
@@ -683,20 +973,54 @@
</p>
</div>
<div class="cell">
<h3>Shared, properly</h3>
<h3>Nothing to remember</h3>
<p>
Owner, editor and viewer roles. People outside your instance join by
invite link. Changes appear live.
Start typing anywhere and you are writing a task, with the letter you
pressed already in the field. <code>⌘K</code> goes anywhere else.
</p>
</div>
<div class="cell">
<h3>Search that reads Danish</h3>
<p>
Type <code>gron</code> and find <code>grøn</code>. Unicode does not
consider <code>ø</code> an accented <code>o</code>, so a search that
leans on that alone is broken for the people this was built for.
</p>
</div>
<div class="cell">
<h3>Repeats, and says so in words</h3>
<p>
Recurring tasks are RRULE underneath and "every other Tuesday" on the
screen. Reminders are absolute or relative, and go out to the devices
you have allowed them on.
</p>
</div>
<div class="cell">
<h3>Notes, beside the work</h3>
<p>
Rich text to write in, Markdown on disk. File a note in a project and
everybody in it can read it; <code>#project</code> and
Rich text to write in, Markdown on disk — and a source view to see it.
Images are pasted or dragged in. <code>#project</code> and
<code>[[another note]]</code> are links, and a task shows what has been
written about it. A zip of Markdown goes in and comes back out the same
shape, so nothing is trapped here.
written about it. Archive what is done with it; the bin is for mistakes.
</p>
</div>
<div class="cell">
<h3>Waiting on others</h3>
<p>
What you have handed to somebody else, on its own page, grouped under
the person who has it. The thing a shared list stops being able to tell
you the moment it is shared.
</p>
</div>
</div>
<p class="group-label">Connected to what you already use</p>
<div class="grid">
<div class="cell">
<h3>Shared, properly</h3>
<p>
Owner, editor and viewer roles. People outside your instance join by
invite link. Changes appear live.
</p>
</div>
<div class="cell">
@@ -706,6 +1030,27 @@
here. Not a read-only feed pretending.
</p>
</div>
<div class="cell">
<h3>A calendar of its own</h3>
<p>
A month or a week of what is due, and a secret ICS address to subscribe
to from Apple Calendar, Google or Thunderbird. A Google calendar can be
laid over the top, read-only.
</p>
<p class="caveat">
The Google half is written to Google's documentation and has not yet
been run against Google. The feed and CalDAV have.
</p>
</div>
<div class="cell">
<h3>Your mail, as an inbox</h3>
<p>
Star a mail in Gmail, or flag one over IMAP in iCloud or Fastmail, and
it becomes a task. Forward one to your own address instead and the
subject is parsed like anything else you type. One way: unstarring it
later does nothing to the task you made.
</p>
</div>
<div class="cell">
<h3>Claude, connected</h3>
<p>
@@ -714,35 +1059,39 @@
</p>
</div>
<div class="cell">
<h3>Your mail, as an inbox</h3>
<h3>AI only if you ask for it</h3>
<p>
Star a mail in Gmail, or flag one in iCloud or Fastmail, and it becomes
a task. Forward one to your own address instead and the subject is
parsed like anything else you type. One way: unstarring it later does
nothing to the task you made.
Off by default, your own key, and your choice of Anthropic, OpenAI,
Google or something local. Nothing leaves the server until you have
filled that in, and everything works without it.
</p>
</div>
</div>
<p class="group-label">Yours, and checkable</p>
<div class="grid">
<div class="cell">
<h3>Nothing to remember</h3>
<h3>More than one way in</h3>
<p>
Start typing anywhere and you are writing a task, with the letter you
pressed already in the field. Sign in with a passkey and there is no
password to forget either.
</p>
</div>
<div class="cell">
<h3>Yours to leave</h3>
<p>
Import from Todoist, export back to it, or take everything as JSON. The
round trip is tested, not promised.
Passkeys, or a password hashed with Argon2id and a second factor with
recovery codes. Every device you are signed in on is listed, and any of
them can be signed out from here.
</p>
</div>
<div class="cell">
<h3>Keys stay behind</h3>
<p>
Mail tokens and mailbox passwords are encrypted in the database, and the
key lives in a file beside it — not inside it. So a backup you download,
or one that ends up somewhere it should not, does not open your mail.
Mail tokens, mailbox passwords, second-factor secrets and AI keys are
encrypted in the database, and the key lives in a file beside it — not
inside it. So a backup you download, or one that ends up somewhere it
should not, does not open your mail.
</p>
</div>
<div class="cell">
<h3>Nobody signs themselves up</h3>
<p>
The first account is the administrator, and after that people arrive by
invitation. An address on the internet is not an open door.
</p>
</div>
<div class="cell">
@@ -753,50 +1102,85 @@
instance's record, not the person's.
</p>
</div>
<div class="cell">
<h3>Yours to leave</h3>
<p>
Import from Todoist, export back to it, take everything as JSON, or
hand the notes a zip of Markdown and get the same zip back out. The
round trip is tested, not promised.
</p>
</div>
<div class="cell">
<h3>It counts itself, out loud</h3>
<p>
Once a day an instance says it exists — a random id, the version, and
nothing else. No tasks, no names, no addresses. It is on to begin with,
the settings page prints the exact payload, and one switch stops it.
</p>
</div>
</div>
</section>
<section>
<section id="running">
<h2>Running it</h2>
<h3 class="sub">As a Rune in Yggdrasil Panel</h3>
<p class="muted">
This is what it was built for. <strong>Runes → Carve a rune</strong>, upload
<code>rune/verdande.yaml</code>, create a server from it, set the public address
and your mail server, and start it. No port forwarding — give it a subdomain and
the panel routes it through your Cloudflare Tunnel.
</p>
<h3 class="sub">Or with Docker, anywhere</h3>
<pre><code>docker run -d --name verdande \
<div class="panels">
<div class="panel">
<h3>As a Rune in Yggdrasil Panel</h3>
<p>
This is what it was built for. <strong>Runes → Carve a rune</strong>,
upload <code>rune/verdande.yaml</code>, create a server from it, set the
public address and your mail server, and start it. No port forwarding —
give it a subdomain and the panel routes it through your Cloudflare
Tunnel.
</p>
</div>
<div class="panel">
<h3>Or with Docker, anywhere</h3>
<p>Open the address afterwards and create the first account. That one is the administrator.</p>
<pre><code>docker run -d --name verdande \
-p 8080:8080 \
-v verdande-data:/data \
-e VERDANDE_BASE_URL=https://todo.example.dk \
ghcr.io/kristianwind/verdande:latest</code></pre>
<p class="muted" style="margin-top: 1rem">
Open the address and create the first account. That one is the administrator.
</p>
</div>
</div>
<ul class="plain">
<li>No database to provision — SQLite is inside the binary.</li>
<li>Distroless image: no shell, no package manager, one static file.</li>
<li>amd64 and arm64, so a Raspberry Pi is fine.</li>
<li>Nightly backups, fourteen kept, written with <code>VACUUM INTO</code>.</li>
<li>Twenty-four migrations, run on start. Upgrading is pulling the new image.</li>
</ul>
</section>
<section>
<section id="limits">
<h2>What it will not do</h2>
<p class="muted">
No karma or points. No team workspaces. No native apps. No mail integration
beyond Gmail and the forwarding address. No languages beyond Danish and English.
And no sync protocol of its own — CalDAV and the API <em>are</em> the sync.
</p>
<ul class="nogo">
<li>No karma, streaks or points.</li>
<li>No team workspaces — projects and the people in them are the whole model.</li>
<li>No native apps. It is a PWA, and it installs on a phone.</li>
<li>No Outlook or Exchange: Microsoft has closed IMAP to passwords and wants its own flow.</li>
<li>No languages beyond Danish and English.</li>
<li>No sync protocol of its own — CalDAV and the API <em>are</em> the sync.</li>
<li>No hosted version. There is nowhere to sign up, and that is the point.</li>
</ul>
</section>
<footer>
<!-- The Norn belongs at the end. It was the third paragraph of the hero,
where it stood between somebody arriving and the buttons they came
for — and it is a closing thought, not an opening one. -->
<p class="norn">
Verdande is one of the three Norns — the one who spins <em>that which is
becoming</em>. Not what has been, and not what is fated to be: the thing you are
in the middle of. Which is what a to-do list is.
</p>
<p>
MIT licensed ·
<a href="https://github.com/kristianwind/verdande">github.com/kristianwind/verdande</a>
· <a href="https://kristianwind.github.io/verdande/">Documentation</a>
</p>
<p>
Built to run as a Rune in
+144
View File
@@ -358,6 +358,20 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
'aria-expanded',
'false'
);
// En foldet gruppe står med sin chevron fremme permanent, og den lå oven i
// prikken med et felt i --surface bag sig — en lys firkant ud for navnet på
// den sænkede bjælke, hele tiden. Nu tones prikken ud i stedet, så der er
// ingenting at male henover den med.
const chevron = await sidebar
.locator('.folder-head .chevron-button')
.evaluate((el) => ({
background: getComputedStyle(el).backgroundColor,
dot: getComputedStyle(el.closest('.folder-head').querySelector('.group-dot')).opacity
}));
expect(chevron.background, 'chevronen har en firkant bag sig').toBe('rgba(0, 0, 0, 0)');
expect(chevron.dot, 'prikken ligger stadig under chevronen').toBe('0');
await sidebar.getByRole('button', { name: /^Fold Arbejde/ }).click();
// Colour, which is chosen while renaming rather than from the row. Both are
@@ -1814,6 +1828,14 @@ test('sidebjælken kan ikke scrolles vandret', async ({ page }) => {
const sideways = await sidebar.evaluate((el) => el.scrollWidth - el.clientWidth);
expect(sideways, 'sidebjælken kan scrolles vandret').toBeLessThanOrEqual(1);
// Og rulleboksen indeni, som er den, der faktisk ruller nu. Bjælken selv er
// `overflow: hidden`, så en for bred række ville blive klippet dér og aldrig
// nå at tælle med ovenfor — målingen ville stå og bestå af den forkerte grund.
const inner = await sidebar
.locator('.scroller')
.evaluate((el) => el.scrollWidth - el.clientWidth);
expect(inner, 'rulleboksen kan scrolles vandret').toBeLessThanOrEqual(1);
// And the whole page, for the same reason at a different scale.
const page_sideways = await page.evaluate(
() => document.documentElement.scrollWidth - document.documentElement.clientWidth
@@ -1827,6 +1849,58 @@ test('sidebjælken kan ikke scrolles vandret', async ({ page }) => {
expect(trouble).toEqual([]);
});
/**
* Mærket og de faste visninger bliver stående, når resten ruller.
*
* Rullebjælken gik før hele vejen op: runen og I dag forsvandt op over kanten, så
* snart der var projekter nok — og de er dem, man er på vej hen til oftest.
*
* Målt i et lavt vindue, for i et højt er der ikke noget at rulle, og en prøve,
* der ikke ruller, består uanset hvad man laver om. Bredden holdes over
* telefongrænsen, hvor bjælken bliver til en skuffe og er en anden ting.
*/
test('sidebjælkens hoved bliver stående, når listen ruller', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
await page.setViewportSize({ width: 1000, height: 420 });
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
const scroller = sidebar.locator('.scroller');
await expect(scroller).toBeVisible();
// Rullebjælken begynder under de faste visninger og ikke bag dem.
const geometry = await sidebar.evaluate((el) => {
const box = (sel) => el.querySelector(sel).getBoundingClientRect().toJSON();
const scroll = el.querySelector('.scroller');
return {
views: box('.views'),
scroller: box('.scroller'),
overflows: scroll.scrollHeight - scroll.clientHeight
};
});
expect(geometry.overflows, 'der er ikke noget at rulle — prøven beviser intet').toBeGreaterThan(
0
);
expect(
geometry.scroller.top,
'rulleboksen begynder oppe i de faste visninger'
).toBeGreaterThanOrEqual(geometry.views.bottom - 1);
// Og hovedet bliver, hvor det er, hele vejen ned.
const brand = sidebar.locator('.brand');
const before = await brand.boundingBox();
await scroller.evaluate((el) => el.scrollTo(0, el.scrollHeight));
await expect
.poll(async () => scroller.evaluate((el) => el.scrollTop))
.toBeGreaterThan(0);
const after = await brand.boundingBox();
expect(Math.abs(after.y - before.y), 'mærket fulgte med op').toBeLessThanOrEqual(1);
await expect(sidebar.getByRole('link', { name: 'I dag' })).toBeInViewport();
expect(trouble).toEqual([]);
});
/**
* A project can have more than one section.
*
@@ -1899,6 +1973,76 @@ test('et projekt kan have flere sektioner @forms', async ({ page }) => {
expect(trouble).toEqual([]);
});
/**
* Sektioner kan trækkes op og ned.
*
* Rækkefølgen af sektioner *er* planen — "Planlægning" står før "I gang", fordi
* det er den vej arbejdet går — og indtil nu kunne den kun laves om ved at slette
* en sektion og lave den igen, hvilket smider dens opgaver ud i det usektionerede
* område på vejen.
*
* Målt på rækkefølgen af overskrifterne på siden og bagefter på, hvad serveren
* svarer efter en genindlæsning: den første halvdel kan bestå af, at fladen
* flyttede et element, den anden kan kun bestå, hvis skrivningen gik igennem.
*/
test('sektioner kan trækkes i en anden rækkefølge, og den overlever en genindlæsning', async ({
page
}) => {
const trouble = watchForTrouble(page);
await page.goto('/');
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
await sidebar.getByLabel('Projektnavn').fill('Havehus');
await sidebar.getByLabel('Projektnavn').press('Enter');
await expect(page.getByRole('heading', { name: 'Havehus' })).toBeVisible();
for (const name of ['Fundament', 'Rejsning', 'Tag']) {
await page.getByRole('button', { name: '+ Tilføj sektion' }).click();
const field = page.getByLabel('Ny sektion');
await field.fill(name);
await field.press('Enter');
await expect(page.getByRole('heading', { name, exact: true })).toBeVisible();
}
const order = () =>
page.locator('.section-head h2').evaluateAll((els) => els.map((e) => e.textContent.trim()));
expect(await order()).toEqual(['Fundament', 'Rejsning', 'Tag']);
// Tag trækkes helt op. Sluppet i den øverste halvdel af "Fundament", som er
// gaben over den — hvilken halvdel man rammer, er hele forskellen på over og
// under, så positionen er skrevet ud og ikke overladt til midten.
await page
.locator('.section-head')
.filter({ hasText: 'Tag' })
.dragTo(page.locator('.section-head').filter({ hasText: 'Fundament' }), {
targetPosition: { x: 40, y: 3 }
});
await expect.poll(order).toEqual(['Tag', 'Fundament', 'Rejsning']);
await page.reload();
await expect(page.getByRole('heading', { name: 'Tag', exact: true })).toBeVisible();
expect(await order(), 'serveren tog ikke rækkefølgen').toEqual([
'Tag',
'Fundament',
'Rejsning'
]);
// Og den anden vej: sluppet i den nederste halvdel lander den under.
await page
.locator('.section-head')
.filter({ hasText: 'Tag' })
.dragTo(page.locator('.section-head').filter({ hasText: 'Rejsning' }), {
targetPosition: { x: 40, y: 28 }
});
await expect.poll(order).toEqual(['Fundament', 'Rejsning', 'Tag']);
expect(trouble).toEqual([]);
});
/**
* Form fields on a touch device are at least 16px.
*
+4
View File
@@ -283,6 +283,10 @@ export const api = {
createSection: (projectId, name) => post(`/projects/${projectId}/sections`, { name }),
updateSection: (id, data) => patch(`/sections/${id}`, data),
deleteSection: (id) => del(`/sections/${id}`),
// The whole list, the same as projects and groups: a project has a handful of
// sections, and an order that cannot land half-applied is worth more than the
// bytes a single move would save.
reorderSections: (projectId, ids) => post(`/projects/${projectId}/sections/reorder`, { ids }),
listMembers: (projectId) => get(`/projects/${projectId}/members`),
invite: (projectId, email, role) => post(`/projects/${projectId}/invites`, { email, role }),
+13 -1
View File
@@ -884,8 +884,20 @@
* clamp frem for et tal: en smal rude får det hele, en bred får en
* linjelængde, øjet kan finde tilbage i. Øvre grænse omkring 68rem, hvor en
* linje er lang nok til at rumme en kommando og kort nok til at læse. */
max-width: clamp(38rem, 92%, 68rem);
/* Grænsen sættes som polstring og ikke som bredde, og det er rullebjælken,
* der afgør det.
*
* Det er *dette* element, der ruller. Var det også det, der blev smallere,
* fulgte rullebjælken med ind: den stod ved tekstsøjlens kant med en
* håndflade tom rude til højre for sig og svævede midt i arket. Med
* polstring er elementet lige så bredt som ruden — bjælken bliver ved
* kanten, hvor den hører til — og teksten er nøjagtig lige så bred som før.
*
* `max()` mod nul, fordi en smal rude ellers ville få negativ polstring, og
* `border-box` er sat globalt, så bredden er hele boksen og ikke boksen plus
* polstringen. Uden det ville det her give en vandret rullebjælke. */
width: 100%;
padding-right: max(0px, calc(100% - clamp(38rem, 92%, 68rem)));
}
.hidden {
+7
View File
@@ -221,6 +221,13 @@
gap: var(--s3);
padding: var(--s3) var(--s2);
border-bottom: 1px solid var(--line);
/* Luft under stregen.
*
* Stregen skiller "skriv en ny" fra "det, der allerede er", og uden afstand
* sad den første sektion klods op ad den — så den læste som en kant om
* sektionen frem for som en afslutning på feltet over. En streg skal have
* plads på begge sider for at kunne ses som en streg. */
margin-bottom: var(--s4);
}
.plus {
+332 -280
View File
@@ -639,301 +639,313 @@
</h2>
{/snippet}
<div class="group">
<!-- The heading is also the drop target for "no group": without it, a project
dragged into a group could only be got out again by emptying the group,
and with every project filed there would be no loose row left to aim at. -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="group-head"
class:over={overGroup === ''}
ondragover={(e) => {
if (!carries(e, PROJECT)) return;
accept(e);
overGroup = '';
overId = null;
}}
ondragleave={() => (overGroup = null)}
ondrop={(e) => onDropInGroup(e, '')}
>
{@render foldHeading('projects', t('sidebar.projects'))}
<button
class="icon"
onclick={() => {
adding = !adding;
addingGroup = false;
}}
aria-label={t('sidebar.newProject')}>+</button
>
<button
class="icon new-group"
onclick={() => {
addingGroup = !addingGroup;
adding = false;
newGroupName = '';
}}
aria-label={t('sidebar.newGroup')}
title={t('sidebar.newGroup')}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
</svg>
</button>
</div>
<!-- Alt herunder ruller; mærket og de faste visninger gør ikke.
Hele sidebjælken rullede før, så runen og I dag forsvandt op over kanten,
så snart der var projekter nok — og de er dem, man er på vej hen til
oftest. En rulleboks indeni frem for `position: sticky` udenom, fordi et
klæbende hoved efterlader rullebjælken i fuld højde ved siden af sig: den
skal begynde under hovedet, ikke bag det.
{#if adding}
<form onsubmit={createProject}>
<input
bind:value={newName}
use:focusOnMount
placeholder={t('sidebar.projectName')}
aria-label={t('sidebar.projectName')}
onblur={() => !newName.trim() && (adding = false)}
onkeydown={(e) => e.key === 'Escape' && (adding = false)}
/>
</form>
{/if}
{#if addingGroup}
<form onsubmit={createGroup}>
<input
bind:value={newGroupName}
use:focusOnMount
placeholder={t('sidebar.groupName')}
aria-label={t('sidebar.groupName')}
onblur={() => !newGroupName.trim() && (addingGroup = false)}
onkeydown={(e) => e.key === 'Escape' && (addingGroup = false)}
/>
</form>
{/if}
{#if !app.sectionCollapsed('projects')}
{#each ungrouped as project (project.id)}
{@render projectRow(project, true)}
{/each}
{#if own.length === 0 && !adding}
<p class="empty">{t('sidebar.noProjects')}</p>
{/if}
{/if}
</div>
{#each groups as group (group.id)}
{@const inside = inGroup(group.id)}
<div class="group folder">
<!-- Draggable, except while it is being renamed: a draggable ancestor stops
the pointer from selecting text in the field it contains. -->
Snippet-erklæringerne bliver liggende udenfor. De tegner ikke noget selv,
men de kaldes oppe fra .views, og en snippet erklæret herinde hører kun
til herinde. -->
<div class="scroller">
<div class="group">
<!-- The heading is also the drop target for "no group": without it, a project
dragged into a group could only be got out again by emptying the group,
and with every project filed there would be no loose row left to aim at. -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="group-head folder-head"
class:over={overGroup === group.id}
class:drop-above={overId === group.id && !overBelow}
class:drop-below={overId === group.id && overBelow}
class:dragging={draggingGroup === group.id}
draggable={renamingGroup !== group.id}
ondragstart={(e) => onGroupDragStart(e, group)}
ondragend={clearDrag}
ondragover={(e) => onGroupDragOver(e, group)}
ondragleave={() => {
overGroup = null;
class="group-head"
class:over={overGroup === ''}
ondragover={(e) => {
if (!carries(e, PROJECT)) return;
accept(e);
overGroup = '';
overId = null;
}}
ondrop={(e) => onGroupDrop(e, group)}
ondblclick={() => startRename(group)}
onkeydown={(e) => {
if (e.key === 'F2') {
e.preventDefault();
startRename(group);
}
}}
ondragleave={() => (overGroup = null)}
ondrop={(e) => onDropInGroup(e, '')}
>
{#if renamingGroup === group.id}
<!-- Renaming is where the colour is chosen too.
The dot used to be a button in the row, which made the heading
carry four controls — dot, name, Omdøb, Slet — on a line narrow
enough that they crowded the name. Both are edits to the same
thing, so both belong in the same moment: you open the group to
change it, and change what you came to change.
`focusout` rather than `blur` closes it, and only when focus has
left the form altogether — clicking a swatch is still inside it.
The swatches also refuse focus on mousedown, because Safari and
Firefox do not focus a button on click, so `relatedTarget` would
be null and the form would shut under the pointer. -->
<form
class="renaming"
onsubmit={(e) => renameGroup(e, group)}
onfocusout={(e) => {
if (!e.currentTarget.contains(e.relatedTarget)) renamingGroup = null;
}}
>
<input
bind:value={groupName}
use:focusOnMount
aria-label={t('sidebar.groupName')}
onkeydown={(e) => e.key === 'Escape' && (renamingGroup = null)}
/>
<div class="swatches" role="group" aria-label={t('group.colorOf', { name: group.name })}>
{#each COLORS as color (color.id)}
<button
type="button"
class="swatch"
class:chosen={(group.color ?? 'graphite') === color.id}
style="background: {colorVar(color.id)}"
title={t(color.name)}
aria-label={t(color.name)}
aria-pressed={(group.color ?? 'graphite') === color.id}
onmousedown={(e) => e.preventDefault()}
onclick={() => app.setGroupColor(group.id, color.id)}
></button>
{/each}
</div>
</form>
{:else}
<!-- The button sits inside the heading, not around it, the same way a
project's title does: a group *is* a heading in the sidebar, and
wrapping an h2 in a button both invalidates the markup and takes
the heading away from a screen reader. The whole heading folds —
at this size a lone chevron is a target you miss, and the name is
where the eye already is. -->
<!-- The chevron folds; the name opens the group's own page. Two
targets in one heading, because they are two different intentions
and the old single one could only ever serve the smaller. -->
<button
class="chevron-button"
onclick={() => app.toggleGroup(group.id)}
aria-expanded={!group.collapsed}
aria-label={group.collapsed
? t('sidebar.unfold', { name: group.name })
: t('sidebar.fold', { name: group.name })}
>
<svg
class="chevron"
class:collapsed={group.collapsed}
viewBox="0 0 24 24"
aria-hidden="true"
>
<path d="M6 9l6 6 6-6" />
</svg>
</button>
<h2 class="fold">
<!-- Navigation waits two tenths of a second for a second click. A
link cannot know a double is coming without pausing, and the
alternative was worse than the pause: the first click opened the
page while the rename form was appearing, and the colour swatches
moved out from under the pointer. Two tenths is under the double
click threshold and over the threshold of noticing. -->
<a
href="/gruppe/{group.id}"
onclick={(e) => {
e.preventDefault();
clearTimeout(navTimer);
if (e.detail > 1) return;
navTimer = setTimeout(() => {
onnavigate();
goto(`/gruppe/${group.id}`);
}, 200);
}}
>
<span
class="dot group-dot"
style="background: {colorVar(group.color)}"
aria-hidden="true"
></span>
{group.name}
</a>
</h2>
<span class="count">{inside.length}</span>
{/if}
{@render foldHeading('projects', t('sidebar.projects'))}
<button
class="icon"
onclick={() => {
adding = !adding;
addingGroup = false;
}}
aria-label={t('sidebar.newProject')}>+</button
>
<button
class="icon new-group"
onclick={() => {
addingGroup = !addingGroup;
adding = false;
newGroupName = '';
}}
aria-label={t('sidebar.newGroup')}
title={t('sidebar.newGroup')}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
</svg>
</button>
</div>
{#if !group.collapsed}
{#each inside as project (project.id)}
{#if adding}
<form onsubmit={createProject}>
<input
bind:value={newName}
use:focusOnMount
placeholder={t('sidebar.projectName')}
aria-label={t('sidebar.projectName')}
onblur={() => !newName.trim() && (adding = false)}
onkeydown={(e) => e.key === 'Escape' && (adding = false)}
/>
</form>
{/if}
{#if addingGroup}
<form onsubmit={createGroup}>
<input
bind:value={newGroupName}
use:focusOnMount
placeholder={t('sidebar.groupName')}
aria-label={t('sidebar.groupName')}
onblur={() => !newGroupName.trim() && (addingGroup = false)}
onkeydown={(e) => e.key === 'Escape' && (addingGroup = false)}
/>
</form>
{/if}
{#if !app.sectionCollapsed('projects')}
{#each ungrouped as project (project.id)}
{@render projectRow(project, true)}
{/each}
{#if !inside.length}
<p class="empty">{t('sidebar.emptyGroup')}</p>
{#if own.length === 0 && !adding}
<p class="empty">{t('sidebar.noProjects')}</p>
{/if}
{/if}
</div>
{/each}
{#if shared.length}
<div class="group">
<div class="group-head">{@render foldHeading('shared', t('sidebar.shared'))}</div>
{#if !app.sectionCollapsed('shared')}
{#each shared as project (project.id)}
{@render projectRow(project, false)}
{/each}
{/if}
</div>
{/if}
{#each groups as group (group.id)}
{@const inside = inGroup(group.id)}
<div class="group folder">
<!-- Draggable, except while it is being renamed: a draggable ancestor stops
the pointer from selecting text in the field it contains. -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="group-head folder-head"
class:over={overGroup === group.id}
class:drop-above={overId === group.id && !overBelow}
class:drop-below={overId === group.id && overBelow}
class:dragging={draggingGroup === group.id}
draggable={renamingGroup !== group.id}
ondragstart={(e) => onGroupDragStart(e, group)}
ondragend={clearDrag}
ondragover={(e) => onGroupDragOver(e, group)}
ondragleave={() => {
overGroup = null;
overId = null;
}}
ondrop={(e) => onGroupDrop(e, group)}
ondblclick={() => startRename(group)}
onkeydown={(e) => {
if (e.key === 'F2') {
e.preventDefault();
startRename(group);
}
}}
>
{#if renamingGroup === group.id}
<!-- Renaming is where the colour is chosen too.
The dot used to be a button in the row, which made the heading
carry four controls — dot, name, Omdøb, Slet — on a line narrow
enough that they crowded the name. Both are edits to the same
thing, so both belong in the same moment: you open the group to
change it, and change what you came to change.
{#if filters.length}
<div class="group">
<div class="group-head">{@render foldHeading('filters', t('sidebar.filters'))}</div>
{#if !app.sectionCollapsed('filters')}
{#each filters as filter (filter.id)}
<a
href="/filter/{filter.id}"
class:active={current === `/filter/${filter.id}`}
onclick={onnavigate}
>
<span class="dot" aria-hidden="true"></span>
{filter.name}
</a>
{/each}
{/if}
</div>
{/if}
`focusout` rather than `blur` closes it, and only when focus has
left the form altogether — clicking a swatch is still inside it.
The swatches also refuse focus on mousedown, because Safari and
Firefox do not focus a button on click, so `relatedTarget` would
be null and the form would shut under the pointer. -->
<form
class="renaming"
onsubmit={(e) => renameGroup(e, group)}
onfocusout={(e) => {
if (!e.currentTarget.contains(e.relatedTarget)) renamingGroup = null;
}}
>
<input
bind:value={groupName}
use:focusOnMount
aria-label={t('sidebar.groupName')}
onkeydown={(e) => e.key === 'Escape' && (renamingGroup = null)}
/>
<div class="swatches" role="group" aria-label={t('group.colorOf', { name: group.name })}>
{#each COLORS as color (color.id)}
<button
type="button"
class="swatch"
class:chosen={(group.color ?? 'graphite') === color.id}
style="background: {colorVar(color.id)}"
title={t(color.name)}
aria-label={t(color.name)}
aria-pressed={(group.color ?? 'graphite') === color.id}
onmousedown={(e) => e.preventDefault()}
onclick={() => app.setGroupColor(group.id, color.id)}
></button>
{/each}
</div>
</form>
{:else}
<!-- The button sits inside the heading, not around it, the same way a
project's title does: a group *is* a heading in the sidebar, and
wrapping an h2 in a button both invalidates the markup and takes
the heading away from a screen reader. The whole heading folds —
at this size a lone chevron is a target you miss, and the name is
where the eye already is. -->
<!-- The chevron folds; the name opens the group's own page. Two
targets in one heading, because they are two different intentions
and the old single one could only ever serve the smaller. -->
<button
class="chevron-button"
onclick={() => app.toggleGroup(group.id)}
aria-expanded={!group.collapsed}
aria-label={group.collapsed
? t('sidebar.unfold', { name: group.name })
: t('sidebar.fold', { name: group.name })}
>
<svg
class="chevron"
class:collapsed={group.collapsed}
viewBox="0 0 24 24"
aria-hidden="true"
>
<path d="M6 9l6 6 6-6" />
</svg>
</button>
<h2 class="fold">
<!-- Navigation waits two tenths of a second for a second click. A
link cannot know a double is coming without pausing, and the
alternative was worse than the pause: the first click opened the
page while the rename form was appearing, and the colour swatches
moved out from under the pointer. Two tenths is under the double
click threshold and over the threshold of noticing. -->
<a
href="/gruppe/{group.id}"
onclick={(e) => {
e.preventDefault();
clearTimeout(navTimer);
if (e.detail > 1) return;
navTimer = setTimeout(() => {
onnavigate();
goto(`/gruppe/${group.id}`);
}, 200);
}}
>
<span
class="dot group-dot"
style="background: {colorVar(group.color)}"
aria-hidden="true"
></span>
{group.name}
</a>
</h2>
<span class="count">{inside.length}</span>
{/if}
</div>
{#if labels.length}
<div class="group">
<div class="group-head">{@render foldHeading('labels', t('sidebar.labels'))}</div>
{#if !app.sectionCollapsed('labels')}
{#each labels.filter((l) => l.task_count > 0) as label (label.id)}
<a
href="/etiket/{encodeURIComponent(label.name)}"
class:active={current === `/etiket/${encodeURIComponent(label.name)}`}
onclick={onnavigate}
>
<span class="dot" aria-hidden="true"></span>
{label.name}
<span class="count">{label.task_count}</span>
</a>
{/each}
{/if}
</div>
{/if}
{#if !group.collapsed}
{#each inside as project (project.id)}
{@render projectRow(project, true)}
{/each}
{#if !inside.length}
<p class="empty">{t('sidebar.emptyGroup')}</p>
{/if}
{/if}
</div>
{/each}
<div class="foot">
<a
href="/indstillinger"
class="settings"
class:active={current.startsWith('/indstillinger')}
onclick={onnavigate}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="3" />
<path
d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 11-4 0v-.09A1.65 1.65 0 008 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06A1.65 1.65 0 004.6 15a1.65 1.65 0 00-1.51-1H3a2 2 0 110-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 114 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06A1.65 1.65 0 0019.4 9v0a1.65 1.65 0 001.51 1H21a2 2 0 110 4h-.09a1.65 1.65 0 00-1.51 1z"
/>
</svg>
{t('nav.settings')}
</a>
<!-- A quiet indicator, not an alarm: losing the socket for a moment is
normal, and the app keeps working without it. -->
{#if !app.connected}
<span class="offline" title={t('nav.offlineHint')}>{t('nav.offline')}</span>
{#if shared.length}
<div class="group">
<div class="group-head">{@render foldHeading('shared', t('sidebar.shared'))}</div>
{#if !app.sectionCollapsed('shared')}
{#each shared as project (project.id)}
{@render projectRow(project, false)}
{/each}
{/if}
</div>
{/if}
<button class="user" onclick={signOut}>
<span class="avatar" style="background: {app.user?.avatar_color}">
{app.user?.name?.[0]?.toUpperCase() ?? '?'}
</span>
<span class="user-name">{app.user?.name}</span>
<span class="signout">{t('nav.signOut')}</span>
</button>
{#if filters.length}
<div class="group">
<div class="group-head">{@render foldHeading('filters', t('sidebar.filters'))}</div>
{#if !app.sectionCollapsed('filters')}
{#each filters as filter (filter.id)}
<a
href="/filter/{filter.id}"
class:active={current === `/filter/${filter.id}`}
onclick={onnavigate}
>
<span class="dot" aria-hidden="true"></span>
{filter.name}
</a>
{/each}
{/if}
</div>
{/if}
{#if labels.length}
<div class="group">
<div class="group-head">{@render foldHeading('labels', t('sidebar.labels'))}</div>
{#if !app.sectionCollapsed('labels')}
{#each labels.filter((l) => l.task_count > 0) as label (label.id)}
<a
href="/etiket/{encodeURIComponent(label.name)}"
class:active={current === `/etiket/${encodeURIComponent(label.name)}`}
onclick={onnavigate}
>
<span class="dot" aria-hidden="true"></span>
{label.name}
<span class="count">{label.task_count}</span>
</a>
{/each}
{/if}
</div>
{/if}
<div class="foot">
<a
href="/indstillinger"
class="settings"
class:active={current.startsWith('/indstillinger')}
onclick={onnavigate}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="3" />
<path
d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 11-4 0v-.09A1.65 1.65 0 008 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06A1.65 1.65 0 004.6 15a1.65 1.65 0 00-1.51-1H3a2 2 0 110-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 114 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06A1.65 1.65 0 0019.4 9v0a1.65 1.65 0 001.51 1H21a2 2 0 110 4h-.09a1.65 1.65 0 00-1.51 1z"
/>
</svg>
{t('nav.settings')}
</a>
<!-- A quiet indicator, not an alarm: losing the socket for a moment is
normal, and the app keeps working without it. -->
{#if !app.connected}
<span class="offline" title={t('nav.offlineHint')}>{t('nav.offline')}</span>
{/if}
<button class="user" onclick={signOut}>
<span class="avatar" style="background: {app.user?.avatar_color}">
{app.user?.name?.[0]?.toUpperCase() ?? '?'}
</span>
<span class="user-name">{app.user?.name}</span>
<span class="signout">{t('nav.signOut')}</span>
</button>
</div>
</div>
<!-- Also a slider for the keyboard: arrow keys move it, and Home puts it back.
A drag handle with no keyboard equivalent is a setting some people simply
@@ -970,11 +982,31 @@
padding: var(--s4) var(--s3);
background: var(--surface-sunken);
border-right: 1px solid var(--line);
overflow-y: auto;
/* Selve bjælken ruller ikke længere — .scroller gør. `hidden` frem for
ingenting, fordi den stadig er den yderste kant: løber en række over,
skal den klippes her og ikke skubbe kolonnen bredere. */
overflow: hidden;
padding-left: max(var(--s3), env(safe-area-inset-left));
position: relative;
}
/* `min-height: 0`, ellers nægter et flex-element at blive lavere end sit
indhold, og boksen ville vokse ned gennem bunden af skærmen i stedet for at
rulle — samme fælde som `min-width: 0` på kontonavnet længere nede.
Ikke `overflow-x: hidden` her: `overflow-y: auto` gør den anden akse til
`auto` af sig selv, og en vandret rullebjælke under menuen er en fejl i en
række, ikke i boksen. At klippe den ville skjule fejlen; prøven måler begge
bokse. */
.scroller {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: var(--s5);
}
/* Wider than it looks: a 1px target is a target you miss. The visible line stays
the border; this only widens what the pointer hits.
@@ -1213,6 +1245,11 @@
The dot is the resting state; the chevron takes over on hover or focus, and
stays put while the group is folded, so a folded group still shows the way
back open. */
/* Uden baggrund. Knappen dækkede prikken med et felt i --surface, og på den
sænkede bjælke er det en lys firkant: en foldet gruppe stod med en hvid
klods ud for navnet permanent, fordi chevronen bliver stående, når gruppen
er lukket. Prikken tones ud i stedet for at blive dækket — det er samme
skifte, uden noget at male henover den med. */
.chevron-button {
position: absolute;
left: calc(var(--s2) - 5px);
@@ -1223,7 +1260,13 @@
border-radius: var(--radius-sm);
color: inherit;
opacity: 0;
background: var(--surface);
/* Fordi prikken herunder tones ud frem for at blive dækket. En `opacity`
under 1 laver en stakkontekst og maler elementet, som var det placeret
med `z-index: 0` — så prikken, der står senere i DOM'en, lagde sig oven
på knappen i det øjeblik den forsvandt. Den var usynlig og tog stadig
klikket: prøven brugte tredive sekunder på at folde en gruppe, der ikke
ville foldes. Ét lag op, og rækkefølgen er den samme som før. */
z-index: 1;
}
.folder-head:hover .chevron-button,
@@ -1232,9 +1275,18 @@
opacity: 1;
}
/* Præcis de tre tilfælde ovenfor, læst nedad i stedet for opad: står chevronen
der, er prikken væk. `opacity` og ikke `display`, for de to ligger oven i
hinanden — forsvandt prikken ud af flowet, ville navnet rykke sig hver gang
pilen kom frem. */
.folder-head:hover .group-dot,
.folder-head:has(.chevron-button:focus-visible) .group-dot,
.folder-head:has(.chevron-button[aria-expanded='false']) .group-dot {
opacity: 0;
}
.chevron-button:hover {
color: var(--ink);
background: var(--surface);
}
.fold a,
+1
View File
@@ -18,6 +18,7 @@ export const TASK = 'application/x-verdande-task';
export const PROJECT = 'application/x-verdande-project';
export const GROUP = 'application/x-verdande-group';
export const NOTE = 'application/x-verdande-note';
export const SECTION = 'application/x-verdande-section';
/** Starts a drag carrying one id of the given kind. */
export function startDrag(event, type, id) {
-1
View File
@@ -437,7 +437,6 @@
padding-left: max(var(--s3), env(safe-area-inset-left));
padding-right: var(--s3);
border-right: 1px solid var(--line);
overflow-y: auto;
}
}
</style>
+18 -1
View File
@@ -545,6 +545,11 @@
<span class="filed">{projectName(note.project_id)}</span>
{/if}
</button>
<!-- De to knapper står oven på hinanden i en smal søjle.
Ved siden af hinanden tog de toogfyrre pixels af en liste,
der er tre hundrede bred — og de bruges sjældent, mens
titlen og uddraget læses hele tiden. -->
<div class="rowactions">
<!-- Én note lagt væk, uden at markere den først. Markeringen er
til flere; det her er til den ene, man står med — og i
arkivet er det vejen tilbage. -->
@@ -568,6 +573,7 @@
>
{note.pinned ? '★' : '☆'}
</button>
</div>
</div>
</li>
{/each}
@@ -890,9 +896,20 @@
gap: var(--s1);
}
/* De sjældne handlinger i en søjle, ikke en række. To knapper ved siden af
hinanden er toogfyrre pixels taget fra en liste på tre hundrede — og de er
det, man rører en gang imellem, mens titlen er det, man læser hele tiden. */
.rowactions {
display: flex;
flex-direction: column;
gap: 1px;
flex: none;
}
.star {
flex: none;
width: 24px;
width: 22px;
height: 22px;
color: var(--ink-faint);
opacity: 0;
font-size: var(--text-sm);
+145 -7
View File
@@ -6,7 +6,7 @@
import { app, completedView, projectName } from '$lib/stores.svelte.js';
import { COLORS, colorVar } from '$lib/colors.js';
import { eventName, eventDetail } from '$lib/events.js';
import { TASK, carries, dragged, accept } from '$lib/dnd.js';
import { TASK, SECTION, startDrag, carries, dragged, accept } from '$lib/dnd.js';
import TaskList from '$lib/components/TaskList.svelte';
import TaskRow from '$lib/components/TaskRow.svelte';
import QuickAdd from '$lib/components/QuickAdd.svelte';
@@ -386,6 +386,80 @@
}
}
// --- moving a section ---------------------------------------------------------
//
// The order of the sections is the order of the plan — "Planlægning" stands
// before "I gang" because that is when the work happens — and until this it
// could only be changed by deleting a section and making it again, which drops
// its tasks into the unsectioned area on the way past.
//
// The whole heading is the handle, not a grip beside it. A section heading
// holds nothing that is itself dragged, so there is no gesture to steal — which
// is the reason the sidebar's rows needed one, and the reason these do not.
let draggingSection = $state(null);
/** The heading whose gap is lit up, and which side of it. */
let overSectionEdge = $state(null);
let overSectionBelow = $state(false);
function clearSectionDrag() {
draggingSection = null;
overSectionEdge = null;
}
function onSectionDragStart(event, section) {
draggingSection = section.id;
startDrag(event, SECTION, section.id);
}
function onHeadDragOver(event, section) {
if (!canEdit || !carries(event, SECTION) || draggingSection === section.id) return;
accept(event);
const box = event.currentTarget.getBoundingClientRect();
overSectionEdge = section.id;
overSectionBelow = event.clientY > box.top + box.height / 2;
}
/**
* Optimistic, and the whole list at once.
*
* The array is what the page renders from, so moving the entry moves the
* section; `sort_order` is set alongside it only so a later read of this list
* agrees with what is on screen. If the write fails the previous array goes
* back — a half-moved section is worse than one that did not move.
*/
async function onHeadDrop(event, target) {
if (!carries(event, SECTION)) return;
event.preventDefault();
event.stopPropagation();
const id = dragged(event, SECTION) || draggingSection;
const below = overSectionBelow;
clearSectionDrag();
if (!id || id === target.id || !canEdit) return;
const moved = sections.find((s) => s.id === id);
if (!moved) return;
const without = sections.filter((s) => s.id !== id);
const at = without.findIndex((s) => s.id === target.id);
if (at < 0) return;
const ordered = [...without];
ordered.splice(below ? at + 1 : at, 0, moved);
const previous = sections;
sections = ordered.map((s, i) => ({ ...s, sort_order: i }));
try {
await api.reorderSections(
project.id,
ordered.map((s) => s.id)
);
} catch (e) {
sections = previous;
app.toast(humanMessage(e));
}
}
async function removeSection(section) {
const count = open.filter((t) => t.section_id === section.id).length;
const warning = count
@@ -739,7 +813,22 @@
ondragleave={() => (overSection = null)}
ondrop={(e) => onSectionDrop(e, section.id)}
>
<div class="section-head">
<!-- Draggable, except while it is being renamed: a draggable element
will not let the pointer select text in the field it contains.
The sidebar's groups learned this first. -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="section-head"
class:dragging={draggingSection === section.id}
class:drop-above={overSectionEdge === section.id && !overSectionBelow}
class:drop-below={overSectionEdge === section.id && overSectionBelow}
draggable={canEdit && renamingSection !== section.id}
ondragstart={(e) => onSectionDragStart(e, section)}
ondragend={clearSectionDrag}
ondragover={(e) => onHeadDragOver(e, section)}
ondragleave={() => (overSectionEdge = null)}
ondrop={(e) => onHeadDrop(e, section)}
>
{#if renamingSection === section.id}
<form onsubmit={(e) => renameSection(e, section)}>
<input
@@ -1441,12 +1530,61 @@
gap: var(--s3);
background: var(--surface);
border-radius: var(--radius);
padding: var(--s2) var(--s3);
margin-bottom: var(--s1);
/* Pulled out to the padding of the rows below, so the band lines up with the
text it heads rather than with the rows' own inset. */
margin-left: calc(var(--s2) * -1);
margin-right: calc(var(--s2) * -1);
/* The same inset as the rows, and the same width.
*
* It used to be pulled out by `--s2` and padded by `--s3`, which is the same
* idea done with two different numbers: the band ended up eight pixels wider
* than the list on each side, and its text four pixels *outside* the column
* the rows keep. On the left that was invisible. On the right it was Omdøb
* and Slet standing four pixels past everything above and below them, which
* is exactly far enough to look like a mistake and not far enough to see why.
*
* A row is `.row` with `padding: var(--s2)` inside a box with none, so the
* column the eye follows starts one `--s2` in. The band matches that by
* having the same padding and no margin at all — no arithmetic, and nothing
* to keep in step if the rows are ever re-inset.
*/
padding: var(--s2);
/* For the drop line, which is drawn inside the band's own box. */
position: relative;
}
/* Grabbable, and only where it can actually be grabbed: the rename field is
not draggable, and a heading somebody may only read is not either. */
.section-head[draggable='true'] {
cursor: grab;
}
.section-head.dragging {
opacity: 0.4;
cursor: grabbing;
}
/* A line in the gap rather than a box around the heading. The gap is where it
will land, and a lit heading says "into this one" — which is what a task
dragged onto a section means, and this is not that. */
.section-head::after {
content: '';
position: absolute;
left: 0;
right: 0;
height: 2px;
border-radius: 1px;
background: var(--accent);
opacity: 0;
pointer-events: none;
transition: opacity var(--fast) var(--ease);
}
.section-head.drop-above::after {
top: -1px;
opacity: 1;
}
.section-head.drop-below::after {
bottom: -1px;
opacity: 1;
}
.section-head h2 {