Compare commits

..
8 Commits
Author SHA1 Message Date
Kristian Wind 804c81f74e Merge pull request #84 from kristianwind/soegefeltet-taber-ikke-bogstaver
CI / OpenAPI is valid (push) Successful in 9s
CI / End-to-end (push) Successful in 9m21s
CI / Docker image builds (push) Successful in 1m47s
Release / Build and publish image (push) Failing after 4s
CI / Go (push) Failing after 14m17s
fix(noter): søgefeltet taber ikke bogstaver, og det nyeste svar vinder
2026-09-16 08:34:59 +02:00
kristianwindandClaude Opus 5 87054ac8ca fix(noter): søgefeltet taber ikke bogstaver, og det nyeste svar vinder
Fra en skærmoptagelse, to billeder i sekundet:

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

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

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

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

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

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

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

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

To ting på én skærm.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-15 14:00:22 +02:00
7 changed files with 341 additions and 60 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>
+150 -3
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();
}
@@ -4681,10 +4687,11 @@ test('en søgning i ⌘K efterlader de træffere, den fandt', async ({ page }) =
await expect(list.getByText('Fyrreskov syd')).toBeVisible();
await expect(list.getByText('Egeskov')).toHaveCount(0);
// Og adressen lyver ikke, når feltet tømmes: uden det ville et genbesøg hente
// en søgning tilbage, man selv havde ryddet væk.
// Feltet kan tømmes, og listen kommer hel igen. Adressen beholder sit `q` —
// den skrives kun af navigation. Den første udgave skrev den ved hvert
// tastetryk, så den kunne deles, og det kostede bogstaver ud af feltet; se
// prøven nedenfor.
await page.getByPlaceholder('Søg i noter').fill('');
await expect(page).not.toHaveURL(/[?&]q=/);
await expect(list.getByText('Egeskov')).toBeVisible();
expect(trouble).toEqual([]);
@@ -4749,3 +4756,143 @@ test('et indsat billede kan åbnes stort og kopieres', async ({ page, context })
expect(trouble).toEqual([]);
});
/**
* En opgaves tekst kan læses færdig, også på en telefon.
*
* Rapporteret fra en telefon, med et billede af en beskrivelse, der manglede sit
* første bogstav på hver linje: "hermostat RU0910301440" og "atteries". Det er
* ikke manglende tegn — det er et felt, der er rullet til højre. En beskrivelse er
* tit et klippet stykke mail med en URL i, et <textarea> ombryder ved mellemrum,
* og en URL har ingen.
*
* Og titlen var et <input>, som ikke kan ombryde overhovedet. "tado°: Your Smart
* Thermostat needs new batteries" er en almindelig opgavetitel, og halvdelen af
* den stod uden for kanten.
*/
test('en opgaves tekst kan læses færdig på en telefon', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/');
const langTitel = 'tado°: Your Smart Thermostat needs new batteries';
// "i dag" for at den lander på siden, prøven står på — parseren klipper det ud
// af titlen igen, så den hedder præcis det, den skal.
await page.getByLabel('Ny opgave').fill(`${langTitel} i dag`);
await page.getByLabel('Ny opgave').press('Enter');
await page.getByText('tado°', { exact: false }).first().click();
const titel = page.getByLabel('Opgavens tekst');
await expect(titel).toBeVisible();
await expect(titel).toHaveValue(langTitel);
// Beskrivelsen: en URL uden et eneste mellemrum at bryde ved.
const url = 'https://link.tado.com/emails/8/ttid/1-a37f2c9b4e6d8a1f0c3b5e7d9a2f4c6b8e0d1a3f5c7b9e2d4a6f8c0b3e5d7a9f';
const beskrivelse = page.getByLabel('Beskrivelse');
await beskrivelse.fill(`Thermostat RU0910301440\nBatteries\n${url}`);
await beskrivelse.blur();
// Målt frem for beskrevet: et felt, der ruller vandret, har en scrollWidth
// større end sin clientWidth. Det var dét, der klippede venstre kant.
//
// Felterne måles gennem deres egne lokatorer og ikke med en forespørgsel på
// aria-label: beskrivelsen har et <label for>, ikke et aria-label, så en
// forespørgsel fandt ingenting og ville have bestået tomt.
const spild = [];
for (const [navn, felt] of [
['Opgavens tekst', titel],
['Beskrivelse', beskrivelse]
]) {
const m = await felt.evaluate((el) => ({ ind: el.scrollWidth, ude: el.clientWidth }));
if (m.ind - m.ude > 1) spild.push(`${navn}: ${m.ind} > ${m.ude}`);
}
expect(spild).toEqual([]);
// Beskrivelsen løber ikke over i Chromium, heller ikke uden reglen — det var
// Safari, den blev set i, og det er en anden motor. Prøven måler derfor reglen
// selv frem for dens virkning. Det er svagere, og det står her, så den næste
// ved det: den vogter mod at reglen bliver slettet, ikke mod at den er forkert.
await expect(beskrivelse).toHaveCSS('overflow-wrap', 'break-word');
// Og titlen er blevet høj nok til at rumme sig selv frem for at klippe.
const linjer = await titel.evaluate((el) => ({
hoejde: el.clientHeight,
linje: parseFloat(getComputedStyle(el).lineHeight)
}));
expect(linjer.hoejde).toBeGreaterThan(linjer.linje * 1.5);
expect(trouble).toEqual([]);
});
/**
* Søgefeltet taber ikke bogstaver, og listen viser det, der blev spurgt om.
*
* Fra en skærmoptagelse, to billeder i sekundet:
*
* 2,5 s feltet "verd" listen ufiltreret
* 3,0 s feltet "nde" listen filtreret
* 6,0 s feltet "verdande" listen ufiltreret
* 6,5 s feltet tomt listen filtreret
*
* To fejl oven i hinanden.
*
* Bogstaverne forsvandt, fordi adressen blev skrevet ved hvert tastetryk:
* `replaceState` opdaterer `$page.url` et øjeblik efter, den kaldes, så en effekt
* læste den forrige adresse og skrev den gamle værdi tilbage i feltet.
*
* Og resultaterne landede for sent, fordi to hentninger kunne være undervejs på
* én gang uden at nogen holdt styr på, hvilken der gjaldt. Den langsomste vandt.
*/
test('søgefeltet taber ikke bogstaver, og det nyeste svar vinder', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
// Sin egen note at finde. Et ord fra en anden prøve ville gøre den her
// afhængig af rækkefølgen, og den kobling har allerede narret mig i denne fil.
await page.getByRole('button', { name: 'Ny note' }).click();
const krop = page.getByLabel('Notens tekst');
await krop.fill('Kaffemølle\n\nnoget om den');
await krop.blur();
await expect(page.getByText('Gemt', { exact: true })).toBeVisible();
await expect(page.locator('.list').getByText('Kaffemølle', { exact: true })).toBeVisible();
// Skrevet i et tempo, et menneske kan skrive i. Det var hastigheden, der
// afslørede det: en pause mellem hvert bogstav skjuler kapløbet helt.
const felt = page.getByPlaceholder('Søg i noter');
await felt.click();
await page.keyboard.type('kaffemølle', { delay: 40 });
await expect(felt).toHaveValue('kaffemølle');
// Og det bliver stående. Fejlen viste sig et halvt sekund efter, ikke med det
// samme — adressen skal nå at komme tilbage, før den kan skrive over noget.
await page.waitForTimeout(1500);
await expect(felt).toHaveValue('kaffemølle');
// Det nyeste svar vinder, også når det kommer først.
//
// Den første søgning holdes tilbage, den anden slippes igennem. Uden vagten
// lander den gamle sidst og overskriver listen med svaret på et spørgsmål, der
// ikke længere bliver stillet.
let held;
await page.route('**/api/v1/notes?*', async (route) => {
const q = new URL(route.request().url()).searchParams.get('q');
if (q === 'aaaaa' && !held) {
held = route;
return; // holdes, indtil den næste er kommet igennem
}
await route.continue();
});
await felt.fill('');
await felt.fill('aaaaa');
await page.waitForTimeout(500);
await felt.fill('kaffemølle');
await page.waitForTimeout(900);
if (held) await held.continue();
await page.waitForTimeout(700);
// Feltet og listen er enige: det, der står, er det, der blev spurgt om.
await expect(felt).toHaveValue('kaffemølle');
await expect(page.locator('.list').getByText('Kaffemølle', { exact: true })).toBeVisible();
await page.unroute('**/api/v1/notes?*');
expect(trouble).toEqual([]);
});
+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 {
+34 -33
View File
@@ -15,7 +15,7 @@
import { app } from '$lib/stores.svelte.js';
import { page } from '$app/stores';
import { untrack } from 'svelte';
import { goto, replaceState } from '$app/navigation';
import { goto } from '$app/navigation';
import { t, tag } from '$lib/i18n.svelte.js';
import { NOTE, startDrag } from '$lib/dnd.js';
import { colorVar } from '$lib/colors.js';
@@ -485,48 +485,32 @@
});
/**
* En søgning, man kommer med, og en søgning, man kan gå videre med.
* En søgning, man kommer med.
*
* ⌘K viste noter, der passede, i sin egen liste — og når man valgte en, åbnede
* den noten på en side, hvis liste stod uændret. Så var de andre træffere væk,
* og det eneste sted, de havde været, var en popup, der lige var lukket. "Jeg
* får kun præsenteret en popup med resultater, og trykker jeg retur, kommer jeg
* tilbage til den normale liste."
* ⌘K sender `?note=…&q=…`, så listen ved siden af den åbne note er de andre
* træffere frem for den almindelige liste.
*
* Nu bærer adressen søgningen, så listen ved siden af den åbne note er de andre
* træffere. Og den skrives tilbage, når feltet ændres, så adressen kan gemmes og
* deles — og frem for alt ikke lyver, når man har tømt feltet.
* Læst, aldrig skrevet. Den første udgave skrev også søgningen tilbage i
* adressen ved hvert tastetryk, så den kunne gemmes og deles — og det kostede
* bogstaver. `replaceState` opdaterer `$page.url` et øjeblik efter, den kaldes,
* så to hurtige tastetryk gav en effekt, der læste den *forrige* adresse, fandt
* den uenig med det skrevne, og skrev den gamle værdi tilbage i feltet. Man
* skrev "verd", og der stod "nde". Målt på en skærmoptagelse, ikke gættet.
*
* `settled` er det, de to retninger er blevet enige om — og den læses med
* `untrack`, hvilket er hele forskellen på at virke og ikke.
*
* Uden den: den nederste effekt sætter `settled`, det vækker den øverste, og
* `replaceState` har endnu ikke opdateret `$page.url`. Den øverste læser derfor
* den *gamle* adresse, finder en søgning, der ikke er enig med `settled`, og
* skriver den tilbage i feltet. Man tømmer søgefeltet, og ordet kommer igen.
* Hver effekt skal reagere på sin egen kilde — adressen og feltet — og aldrig
* på den fælles markør.
* En markør, der skal holde to retninger enige, er en markør, der taber
* kapløbet et sted. Adressen skrives derfor kun af navigation, og det her er
* den eneste, der læser den.
*/
let settled = $state(null);
let cameWith = $state(null);
$effect(() => {
const inURL = $page.url.searchParams.get('q') ?? '';
if (inURL === untrack(() => settled)) return;
settled = inURL;
if (inURL === untrack(() => cameWith)) return;
cameWith = inURL;
draftQuery = inURL;
query = inURL;
});
$effect(() => {
const now = query;
if (now === untrack(() => settled)) return;
settled = now;
const url = new URL(untrack(() => $page.url));
if (now) url.searchParams.set('q', now);
else url.searchParams.delete('q');
replaceState(url, {});
});
// Arriving from somewhere that names a note — a task's panel, a project's page —
// opens it rather than dropping the person on a list to find it again.
let asked = $derived($page.url.searchParams.get('note'));
@@ -537,13 +521,30 @@
else api.note(asked).then(open).catch(() => {});
});
/**
* Hvilken hentning der gælder.
*
* To søgninger kan være undervejs på én gang, og den langsomste kan lande
* sidst. Uden det her betød det, at listen viste svaret på et spørgsmål, der
* ikke længere blev stillet — på optagelsen stod resultaterne for "verdande" i
* listen, efter feltet var tømt. Pausen på 250 ms gjorde det sjældnere og kunne
* ikke gøre det umuligt.
*/
let newest = 0;
async function load(q) {
const mine = ++newest;
try {
const params = q ? { q } : showArchive ? { archived: '1' } : {};
notes = (await api.notes(params)).notes ?? [];
const got = (await api.notes(params)).notes ?? [];
// Et svar, der er blevet overhalet, kastes væk. Det er ikke forkert —
// det er bare ikke længere svaret på det, der blev spurgt om.
if (mine !== newest) return;
notes = got;
status = 'ready';
if (selectedId && !notes.some((n) => n.id === selectedId)) open(notes[0] ?? null);
} catch (e) {
if (mine !== newest) return;
status = 'failed';
app.toast(humanMessage(e));
}