Compare commits

..
Author SHA1 Message Date
kristianwindandClaude Opus 5 0110a86858 fix(opgave): teksten kan læses færdig, også på en telefon
Rapporteret med et billede af en beskrivelse, der manglede sit første bogstav på
hver linje: "hermostat RU0910301440" og "atteries". Det er ikke manglende tegn —
det er et felt, der er rullet til højre. Og det ligner ikke rulning; det ligner,
at bogstaverne ikke er der.

To ting på én skærm.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 14:00:22 +02:00
Kristian Wind f68d6a8c15 Merge pull request #80 from kristianwind/billeder-kan-ses-og-kopieres
CI / OpenAPI is valid (push) Successful in 11s
CI / End-to-end (push) Successful in 12m56s
CI / Docker image builds (push) Successful in 2m29s
CI / Go (push) Failing after 15m40s
Release / Build and publish image (push) Failing after 7s
feat(noter): et indsat billede kan ses helt og kopieres videre
2026-09-14 22:04:38 +02:00
6 changed files with 229 additions and 24 deletions
+18 -10
View File
@@ -1,13 +1,21 @@
name: Documentation
# The docs are built on every push so a broken build is caught immediately, but
# only published once GitHub Pages has been enabled for the repository. Until the
# owner decides to publish, this job builds and stops.
# Built on every push, and published from main.
#
# And on pull requests, where the build is the only check there is: `mkdocs build
# --strict` fails on a link to a page that does not exist, and building only after
# the merge means finding that out on `main`. Nothing is published from a pull
# request — the `deploy` job below runs on a manual dispatch and nothing else.
# The publishing used to wait for a manual dispatch, with a note saying it would
# run "once Pages is enabled". Pages was enabled — and the condition was never
# changed, so the site was built forty times and published none of them. What it
# showed was a Verdande from before v0.43: /links/, /beacon/ and /notifications/
# all answered 404, months after they were written. Nothing failed, because
# building *is* what the job was asked to do.
#
# The lesson is not about this line. It is that "publish later" needs something
# that fails when later arrives — a switch that is off and silent looks exactly
# like a switch that is on.
#
# Pull requests still build and never publish: `mkdocs build --strict` fails on a
# link to a page that does not exist, and building only after the merge means
# finding that out on `main`.
on:
push:
branches: [main]
@@ -54,9 +62,9 @@ jobs:
deploy:
needs: build
runs-on: ubuntu-latest
# Only runs once Pages is enabled. Until then the build above still guards
# against a broken docs tree.
if: github.event_name == 'workflow_dispatch'
# From main, and by hand. Never from a pull request — a fork could otherwise
# publish over the site by opening one.
if: github.event_name != 'pull_request'
environment:
name: github-pages
url: ${{ steps.deploy.outputs.page_url }}
+17 -1
View File
@@ -20,7 +20,8 @@ Which is what a to-do list is.
All six phases are built. See [What works today](#what-works-today) for what is
tested and what is not.
Documentation: [`docs/`](docs/), built with Material for MkDocs.
Documentation: **<https://kristianwind.github.io/verdande/>** — the source is in
[`docs/`](docs/), built with Material for MkDocs and published from `main`.
## Why this exists
@@ -43,6 +44,10 @@ and an MCP endpoint so Claude can work your task list directly.
the text, which shares it with her and tells her. `#project` and
`[[another note]]` are links, and a task shows the notes that mention it. A zip of Markdown goes in — Obsidian, Bear, or Apple Notes through
the exporter in [`tools/`](tools/) — and comes back out the same shape.
- **Notes are sealed at rest.** Title and body are encrypted in the database under
a key in a file beside it — the title too, because a title beside a sealed body
leaves the beginning of every note in the open. A copy of the file is not a copy
of the notes.
- **Drag where it means something.** Reorder a list or a board, file a project under
a group, drop a task on another project, or drop it on another day.
- **Sharing.** Owner, editor and viewer roles. There is no open registration:
@@ -128,6 +133,17 @@ Apple Reminders, forward mail into it, and add it to Claude as a connector.
| OpenAPI 3.1 spec, checked against the router | Done, tested |
| End-to-end smoke tests (Playwright) | Done |
| Documentation, landing page, licence | Done |
| Notes sealed at rest: title and body encrypted in the database | Done, tested |
| `@name` in a note shares it with that person, and tells them | Done, tested |
| Share a project with somebody who already has an account, by name | Done, tested |
| Invitations that have not been taken up stay visible, and can be withdrawn | Done, tested |
| Told when a project is shared with you — and able to leave it | Done, tested |
| Push from other programs: a webhook address per person | Done, tested |
| Today's plan as a card on **I dag**, not only as a notification | Done, tested |
| Search the settings, from the page or from ⌘K | Done, tested |
| Choose which browser a link opens in | Done, tested |
| Images in notes: open full size, copy to the clipboard | Done, tested |
| Install count: two values, once a day, one click to stop | Done, tested |
**Not finished:** Web Push, the AI providers and the Google Calendar API calls are
implemented against their specifications and have only ever been exercised there,
+8
View File
@@ -138,6 +138,14 @@ Search finds a word wherever it is:
body. A word in the title is what the note is about; the same word once in a long
body is a mention.
Two places to search from, and they now agree. The field above the list filters it
in place. **⌘K** searches everything at once — tasks, projects, notes and the
settings — and picking a note from it opens that note *with the rest of the matches
still in the list beside it*. It used to drop you on the ordinary list, so the
other results existed only in the popup you had just closed. The address carries
the search, which also means a search worth keeping can be bookmarked or sent to
somebody.
## Putting notes away
Two different things, and the difference matters:
+58 -11
View File
@@ -1326,6 +1326,64 @@
<a href="https://github.com/kristianwind/verdande/releases">the releases page</a>.
</p>
<ul class="lately">
<li>
<span class="ver">0.47</span>
Click a picture in a note and it opens at full size, with a button that
copies it to the clipboard. In the note it is scaled to the width of the
column — a photo off a phone is four thousand pixels across — so what you
were looking at was a thumbnail that did not look like one.
</li>
<li>
<span class="ver">0.47</span>
Search in ⌘K, pick a note, and the rest of the matches stay in the list
beside it. They used to exist only in the popup you had just closed.
</li>
<li>
<span class="ver">0.46</span>
Share a project with somebody who already has an account by picking their
name, rather than by spelling an email address you would have to know.
Invitations that have not been taken up stay visible so they do not get
sent twice, and can be withdrawn.
</li>
<li>
<span class="ver">0.46</span>
You are told when a project is shared with you, and there is a
<strong>Leave the project</strong> button. Being let in without being
asked, without being told, and with no way out was three things at once.
</li>
<li>
<span class="ver">0.45</span>
Search for a setting instead of remembering which tab it was under — in
the settings themselves and in ⌘K. It knows them by what they do, so
<em>beacon</em> finds the install count even though that is not its name.
</li>
<li>
<span class="ver">0.45</span>
Today's plan is a card on <strong>I dag</strong> rather than only a
message in the morning that was gone if you did not read it at once. And
a link can be told which browser to open in.
</li>
<li>
<span class="ver">0.44</span>
An optional AI layer, on your own key: it tidies the inbox, pulls the
tasks out of a meeting note, writes a morning plan, turns
<em>"SV: SV: Vedr. levering uge 12"</em> into what the mail actually asks
for, and answers questions about your own notes with links to what the
answer rests on. Every one of them is a line you accept, edit or refuse —
it suggests, it never writes.
</li>
<li>
<span class="ver">0.44</span>
Administrators are told in the bell when a new version is out, once per
version, and the message leads to the button that restarts.
</li>
<li>
<span class="ver">0.43</span>
Anything that can call a URL can put a task in your inbox — a shortcut on
your phone, a script, a service. Notes can be handed to somebody who has
no account yet, and writing <code>@Sofie</code> in a note shares it with
her and tells her. Unread counts show on the app's icon in the Dock.
</li>
<li>
<span class="ver">0.42</span>
A sixth theme, <strong>Charcoal</strong> — a warm near-black after Apple
@@ -1362,17 +1420,6 @@
body to move it. A single note can be shared with one person, and a
<code>[[note]]</code> is a link you follow inline.
</li>
<li>
<span class="ver">0.34</span>
Sections can be dragged into the order the work actually happens in. The
sidebar keeps its head still while the projects scroll.
</li>
<li>
<span class="ver">0.33</span>
Notes grew an archive, selection of many at once, pictures you can paste
or drag in, a source view, and a copy button on code blocks. Search
stopped answering by date and started answering by relevance.
</li>
</ul>
</section>
+72
View File
@@ -4656,11 +4656,17 @@ test('en søgning i ⌘K efterlader de træffere, den fandt', async ({ page }) =
// Gemt ved at forlade feltet, som resten af fladen gør det — der er ingen
// Gem-knap at huske.
//
// Der ventes på "Gemt", før den næste note laves. Uden det klikkes "Ny note",
// mens den forrige stadig er undervejs, og listen når ikke at få den med — det
// fejlede i CI én gang ud af tre kørsler, og bestod hver gang her på maskinen.
// En prøve, der er hurtigere end programmet, måler maskinen den kører på.
for (const title of ['Fyrreskov nord', 'Fyrreskov syd', 'Egeskov']) {
await page.getByRole('button', { name: 'Ny note' }).click();
const body = page.getByLabel('Notens tekst');
await body.fill(`${title}\n\nnoget om den`);
await body.blur();
await expect(page.getByText('Gemt', { exact: true })).toBeVisible();
await expect(page.locator('.list').getByText(title, { exact: true })).toBeVisible();
}
@@ -4749,3 +4755,69 @@ test('et indsat billede kan åbnes stort og kopieres', async ({ page, context })
expect(trouble).toEqual([]);
});
/**
* En opgaves tekst kan læses færdig, også på en telefon.
*
* Rapporteret fra en telefon, med et billede af en beskrivelse, der manglede sit
* første bogstav på hver linje: "hermostat RU0910301440" og "atteries". Det er
* ikke manglende tegn — det er et felt, der er rullet til højre. En beskrivelse er
* tit et klippet stykke mail med en URL i, et <textarea> ombryder ved mellemrum,
* og en URL har ingen.
*
* Og titlen var et <input>, som ikke kan ombryde overhovedet. "tado°: Your Smart
* Thermostat needs new batteries" er en almindelig opgavetitel, og halvdelen af
* den stod uden for kanten.
*/
test('en opgaves tekst kan læses færdig på en telefon', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/');
const langTitel = 'tado°: Your Smart Thermostat needs new batteries';
// "i dag" for at den lander på siden, prøven står på — parseren klipper det ud
// af titlen igen, så den hedder præcis det, den skal.
await page.getByLabel('Ny opgave').fill(`${langTitel} i dag`);
await page.getByLabel('Ny opgave').press('Enter');
await page.getByText('tado°', { exact: false }).first().click();
const titel = page.getByLabel('Opgavens tekst');
await expect(titel).toBeVisible();
await expect(titel).toHaveValue(langTitel);
// Beskrivelsen: en URL uden et eneste mellemrum at bryde ved.
const url = 'https://link.tado.com/emails/8/ttid/1-a37f2c9b4e6d8a1f0c3b5e7d9a2f4c6b8e0d1a3f5c7b9e2d4a6f8c0b3e5d7a9f';
const beskrivelse = page.getByLabel('Beskrivelse');
await beskrivelse.fill(`Thermostat RU0910301440\nBatteries\n${url}`);
await beskrivelse.blur();
// Målt frem for beskrevet: et felt, der ruller vandret, har en scrollWidth
// større end sin clientWidth. Det var dét, der klippede venstre kant.
//
// Felterne måles gennem deres egne lokatorer og ikke med en forespørgsel på
// aria-label: beskrivelsen har et <label for>, ikke et aria-label, så en
// forespørgsel fandt ingenting og ville have bestået tomt.
const spild = [];
for (const [navn, felt] of [
['Opgavens tekst', titel],
['Beskrivelse', beskrivelse]
]) {
const m = await felt.evaluate((el) => ({ ind: el.scrollWidth, ude: el.clientWidth }));
if (m.ind - m.ude > 1) spild.push(`${navn}: ${m.ind} > ${m.ude}`);
}
expect(spild).toEqual([]);
// Beskrivelsen løber ikke over i Chromium, heller ikke uden reglen — det var
// Safari, den blev set i, og det er en anden motor. Prøven måler derfor reglen
// selv frem for dens virkning. Det er svagere, og det står her, så den næste
// ved det: den vogter mod at reglen bliver slettet, ikke mod at den er forkert.
await expect(beskrivelse).toHaveCSS('overflow-wrap', 'break-word');
// Og titlen er blevet høj nok til at rumme sig selv frem for at klippe.
const linjer = await titel.evaluate((el) => ({
hoejde: el.clientHeight,
linje: parseFloat(getComputedStyle(el).lineHeight)
}));
expect(linjer.hoejde).toBeGreaterThan(linjer.linje * 1.5);
expect(trouble).toEqual([]);
});
+56 -2
View File
@@ -171,6 +171,32 @@
* took is said out loud, because a title that edits itself without a word is
* worse than one that does nothing.
*/
/**
* Lader et felt være lige så højt som sin tekst.
*
* `height: auto` først, og så `scrollHeight`: uden nulstillingen kan feltet kun
* vokse, for scrollHeight på et felt, der allerede er for højt, er dets egen
* højde.
*/
function grow(node) {
const fit = () => {
node.style.height = 'auto';
node.style.height = `${node.scrollHeight}px`;
};
fit();
node.addEventListener('input', fit);
// Bredden skifter, når ruden gør — og en tekst, der fyldte to linjer, fylder
// tre på en smallere skærm uden at der er rørt ved den.
const watch = new ResizeObserver(fit);
watch.observe(node);
return {
destroy() {
node.removeEventListener('input', fit);
watch.disconnect();
}
};
}
async function saveContent() {
if (!task) return;
const trimmed = content.trim();
@@ -418,13 +444,27 @@
<div class="scroll">
<div class="field">
<input
<!-- Et felt frem for en linje. En opgavetitel er tit længere end en telefon
er bred — "tado°: Your Smart Thermostat needs new batteries" er en
almindelig en — og et <input> kan ikke ombryde: det ruller, og så står
halvdelen af overskriften uden for kanten. -->
<textarea
class="title"
rows="1"
use:focusOnMount
use:grow
bind:value={content}
onblur={saveContent}
onkeydown={(e) => {
// Retur gemmer frem for at lave en ny linje. En titel er én linje,
// uanset hvor mange den fylder på skærmen.
if (e.key === 'Enter') {
e.preventDefault();
e.currentTarget.blur();
}
}}
aria-label={t('detail.text')}
/>
></textarea>
</div>
<div class="field">
@@ -894,6 +934,16 @@
resize: vertical;
font-family: inherit;
line-height: 1.5;
/* En beskrivelse er tit et klippet stykke mail med en URL i. Et <textarea>
ombryder ved mellemrum, og en URL har ingen — så feltet rullede vandret,
og på en telefon stod teksten forskudt med venstre kant klippet af. Det
ser ikke ud som rulning; det ser ud som manglende bogstaver.
`break-word` og ikke `anywhere`: den første bryder kun et ord, der ikke kan
stå på en linje alene, mens den anden bryder ved først givne lejlighed og
delte "GarageRisteriet" midt i en stavelse. Se h1 på projektets side — den
lektion er betalt én gang. */
overflow-wrap: break-word;
}
/* The title is the one field that should not look like a field until you are in
@@ -905,6 +955,10 @@
background: transparent;
border-color: transparent;
padding-left: 0;
/* Højden sættes af `grow`, så håndtaget ville love noget, det ikke kan
holde: trækker man i det, retter det næste bogstav det tilbage. */
resize: none;
overflow: hidden;
}
.title:hover {