Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

## Det ene, funktionen hænger på

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

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

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

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

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

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

## Og en fejl, skærmbilledet fandt

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 06:50:56 +02:00
Kristian Wind 807aa83e58 Merge pull request #99 from kristianwind/urd-faar-sin-egen-doer
CI / OpenAPI is valid (push) Successful in 13s
CI / End-to-end (push) Successful in 15m49s
CI / Docker image builds (push) Successful in 2m55s
Release / Build and publish image (push) Failing after 6s
CI / Go (push) Failing after 26m49s
feat(urd): den anden dør — /urd kan installeres som sin egen app
2026-10-07 10:42:20 +02:00
kristianwindandClaude Opus 5 9199709aa5 feat(urd): den anden dør — /urd kan installeres som sin egen app
Én instans, én database, ét login — og to ting, der kan installeres.

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

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

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

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

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

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

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

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

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

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

## Undervejs

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-07 10:32:25 +02:00
13 changed files with 1160 additions and 37 deletions
+59 -4
View File
@@ -26,10 +26,7 @@ No second image and no rune of its own, deliberately.
The edition is for an operator who wants an instance that genuinely has no task
routes — the routes are absent from the router, not hidden in the interface. It is
**not** how urd is normally reached. That is being built as a second face of one
instance, with its own entry point and its own web manifest, so that both can be
installed as separate apps while sharing one database, one login and one set of
links. Until that lands, this page describes the edition only.
**not** how urd is normally reached. That is the second door, below.
Two details that surprise people who look inside:
@@ -41,6 +38,64 @@ Two details that surprise people who look inside:
and never reads the manifest, so renaming paths would have left iOS with the
wrong mark.
## The second door: two apps, one instance
urd is normally reached at **`/urd`** on an ordinary verdande. One instance, one
database, one login, one set of links between notes and tasks — and two things you
can install.
| | verdande | urd |
|---|---|---|
| entry | `/` | `/urd` |
| manifest | `/manifest.webmanifest` | `/urd.webmanifest` |
| manifest `id` | `/` | `/urd` |
| tab and mark | verdande, ᚹ | urd, ð |
| database, account, login | the same | the same |
An operating system does not install a URL, it installs a **manifest**, and the
identity of an installed app is that manifest's `id`. Two manifests on one origin
with different ids are two apps; two URLs sharing one manifest are one app with a
bookmark. That is the whole mechanism.
The shell is served per door rather than patched by JavaScript after the page
loads, because iOS reads the page's `<head>` when somebody taps Add to Home Screen
— and Safari looks for `apple-touch-icon` **by name**, having historically ignored
the manifest's icons altogether.
urd's manifest is built from verdande's rather than written beside it, so the
fields the two genuinely share — the colours, the display mode, the language —
come from one place and cannot drift. Six keys are changed: `id`, `start_url`,
`scope`, the two names and the icons. An icon in the built manifest that urd has no
file for stops the server rather than being passed through, because the symptom
would otherwise be urd's install prompt quietly showing Verdande's mark.
### The face is a preference, not a gate
Entering at `/urd` makes that **window** wear urd's face: its mark, its name, its
tab. It is kept strictly apart from the notes edition in the code, and the
distinction is the one thing worth reading twice:
| | what it means | what follows |
|---|---|---|
| `notesOnly` | the server has no task routes | a **guard** — the interface must not fetch them, they answer 404 |
| `urdFace` | this window came in through the urd door | a **preference** — the routes are there, the data is yours, only the name differs |
Conflate them and the urd face starts hiding data rather than chrome: a notebook
would stop showing its tasks because somebody chose a different entrance. A test
asserts the opposite directly — the urd window still gets 200 from
`/api/v1/tasks`.
The face lives in `sessionStorage`, not `localStorage`, and that is what lets the
two apps be two apps: they share an origin, so they share localStorage, cookies and
the service worker. `sessionStorage` is per window, and an installed PWA is its own
window. It survives a reload, which it must — after the first navigation the
address is `/noter` and nobody passes through `/urd` again.
!!! note "One thing the two doors cannot have separately"
Theme, typeface and text size are shared, because they are in the localStorage
the origin shares. That was a deliberate choice: splitting them per face would
mean migrating everybody's stored settings for a difference few people want.
## Moving between the two, measured
A `/data` directory moves between the editions in **both directions, losslessly**.
+27
View File
@@ -86,6 +86,12 @@ type Server struct {
// Icon paths this edition serves from another file. Empty in the full edition.
assets map[string]string
// The second door: /urd and its own web manifest. Built on every instance,
// whatever the edition, because it is how the notes face is installed as its
// own app rather than a property of the instance. See shell.go.
urdShell []byte
urdManifest []byte
router chi.Router
}
@@ -135,6 +141,18 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
})
}
// The second door, on every edition. A notes instance already calls itself urd
// at the root; the door is still built there so the two addresses behave the
// same, and so a single test does not have to know which edition it is in.
if web != nil {
urdShell, urdManifest, err := urdDoorAssets(web)
if err != nil {
log.Error("the urd door could not be built; /urd will not be its own app", "err", err)
} else {
s.urdShell, s.urdManifest = urdShell, urdManifest
}
}
if s.shell != nil {
s.csp = contentSecurityPolicy(scriptHashesIn(s.shell))
} else {
@@ -797,6 +815,15 @@ func (s *Server) serveWeb(w http.ResponseWriter, r *http.Request) {
name = "index.html"
}
// The second door, before anything else touches the filesystem: /urd is not a
// file, so the fallback below would hand back the other door's shell.
if b := s.door(name); b != nil {
w.Header().Set("Content-Type", contentTypeFor(name))
w.Header().Set("Cache-Control", "no-cache")
http.ServeContent(w, r, name, time.Time{}, bytes.NewReader(b))
return
}
// This edition's own icon, where it has one. Before the Open rather than after,
// unlike the shell below: these are real files, so there is no fallback to get
// in the way, and resolveNotesAssets has already checked each one exists.
+165 -1
View File
@@ -2,8 +2,10 @@ package httpapi
import (
"bytes"
"encoding/json"
"fmt"
"io/fs"
"strings"
)
// The product name is in the built frontend as a literal in four places, because
@@ -132,7 +134,12 @@ func (s *Server) renamed(name string) []byte {
// is ignored by the browser without an error anywhere: the install prompt simply
// does not appear.
func contentTypeFor(name string) string {
if name == "manifest.webmanifest" {
// Any .webmanifest, not the one name — there are two of them now, and the
// second one was served as HTML for about four minutes because this read
// `name == "manifest.webmanifest"`. Nothing would have reported it: a manifest
// with the wrong type is ignored silently, and the only symptom is an install
// prompt that does not appear.
if strings.HasSuffix(name, ".webmanifest") {
return "application/manifest+json"
}
return "text/html; charset=utf-8"
@@ -175,3 +182,160 @@ func resolveNotesAssets(web fs.FS, missing func(name, alias string)) map[string]
}
return out
}
// ---------------------------------------------------------------------------
// The second door.
//
// One instance, one database, one login — and two things you can install. urd is
// reached at /urd, which serves the same single-page app under its own identity:
// its own title, its own mark, and its own web manifest.
//
// The manifest is the part that matters and the part that is easy to get wrong.
// An operating system does not install a URL, it installs a *manifest*, and the
// identity of an installed app is the manifest's `id`. Two manifests on one origin
// with different ids are two installable apps; two URLs sharing one manifest are
// one app with a bookmark.
//
// And the shell has to be served per door rather than patched by JavaScript,
// because iOS reads the page's <head> when somebody taps Add to Home Screen —
// Safari looks for apple-touch-icon BY NAME and has historically ignored the
// manifest's icons entirely. A head rewritten after load is a head that may or may
// not be the one iOS read.
// ---------------------------------------------------------------------------
// urdDoorPath is the entry point, and it is also the manifest's id. One constant,
// because those two being the same string is the whole mechanism.
const urdDoorPath = "/urd"
// urdShellRebrands rewrites the four things in the shell's <head> that decide what
// this door is: what the tab says, what a crawler reads, which manifest the browser
// fetches, and which icons it finds without the manifest.
//
// Note what is NOT in here, for the same reason as in shellRebrands above: the
// three `verdande:` localStorage keys in the inline theme script. They are storage
// rather than a name, the rest of the app reads them, and — now more than before —
// the two doors share an origin and therefore share that storage. Renaming them
// here would give the two apps different themes by accident and lose everybody's
// setting on the way.
func urdShellRebrands() []rebrand {
return []rebrand{
{"<title>verdande</title>", "<title>urd</title>"},
{
`<meta name="description" content="verdande — opgaver og projekter, delt." />`,
`<meta name="description" content="` + notesShellDescription + `" />`,
},
{
`<link rel="manifest" href="/manifest.webmanifest" />`,
`<link rel="manifest" href="/urd.webmanifest" />`,
},
{
`<link rel="icon" href="/icon.svg" type="image/svg+xml" />`,
`<link rel="icon" href="/urd-icon.svg" type="image/svg+xml" />`,
},
{
`<link rel="apple-touch-icon" href="/apple-touch-icon.png" />`,
`<link rel="apple-touch-icon" href="/urd-apple-touch-icon.png" />`,
},
}
}
// urdManifest builds the second door's manifest from the one the frontend build
// produced, rather than writing a second one by hand.
//
// Transformed as JSON rather than by string replacement, which the shell above
// does: a manifest needs keys the built one does not have — `id` and `start_url`
// — so there is nothing to anchor a replacement on, and a hand-written copy would
// drift on the fields the two genuinely share (the colours, the display mode, the
// language).
//
// `id` is what makes this a second app rather than a bookmark. `scope` is spelled
// out even though "/" is what the spec would default to from a start_url of
// "/urd": an app whose scope did not cover the whole origin would open its own
// links in a browser tab the first time somebody navigated out of /urd, and that
// is a rule nobody should have to remember while reading this.
func urdManifest(raw []byte) ([]byte, error) {
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
return nil, fmt.Errorf("the built manifest is not JSON: %w", err)
}
m["id"] = urdDoorPath
m["start_url"] = urdDoorPath
m["scope"] = "/"
m["name"] = "urd"
m["short_name"] = "urd"
m["description"] = notesManifestDescription
icons, ok := m["icons"].([]any)
if !ok || len(icons) == 0 {
return nil, fmt.Errorf("the built manifest has no icons to rewrite")
}
for i, entry := range icons {
icon, ok := entry.(map[string]any)
if !ok {
return nil, fmt.Errorf("icon %d is not an object", i)
}
src, _ := icon["src"].(string)
// Every icon must be one this door has a file for. Refusing an unknown one
// is the point: an icon added to the manifest and not to this map would
// otherwise leave urd's install prompt showing Verdande's mark, which is
// cosmetic enough that nobody files it.
swapped, ok := map[string]string{
"/icon.svg": "/urd-icon.svg",
"/icon-192.png": "/urd-icon-192.png",
"/icon-512.png": "/urd-icon-512.png",
}[src]
if !ok {
return nil, fmt.Errorf("icon %d is %q, which urd has no file for", i, src)
}
icon["src"] = swapped
}
out, err := json.MarshalIndent(m, "", " ")
if err != nil {
return nil, err
}
return append(out, '\n'), nil
}
// urdDoorAssets builds the second door from the files the frontend build produced.
//
// Both or neither, and loudly: a door whose shell points at /urd.webmanifest while
// that manifest is not being served is an install prompt that silently falls back
// to the other app's identity.
func urdDoorAssets(web fs.FS) (shell, manifest []byte, err error) {
raw, err := fs.ReadFile(web, "index.html")
if err != nil {
return nil, nil, fmt.Errorf("index.html: %w", err)
}
shell, err = applyRebrands(raw, urdShellRebrands())
if err != nil {
return nil, nil, fmt.Errorf("index.html: %w", err)
}
raw, err = fs.ReadFile(web, "manifest.webmanifest")
if err != nil {
return nil, nil, fmt.Errorf("manifest.webmanifest: %w", err)
}
manifest, err = urdManifest(raw)
if err != nil {
return nil, nil, fmt.Errorf("manifest.webmanifest: %w", err)
}
return shell, manifest, nil
}
// door returns the bytes for the second door's two paths, or nil.
//
// Keyed on the requested path rather than on the resolved file, unlike `renamed`
// below: /urd is not a file and never will be, so it has to be answered before the
// fallback to index.html gets to it — which would serve the other door's shell
// under this door's address, with no error anywhere.
func (s *Server) door(name string) []byte {
switch name {
case "urd":
return s.urdShell
case "urd.webmanifest":
return s.urdManifest
}
return nil
}
+132 -6
View File
@@ -1,6 +1,7 @@
package httpapi
import (
"encoding/json"
"io"
"io/fs"
"log/slog"
@@ -45,6 +46,8 @@ const builtShell = `<!doctype html>
<head>
<meta name="description" content="verdande — opgaver og projekter, delt." />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<title>verdande</title>
<script>
try {
@@ -62,7 +65,17 @@ const builtManifest = `{
"name": "verdande",
"short_name": "verdande",
"description": "Opgaver og projekter, delt.",
"start_url": "/"
"start_url": "/",
"display": "standalone",
"background_color": "#111312",
"theme_color": "#111312",
"lang": "da",
"icons": [
{ "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" },
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
`
@@ -194,10 +207,19 @@ func TestAppHTMLStillCarriesTheAnchors(t *testing.T) {
}
src := string(raw)
for _, pair := range shellRebrands("urd", "d") {
if n := strings.Count(src, pair.old); n != 1 {
t.Errorf("web/src/app.html contains %d occurrences of the anchor %q, want exactly 1 — "+
"the notes edition renames the shell by replacing it", n, pair.old)
// Both sets: the edition's rename and the second door's. The door was added
// after this test and its anchors were NOT covered, which is how its fixture
// came to be missing two <link> lines the real file has — caught only because
// applyRebrands refuses an anchor it cannot find.
for what, pairs := range map[string][]rebrand{
"the notes edition's rename": shellRebrands("urd", "d"),
"the urd door": urdShellRebrands(),
} {
for _, pair := range pairs {
if n := strings.Count(src, pair.old); n != 1 {
t.Errorf("web/src/app.html contains %d occurrences of the anchor %q, want exactly 1 — %s replaces it",
n, pair.old, what)
}
}
}
// And the keys the rename must not touch, asserted here too: app.html is where
@@ -206,7 +228,8 @@ func TestAppHTMLStillCarriesTheAnchors(t *testing.T) {
if !strings.Contains(src, `localStorage.getItem('verdande:theme')`) {
t.Error("web/src/app.html no longer reads verdande:theme; check that the app reads the same key")
}
t.Logf("app.html: %d bytes, %d anchors found", len(raw), len(shellRebrands("urd", "d")))
t.Logf("app.html: %d bytes, %d edition anchors, %d door anchors",
len(raw), len(shellRebrands("urd", "d")), len(urdShellRebrands()))
}
// TestTheFullEditionServesTheBuiltFilesUnchanged is the control. Everything above
@@ -314,3 +337,106 @@ func TestAMissingMarkIsReportedAndFallsBack(t *testing.T) {
t.Error("one missing icon took the other three with it")
}
}
// TestTheUrdDoorIsItsOwnApp.
//
// An operating system does not install a URL, it installs a manifest, and the
// identity of an installed app is that manifest's `id`. So this asserts the two
// things that decide whether /urd becomes a second icon on a home screen or just a
// bookmark into the first one — and it asserts them as named values, because an
// empty manifest, verdande's manifest and urd's all parse as JSON.
func TestTheUrdDoorIsItsOwnApp(t *testing.T) {
srv := serverWithWeb(t, "full", webWithIcons())
if srv.door("urd") == nil || srv.door("urd.webmanifest") == nil {
t.Fatal("the full edition does not serve the urd door; it is a second face, not an edition")
}
shell := string(srv.door("urd"))
for _, want := range []string{
"<title>urd</title>",
`<link rel="manifest" href="/urd.webmanifest" />`,
// iOS reads this one by name and has historically ignored the manifest's
// icons entirely, which is why the shell is served per door rather than
// patched by script after load.
`<link rel="apple-touch-icon" href="/urd-apple-touch-icon.png" />`,
`<link rel="icon" href="/urd-icon.svg" type="image/svg+xml" />`,
} {
if !strings.Contains(shell, want) {
t.Errorf("the urd door's shell does not contain %q", want)
}
}
// The same trap as the edition shell: the two doors share an origin and
// therefore share localStorage, so renaming a storage key here would give the
// two apps different themes by accident.
if !strings.Contains(shell, `localStorage.getItem('verdande:theme')`) {
t.Error("the urd door's shell lost a storage key; the two doors share one origin and one localStorage")
}
var m map[string]any
if err := json.Unmarshal(srv.door("urd.webmanifest"), &m); err != nil {
t.Fatalf("the urd manifest is not JSON: %v", err)
}
for key, want := range map[string]any{
// id is the install identity. Without it, two manifests on one origin are
// one app with two start urls.
"id": "/urd",
"start_url": "/urd",
"scope": "/",
"name": "urd",
"short_name": "urd",
} {
if m[key] != want {
t.Errorf("the urd manifest has %s = %v, want %v", key, m[key], want)
}
}
// And the shared half, to prove it was transformed rather than written out: a
// hand-written copy would drift on exactly these.
var built map[string]any
raw, _ := fs.ReadFile(webWithIcons(), "manifest.webmanifest")
_ = json.Unmarshal(raw, &built)
for _, key := range []string{"display", "background_color", "theme_color", "lang"} {
if _, ok := built[key]; ok && m[key] != built[key] {
t.Errorf("the urd manifest lost the built one's %s: %v vs %v", key, m[key], built[key])
}
}
icons, _ := m["icons"].([]any)
if len(icons) == 0 {
t.Fatal("the urd manifest has no icons")
}
for i, entry := range icons {
src, _ := entry.(map[string]any)["src"].(string)
if !strings.HasPrefix(src, "/urd-") {
t.Errorf("icon %d is %q — the urd manifest points at verdande's mark", i, src)
}
}
t.Logf("urd door: shell %d bytes, manifest %d bytes, %d icons", len(srv.door("urd")), len(srv.door("urd.webmanifest")), len(icons))
}
// TestAnUnknownIconStopsTheUrdManifest is the direction that matters: an icon
// added to the built manifest and not to urd's map would otherwise leave urd's
// install prompt showing Verdande's mark, which nobody reports.
func TestAnUnknownIconStopsTheUrdManifest(t *testing.T) {
raw := []byte(`{"name":"verdande","icons":[{"src":"/icon-1024.png","sizes":"1024x1024"}]}`)
if _, err := urdManifest(raw); err == nil {
t.Error("urdManifest accepted an icon it has no file for")
} else if !strings.Contains(err.Error(), "/icon-1024.png") {
t.Errorf("the error does not name the icon: %v", err)
}
}
// TestTheWebmanifestTypeCoversBothDoors. A manifest served as text/html is ignored
// by the browser with no error anywhere; the only symptom is that the install
// prompt does not appear. This read `name == "manifest.webmanifest"` when the
// second door was added.
func TestTheWebmanifestTypeCoversBothDoors(t *testing.T) {
for _, name := range []string{"manifest.webmanifest", "urd.webmanifest"} {
if got := contentTypeFor(name); got != "application/manifest+json" {
t.Errorf("%s is served as %q", name, got)
}
}
if got := contentTypeFor("index.html"); got != "text/html; charset=utf-8" {
t.Errorf("index.html is served as %q", got)
}
}
+277
View File
@@ -5102,6 +5102,85 @@ test('flere markerede afsnit bliver til én monotypeblok', async ({ page }) => {
expect(trouble).toEqual([]);
});
/**
* Blokke, som retur efterlod som <div>, bliver brødtekst — også bagefter.
*
* Chromium svarer på retur med en bar <div>. Den læses som brødtekst og er ikke
* formateret som en, så afstanden mellem afsnit forsvinder stille. Rettelsen er
* udskudt ét hak — før delingen er der ingen ny blok at rette — og den spørger,
* hvor markøren står. Skriver man hurtigt, når næste retur igennem først, og så
* retter den den næste blok to gange og lader denne stå.
*
* Det er målt frem for gættet: på en CI-runner faldt "flere markerede afsnit
* bliver til én monotypeblok" med tre linjer rigtigt skrevet, hvor "linje et" var
* en <div> og "linje to" og "linje tre" var <p>.
*
* Racet selv kan ikke skrives som en pålidelig prøve — det afhænger af, hvornår
* runneren når sine timere. Derfor planter denne den tilstand, racet efterlader,
* og holder på invarianten i stedet: ingen blok i en note er browserens egen
* beholder, uanset hvor markøren har været i mellemtiden.
*/
test('blokke retur efterlod som <div> bliver brødtekst alligevel', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
const ed = await nyNote(page);
await page.keyboard.type('Overskrift');
await page.keyboard.press('Enter');
await page.keyboard.type('første afsnit');
await page.keyboard.press('Enter');
await page.keyboard.type('andet afsnit');
await expect(ed.locator('p')).toHaveCount(2);
// Tilstanden racet efterlader: rettelserne løb, mens markøren stod et andet sted,
// så ingen af blokkene blev lavet om. To af dem, fordi én er for let: en rettelse,
// der holder fast i et håndtag til blokken, kan nå den første og mister den næste,
// når den første ombytning tager dens node ud af dokumentet.
await ed.evaluate((el) => {
for (const para of [...el.querySelectorAll('p')]) {
const div = document.createElement('div');
while (para.firstChild) div.appendChild(para.firstChild);
para.replaceWith(div);
}
// Markøren tilbage for enden af den sidste, hvor racet ville have efterladt den:
// brugeren skriver videre. At erstatte blokken kollapser markeringen til
// begyndelsen, og så bliver plantningen selv den fejl, prøven måler.
const range = document.createRange();
range.selectNodeContents(el.lastElementChild);
range.collapse(false);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
});
await expect(ed.locator('p')).toHaveCount(0);
await expect(ed.locator('> div')).toHaveCount(2);
// Næste retur henter dem begge ind igen, sammen med den nye linje.
await page.keyboard.press('Enter');
await page.keyboard.type('tredje afsnit');
await expect(ed.locator('> div')).toHaveCount(0);
await expect(ed.locator('p')).toHaveCount(3);
await expect(ed.locator('h1')).toHaveCount(1);
// Og teksten er den samme hele vejen — en rettelse, der taber et ord, er ingen
// rettelse. Den skal også overleve en gemning.
const gemt = noteGemt(page);
await ed.blur();
await gemt;
await page.reload();
await page.getByRole('button', { name: /Overskrift/ }).click();
await expect(ed.locator('> div')).toHaveCount(0);
await expect(ed.locator('p')).toHaveCount(3);
expect(await ed.locator('p').nth(0).textContent()).toBe('første afsnit');
expect(await ed.locator('p').nth(1).textContent()).toBe('andet afsnit');
expect(await ed.locator('p').nth(2).textContent()).toBe('tredje afsnit');
expect(trouble).toEqual([]);
});
/**
* Hele kontoens opgaver kan hentes i én omgang.
*
@@ -5515,3 +5594,201 @@ test('the full edition still calls itself verdande', async ({ page }) => {
expect(icon).toContain('M26 15l17');
expect(icon).not.toContain('M44 36L27 10');
});
/**
* The second door.
*
* One instance, one database, one login — and two things that can be installed.
* This runs in the FULL edition on purpose: urd is a second face of an ordinary
* verdande, not a property of an instance, so if it only worked where
* VERDANDE_EDITION=notes it would not be the thing that was asked for.
*
* Measured on the bytes the server sends. An operating system installs a manifest,
* not a URL, so these assertions are about the manifest's `id` and the shell's
* <head> — the two things that decide whether /urd becomes a second icon on a home
* screen or a bookmark into the first app.
*/
test('urd is a second installable app on the same instance', async ({ page }) => {
const shell = await (await page.request.get('/urd')).text();
expect(shell, 'the tab, before any script runs').toContain('<title>urd</title>');
expect(shell, 'which manifest the browser fetches').toContain(
'<link rel="manifest" href="/urd.webmanifest" />'
);
// iOS looks for this one BY NAME and has historically ignored the manifest's
// icons, which is why the shell is served per door rather than patched by
// script after the page loads.
expect(shell, 'what iOS puts on a home screen').toContain(
'<link rel="apple-touch-icon" href="/urd-apple-touch-icon.png" />'
);
// And the root is untouched: two doors, not a rename.
const root = await (await page.request.get('/')).text();
expect(root).toContain('<title>verdande</title>');
expect(root).toContain('<link rel="manifest" href="/manifest.webmanifest" />');
const res = await page.request.get('/urd.webmanifest');
// A manifest served as text/html is ignored with no error anywhere; the only
// symptom is an install prompt that never appears.
expect(res.headers()['content-type']).toContain('application/manifest+json');
const urd = await res.json();
// `id` is the install identity. Without it, two manifests on one origin are one
// app with two start urls.
expect(urd.id).toBe('/urd');
expect(urd.start_url).toBe('/urd');
// Not "/urd": a scope that did not cover the origin would open the app's own
// links in a browser tab the first time somebody navigated out of /urd.
expect(urd.scope).toBe('/');
expect(urd.name).toBe('urd');
for (const icon of urd.icons) {
expect(icon.src, 'the install prompt must not show verdande’s mark').toMatch(/^\/urd-/);
}
const verdande = await (await page.request.get('/manifest.webmanifest')).json();
expect(verdande.name).toBe('verdande');
expect(verdande.id ?? '/').not.toBe('/urd');
// The shared fields come from one source, so a colour changed in the build
// reaches both doors.
expect(urd.theme_color).toBe(verdande.theme_color);
expect(urd.background_color).toBe(verdande.background_color);
});
test('entering through the urd door lands on the notes', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/urd');
// Asserted on what is drawn rather than on the URL: the Go binary serves the
// shell for anything it does not recognise, so a route that no longer exists
// still answers 200 here.
await expect(page.getByRole('heading', { name: /Noter/ }).first()).toBeVisible();
// replaceState, so the phone's back gesture does not return to a page whose
// only job is to redirect.
await expect(page).toHaveURL(/\/noter$/);
// And the window now wears urd's face: its mark, its name, its tab. This is
// chrome only — the task routes are all still there and still answer, which is
// the whole reason this is one instance.
const brand = page.getByRole('navigation', { name: /Hovedmenu|Main menu/ }).locator('.brand');
await expect(brand.locator('.name')).toHaveText('urd');
await expect(brand.locator('.rune')).toHaveText('ð');
await expect(page).toHaveTitle(/· urd$/);
// Nothing is withheld: the same window can still read a task route. A face that
// hid data would be the separate instance wearing a costume.
const tasks = await page.request.get('/api/v1/tasks');
expect(tasks.status(), 'the task routes are still served to the urd window').toBe(200);
// And the face survives a reload — sessionStorage, because the two doors share
// an origin. Nobody passes through /urd again after the first navigation.
await page.reload();
await expect(brand.locator('.name')).toHaveText('urd');
expect(trouble).toEqual([]);
});
test('the verdande door is unaffected by the urd one', async ({ page }) => {
// The control, and it is the half that stops the face from being a rename: a
// window that never went through /urd must be verdande, in the same browser,
// on the same origin, with the other app's sessionStorage sitting beside it.
const trouble = watchForTrouble(page);
await page.goto('/noter');
const brand = page.getByRole('navigation', { name: /Hovedmenu|Main menu/ }).locator('.brand');
await expect(brand.locator('.name')).toHaveText('verdande');
await expect(brand.locator('.rune')).toHaveText('\u16B9');
await expect(page).toHaveTitle(/· verdande$/);
expect(trouble).toEqual([]);
});
/**
* Tal en opgave ind.
*
* The button does not do speech recognition and is not meant to: it puts the
* quick-add field in front of the thumb so the keyboard's own dictation can be
* used. So what there is to test is the field — that it is reachable, that it ends
* up with the caret in it, and that what lands there becomes a task.
*
* What a browser cannot be asked: whether iOS opened its keyboard. That happens
* only when focus() is called inside the user gesture, and no test can see the
* difference. What a test CAN see is the precondition — that the field is already
* in the document before the button is pressed — and that is asserted below,
* because it is exactly what a well-meaning `{#if open}` would take away.
*/
const PHONE = { width: 390, height: 844 };
test('den runde knap sætter markøren i feltet, så tastaturet kan diktere', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.setViewportSize(PHONE);
await page.goto('/');
const knap = page.getByRole('button', { name: 'Tal en opgave ind' });
await expect(knap).toBeVisible();
// Feltet findes FØR klikket. Det er forudsætningen for at fokus kan sættes
// synkront, og den eneste del af iOS-kravet en prøve kan se.
//
// `toHaveCount` og ikke `count()`: den første venter, den anden er et
// øjebliksbillede taget med det samme — og en SPA har ikke tegnet endnu, når
// `goto` vender tilbage, så den ville tælle nul uanset hvad koden gjorde.
await expect(page.locator('.sheet input[type="text"]')).toHaveCount(1);
await knap.click();
// Markøren er i skuffens felt — ikke i sidens eget.
const fokusIskuffen = await page.evaluate(
() => !!document.activeElement?.closest('.sheet')
);
expect(fokusIskuffen, 'markøren skal stå i skuffens felt efter trykket').toBe(true);
// Og det, der dikteres, bliver læst af den samme parser som alt andet.
const felt = page.locator('.sheet input[type="text"]');
await felt.fill('ring til tandlægen i dag p1');
await felt.press('Enter');
// "i dag", af samme grund som de andre prøver her siger det: dette er
// I dag-visningen, og en opgave uden dato eller med "i morgen" er korrekt
// fraværende fra den — hvilket ville ligne, at tilføjelsen var fejlet.
await expect(page.getByText('ring til tandlægen').first()).toBeVisible();
// Og skuffen lukker sig selv, så man ikke står med tastaturet oppe bagefter.
await expect(page.locator('.sheet.open')).toHaveCount(0);
expect(trouble).toEqual([]);
});
test('skuffens felt stjæler ikke skriv-hvor-som-helst-genvejen', async ({ page }) => {
// Genvejen finder feltet med querySelector('[data-quickadd]'), som returnerer
// FØRSTE match i dokumentet. Skuffens felt må derfor ikke bære mærket — ellers
// begynder et tastetryk at skrive i en kasse, ingen kan se.
await page.setViewportSize(PHONE);
await page.goto('/');
await expect(page.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
await expect(page.locator('[data-quickadd]'), 'kun ét mærket felt').toHaveCount(1);
const mærketErIskuffen = await page.evaluate(
() => !!document.querySelector('[data-quickadd]')?.closest('.sheet')
);
expect(mærketErIskuffen, 'mærket skal sidde på sidens felt, ikke skuffens').toBe(false);
await page.keyboard.press('k');
await expect(page.locator('[data-quickadd]')).toHaveValue('k');
});
test('knappen findes ikke på en skrivebordsskærm', async ({ page }) => {
// Der er feltet fremme hele tiden og et tastatur at skrive på. En rund knap i
// bunden er til tommelfingeren.
await page.setViewportSize({ width: 1280, height: 800 });
await page.goto('/');
await expect(page.getByRole('button', { name: 'Tal en opgave ind' })).toBeHidden();
});
test('knappen findes ikke i urd-ansigtet', async ({ page }) => {
// Der er ingen opgaver at lægge noget i. Målt gennem døren frem for ved at
// skrue på en variabel, så det er den vej en bruger faktisk kommer.
await page.setViewportSize(PHONE);
await page.goto('/urd');
await expect(page.getByRole('navigation', { name: /Hovedmenu|Main menu/ })).toBeVisible();
await expect(page.getByRole('button', { name: 'Tal en opgave ind' })).toHaveCount(0);
// Kontrollen: samme telefon, den anden dør, og så er den der.
await page.goto('/');
await expect(page.getByRole('button', { name: 'Tal en opgave ind' })).toBeVisible();
});
+243
View File
@@ -0,0 +1,243 @@
<script>
/**
* Tal en opgave ind.
*
* En stor rund knap i bunden af skærmen, inden for tommelfingerens rækkevidde.
* Den åbner en skuffe med quick-add-feltet og sætter markøren i det, så
* tastaturet kommer frem — og så trykker man mikrofonen på tastaturet.
*
* VI LAVER INGEN TALEGENKENDELSE, og det er et valg frem for en mangel. iOS'
* egen diktering kører på enheden på nyere telefoner; en talegenkendelse bygget
* ind her ville sende lyden fra et selvhostet program videre til Apple eller
* Google, og det er en datavej, der skal forklares på privatlivssiden for at
* spare ét tryk. Teksten lander i det felt, der i forvejen viser, hvad parseren
* forstod — så "ring til tandlægen i morgen kl 10 p1" bliver læst, mens man ser
* på det, og intet oprettes før man siger ja.
*
* Derfor hedder komponenten diktat og ikke tale: den gør ikke selv noget ved
* lyden, den stiller feltet frem.
*/
import QuickAdd from './QuickAdd.svelte';
import { app } from '$lib/stores.svelte.js';
import { t } from '$lib/i18n.svelte.js';
let open = $state(false);
let box;
let sheet;
/**
* Åbner og sætter fokus I SAMME HUG som klikket.
*
* Rækkefølgen her er hele funktionen. Safari på iOS åbner kun tastaturet, hvis
* `focus()` kaldes inde i den brugerhandling, der bad om det — og Sveltes
* opdateringer er asynkrone, så feltet kan ikke være betinget tegnet og få
* fokus i samme omgang. Derfor er skuffen ALTID monteret og bare skubbet ud af
* syne med `transform` og `opacity`: elementet findes, så det kan fokuseres nu
* frem for efter næste opdatering.
*
* `display: none` og `visibility: hidden` ville begge gøre feltet ufokuserbart,
* og `inert` ligeså. Det er grunden til, at den lukkede tilstand er gennemsigtig
* og uden pointer-events frem for skjult.
*/
function start() {
// `inert` fjernes med hånden her, og det er ikke sjusk. Attributten sættes
// reaktivt af `inert={!open}` nedenfor, så den er der stadig i det øjeblik,
// klikket kører — Sveltes opdateringer er asynkrone. Og et inert element kan
// ikke fokuseres, så `focusField()` ville ramme ingenting, og iOS ville ikke
// åbne tastaturet.
//
// Den findes, fordi alternativet er værre: uden den kan skuffens knapper
// tabbes til, mens den er usynlig, på hver eneste side.
sheet?.removeAttribute('inert');
open = true;
box?.focusField();
}
function close() {
open = false;
// Tastaturet bliver stående, hvis feltet beholder fokus.
if (sheet?.contains(document.activeElement)) document.activeElement.blur();
}
function onkeydown(event) {
if (event.key === 'Escape' && open) {
event.preventDefault();
close();
}
}
</script>
<svelte:window {onkeydown} />
<!-- Knappen tegnes ikke i urd-ansigtet: der er ingen opgaver at lægge noget i.
Og den er skjult over 820px i CSS frem for her, fordi en skærm, der skifter
bredde, ikke skal kunne tabe et felt, der har fokus. -->
{#if !app.showsUrd}
<button class="fab" class:away={open} onclick={start} aria-label={t('task.speak')}>
<!-- En mikrofon, tegnet i samme streger som mærkerne: kapslen, bøjlen under
den og foden. Ingen tekst i knappen — den er rund og tommelfingerstor,
og navnet står i aria-label. -->
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="9" y="2.5" width="6" height="11" rx="3" />
<path d="M5.5 11a6.5 6.5 0 0 0 13 0" />
<path d="M12 17.5V21" />
</svg>
</button>
{/if}
<div
class="scrim"
class:open
aria-hidden="true"
onclick={close}
role="presentation"
></div>
<div
class="sheet"
class:open
bind:this={sheet}
role="dialog"
aria-modal="false"
aria-label={t('task.speak')}
aria-hidden={!open}
inert={!open}
>
<div class="grab" aria-hidden="true"></div>
<p class="hint">{t('task.speakHint')}</p>
<!-- `marked={false}`: den her må ikke være den, type-anywhere-genvejen finder.
Se kommentaren ved `marked` i QuickAdd. -->
<QuickAdd bind:this={box} marked={false} label={t('task.speak')} onadded={close} />
<button class="done" onclick={close}>{t('detail.close')}</button>
</div>
<style>
.fab {
position: fixed;
right: max(var(--s4), env(safe-area-inset-right));
/* Over hjemmeindikatoren på en iPhone, ikke under den. */
bottom: calc(var(--s4) + env(safe-area-inset-bottom));
z-index: 40;
width: 64px;
height: 64px;
border: none;
border-radius: var(--radius-full);
background: var(--accent);
color: var(--accent-ink);
box-shadow: var(--shadow-lg);
display: grid;
place-items: center;
cursor: pointer;
transition:
transform var(--fast) var(--ease-out),
opacity var(--fast) var(--ease-out);
}
.fab svg {
width: 28px;
height: 28px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.fab:active {
transform: scale(0.94);
}
/* Væk, mens skuffen er åben: den ville ellers stå oven på feltet. */
.fab.away {
opacity: 0;
pointer-events: none;
transform: translateY(8px);
}
.scrim {
position: fixed;
inset: 0;
z-index: 44;
background: rgb(0 0 0 / 0.4);
opacity: 0;
pointer-events: none;
transition: opacity var(--medium) var(--ease-out);
}
.scrim.open {
opacity: 1;
pointer-events: auto;
}
.sheet {
position: fixed;
inset: auto 0 0 0;
z-index: 45;
padding: var(--s3) max(var(--s3), env(safe-area-inset-left))
calc(var(--s3) + env(safe-area-inset-bottom)) max(var(--s3), env(safe-area-inset-right));
background: var(--surface-raised);
border-top: 1px solid var(--line);
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
box-shadow: var(--shadow-lg);
display: grid;
gap: var(--s2);
/* Lukket: gennemsigtig og skubbet ned, IKKE display:none — feltet skal kunne
fokuseres i samme hug som klikket. Se `start()`. */
opacity: 0;
transform: translateY(100%);
pointer-events: none;
transition:
transform var(--medium) var(--ease-out),
opacity var(--medium) var(--ease-out);
}
.sheet.open {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.grab {
width: 36px;
height: 4px;
border-radius: var(--radius-full);
background: var(--line-strong);
justify-self: center;
}
.hint {
margin: 0;
color: var(--ink-muted);
font-size: 0.85rem;
text-align: center;
}
.done {
justify-self: center;
background: none;
border: none;
color: var(--ink-muted);
padding: var(--s2);
cursor: pointer;
}
/* En skrivebordsskærm har feltet fremme hele tiden og et tastatur at skrive
på. Knappen er til tommelfingeren. */
@media (min-width: 821px) {
.fab,
.scrim,
.sheet {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.fab,
.scrim,
.sheet {
transition: none;
}
}
</style>
+49 -17
View File
@@ -821,23 +821,54 @@
* the caret in another heading in most browsers, and a note where every line is
* a title is a note with no title at all.
*/
function keepTitleFirst() {
const block = currentBlock();
if (!block) return;
function keepTitleFirst(split) {
if (!editor) return;
// A heading that is not the first line becomes body.
if (block.tagName === 'H1' && block !== editor.firstElementChild) {
document.execCommand('formatBlock', false, 'p');
return;
}
// Der hvor markøren står, gør execCommand det, for det er den, der lægger
// ændringen i browserens egen fortryd-historik — ⌘Z skal tage afsnittet tilbage,
// ligesom den tager et ord tilbage.
const caret = currentBlock();
if (caret && stray(caret)) document.execCommand('formatBlock', false, 'p');
// And the browser's own container becomes a paragraph. Chromium answers Enter
// with a bare <div>, which reads as body but is not styled as one — so the
// spacing between paragraphs quietly disappears the moment somebody presses
// return rather than choosing Brødtekst from the menu.
if (block.tagName === 'DIV') {
document.execCommand('formatBlock', false, 'p');
}
// Retur for enden af en overskrift efterlader endnu en overskrift i de fleste
// browsere, og markøren kan være løbet videre, inden rettelsen når frem. Kun den
// blok, delingen lige skabte: en <h1> andre steder i noten kan være en linje,
// nogen har valgt Titel til i menuen, og den er deres at beholde.
const lavet = split?.nextElementSibling;
if (lavet && lavet !== currentBlock() && stray(lavet)) swapTag(lavet, 'p');
// Og hver <div>, rettelsen ikke nåede.
//
// Den er udskudt ét hak, fordi der før delingen ikke er nogen ny blok at rette.
// Men et hak er nok: skriver man hurtigt, når næste retur igennem først, og så
// står markøren i den NÆSTE blok. Så rettede den den — to gange — og lod den
// blok, rettelsen var ment for, stå tilbage. Målt på en CI-runner, hvor "linje
// et" blev en <div> mens "linje to" og "linje tre" blev <p>: brødtekst uden
// afstand, hos netop de brugere der skriver hurtigst.
//
// Den spørger noten frem for et håndtag. Et håndtag holder ikke: når den første
// udskudte rettelse bytter tagget på en blok, peger den næstes håndtag på en
// node, der ikke længere sidder i dokumentet, og så bliver blokken midt i
// kæden aldrig rettet. En <div> er altid browserens egen beholder og aldrig
// nogens valg, så den kan fejes op hvor som helst uden at tage noget fra nogen.
const her = currentBlock();
for (const block of [...editor.children])
if (block !== her && block.tagName === 'DIV') swapTag(block, 'p');
}
/**
* En blok, der ikke må stå, som den står.
*
* Browserens egen beholder: Chromium svarer på retur med en bar <div>, som læses
* som brødtekst og ikke er formateret som en, så afstanden mellem afsnit stille
* forsvinder. Eller en overskrift, der ikke er den første linje — en note, hvor
* hver linje er en titel, er en note uden titel.
*/
function stray(block) {
if (block.parentNode !== editor) return false;
return (
block.tagName === 'DIV' || (block.tagName === 'H1' && block !== editor.firstElementChild)
);
}
/**
@@ -912,9 +943,10 @@
changed();
return;
}
// After the split, not before it.
// After the split, not before it — and with the block the split started from,
// because by the time this runs the caret may be two blocks further on.
setTimeout(() => {
keepTitleFirst();
keepTitleFirst(block);
changed();
}, 0);
return;
+73 -6
View File
@@ -16,14 +16,53 @@
import { app } from '$lib/stores.svelte.js';
import { t } from '$lib/i18n.svelte.js';
import { readList } from '$lib/pastelist.js';
let { projectId = undefined, onadded = undefined, autofocus = false } = $props();
let {
projectId = undefined,
onadded = undefined,
autofocus = false,
/**
* Whether this box carries the `data-quickadd` marker.
*
* The layout's type-anywhere shortcut finds the field with
* `document.querySelector('[data-quickadd]')`, which returns the FIRST match
* in the document. So a second box on the page — the dictate sheet's — has
* to stay unmarked, or the shortcut starts typing into a box nobody can see.
*/
marked = true,
/**
* The field's accessible name.
*
* A prop because the dictate sheet puts a second one of these on the page,
* and two fields answering to "Ny opgave" is a screen reader reading the
* same name twice with no way to tell which is which. They are different
* affordances — one is the box on the page, the other is the thing the
* round button opens — so they say different names.
*/
label = undefined
} = $props();
let fieldLabel = $derived(label ?? t('task.new'));
let text = $state('');
let spans = $state([]);
let submitting = $state(false);
let input;
let mirror;
let focused = $state(false);
/**
* Keeps the tint under the right characters when the line is longer than the
* field.
*
* The input scrolls horizontally and the mirror has to scroll with it. Done on
* both `scroll` and `input` because typing at the end of a long line moves the
* caret — and therefore the scroll — without ever firing a scroll event in some
* browsers.
*/
function syncScroll() {
if (mirror && input) mirror.scrollLeft = input.scrollLeft;
}
// Kept beside the parser's own vocabulary on purpose: a legend that promises
// something internal/quickadd cannot read is worse than no legend. These four
// are what Parse recognises as marks; dates it reads as prose, which is why the
@@ -72,6 +111,20 @@
if (autofocus && input) input.focus();
});
/**
* Focus the field from outside, synchronously.
*
* This exists because of iOS and nothing else. Safari opens the on-screen
* keyboard only when `focus()` is called INSIDE the user gesture that asked for
* it — so the `autofocus` effect above, which runs after the click handler has
* returned, gives the field a caret and no keyboard. For the dictate button that
* is the whole feature failing quietly: the sheet opens, nothing to dictate
* into, and there is no error anywhere.
*/
export function focusField() {
input?.focus();
}
/** Splits the raw text into highlighted and plain runs, by byte offset. */
let segments = $derived.by(() => {
if (!spans.length) return [{ text, kind: null }];
@@ -197,7 +250,7 @@
<!-- The mirror sits behind the input and must match it exactly: same font,
same padding, same wrapping. Any difference shows up as highlighting
that drifts out of alignment as the line grows. -->
<div class="mirror" aria-hidden="true">
<div class="mirror" aria-hidden="true" bind:this={mirror}>
{#each segments as segment}
{#if segment.kind}
<mark data-kind={segment.kind}>{segment.text}</mark>
@@ -211,12 +264,14 @@
bind:this={input}
bind:value={text}
{onkeydown}
onscroll={syncScroll}
oninput={syncScroll}
onfocus={() => (focused = true)}
onblur={() => (focused = false)}
type="text"
data-quickadd
data-quickadd={marked ? '' : undefined}
placeholder={t('task.placeholder')}
aria-label={t('task.new')}
aria-label={fieldLabel}
autocomplete="off"
spellcheck="false"
{onpaste}
@@ -385,8 +440,16 @@
letter-spacing: normal;
padding: 0;
border: 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
/* `pre`, not `pre-wrap`, and that is the whole alignment.
An <input type="text"> is single-line by specification and cannot wrap
whatever CSS says. So a wrapping mirror behind a non-wrapping input does
not drift a little — it puts the tint on a second line that the input does
not have, and the highlight ends up under empty space. Rare on a wide
desktop field, constant in the dictate sheet on a 390px phone, where a
spoken sentence overflows immediately. */
white-space: pre;
overflow-wrap: normal;
}
.mirror {
@@ -394,6 +457,10 @@
inset: 0;
pointer-events: none;
color: transparent;
/* Scrolled in step with the input by hand — see `syncScroll`. The overflow
has to be hidden rather than visible, or the long tail of a line paints
out past the field's edge. */
overflow: hidden;
}
/* The tint is behind the glyphs, not on them: colouring the text itself would
+5
View File
@@ -267,6 +267,11 @@ export const da = {
'task.pastedAdd': 'Opret {n} opgaver',
'task.pastedCancel': 'Fortryd',
'task.new': 'Ny opgave',
// Diktat, ikke tale: programmet laver ingen talegenkendelse, det stiller
// feltet frem, så tastaturets egen mikrofon kan bruges. Hjælpelinjen siger
// det, fordi en knap med en mikrofon på lover noget andet.
'task.speak': 'Tal en opgave ind',
'task.speakHint': 'Tryk mikrofonen på tastaturet, og sig hvad der skal gøres.',
'task.syntaxProject': 'projekt',
'task.syntaxSection': 'sektion',
'task.syntaxLabel': 'etiket',
+2
View File
@@ -264,6 +264,8 @@ export const en = {
'task.pastedAdd': 'Add {n} tasks',
'task.pastedCancel': 'Cancel',
'task.new': 'New task',
'task.speak': 'Speak a task',
'task.speakHint': 'Tap the microphone on your keyboard and say what needs doing.',
'task.syntaxProject': 'project',
'task.syntaxSection': 'section',
'task.syntaxLabel': 'label',
+73 -3
View File
@@ -64,6 +64,55 @@ class AppState {
return this.edition === 'notes';
}
/**
* Hvilken dør dette vindue kom ind ad.
*
* HOLDT ADSKILT FRA `notesOnly` med vilje, og det er den vigtigste linje i
* filen her. De to ligner hinanden og betyder ikke det samme:
*
* notesOnly — serveren HAR ingen opgaveruter. En vagt: fladen må ikke hente
* dem, for de svarer 404.
* urdFace — personen kom ind ad urd-døren. En præference: ruterne er der,
* dataene er deres egne, og der er intet at holde tilbage.
*
* Slås de sammen, begynder urd-ansigtet at skjule data frem for pynt — en
* notesbog ville holde op med at vise sine opgaver, selvom de ligger i samme
* database og personen bare valgte en anden indgang.
*
* Læst fra sessionStorage frem for localStorage, fordi de to apps deler origin
* og dermed deler localStorage. sessionStorage er per vindue, og en installeret
* PWA er sit eget vindue.
*/
urdFace = $state(false);
enterUrd() {
this.setDoor('urd');
}
leaveUrd() {
this.setDoor('verdande');
}
/**
* Hvilken dør dette vindue sidst kom ind ad.
*
* Symmetrisk med vilje: `/` er også en dør. Uden den halvdel bliver en fane,
* der ÉN gang har set /urd, ved med at hedde urd — også på opgavesiderne, også
* efter et link til forsiden. I den installerede app sker det aldrig, for dens
* start_url er /urd og den anden apps er /; i en browserfane sker det hele
* tiden.
*/
setDoor(door) {
this.urdFace = door === 'urd';
try {
sessionStorage.setItem('verdande:door', door);
} catch (e) {
// Privat browsing kan nægte. Ansigtet holder så vinduet ud alligevel,
// fordi det står i $state ovenfor — det er kun genindlæsningen, der
// mister det.
}
}
/**
* Navnet instansen kalder sig selv — `Urd` i en sætning, `urd` i et mærke.
*
@@ -77,12 +126,24 @@ class AppState {
* (`internal/httpapi/shell.go`). Gjorde vi det her, ville hver indlæsning
* vise søsterens navn et øjeblik og så rette sig selv.
*/
/**
* Om DETTE vindue viser urd — enten fordi instansen ikke har opgaveruter, eller
* fordi personen kom ind ad urd-døren.
*
* De to grunde er forskellige og må ikke slås sammen andre steder end her: det
* her er navnet og mærket, altså pynt. `notesOnly` alene er vagten, der afgør,
* om der overhovedet er en rute at hente fra.
*/
get showsUrd() {
return this.notesOnly || this.urdFace;
}
get productName() {
return this.notesOnly ? 'Urd' : 'Verdande';
return this.showsUrd ? 'Urd' : 'Verdande';
}
get productSlug() {
return this.notesOnly ? 'urd' : 'verdande';
return this.showsUrd ? 'urd' : 'verdande';
}
/**
@@ -99,7 +160,7 @@ class AppState {
* Så bogstavet frem for runen. Hele historien står i web/static/urd-icon.svg.
*/
get productMark() {
return this.notesOnly ? 'ð' : '\u16B9';
return this.showsUrd ? 'ð' : '\u16B9';
}
/** Transient messages: a failed save, a rolled-back change. */
@@ -168,6 +229,15 @@ class AppState {
async load() {
this.loading = true;
// Døren, hvis dette vindue allerede er gået gennem den. Genindlæser nogen på
// /noter inde i urd-appen, kommer de ikke forbi /urd igen, og uden den her
// linje ville ansigtet falde af ved hvert F5.
try {
if (sessionStorage.getItem('verdande:door') === 'urd') this.urdFace = true;
} catch (e) {
// Privat browsing nægter. Så holder ansigtet vinduet ud og ikke længere.
}
// Formen hentes for sig, og en fejl på den tier. En 401 fra `me` må ikke
// tage udgaven med sig — login-skærmen er også en flade i en udgave — og
// en instans, der slet ikke svarer, melder `me` ordentligt nedenfor.
+29
View File
@@ -6,6 +6,7 @@
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import Sidebar from '$lib/components/Sidebar.svelte';
import DictateSheet from '$lib/components/DictateSheet.svelte';
import CommandPalette from '$lib/components/CommandPalette.svelte';
import SignIn from '$lib/components/SignIn.svelte';
import NoConnection from '$lib/components/NoConnection.svelte';
@@ -57,6 +58,25 @@
app.load();
});
/**
* Hvilken dør dette vindue er inde ad.
*
* To døre, og reglen er én sætning: den, du sidst kom ind ad, bestemmer
* ansigtet. `/urd` sætter urd, `/` sætter verdande, og alt derimellem rører
* det ikke — så et klik videre til en note eller en opgave beholder det navn,
* vinduet åbnede med.
*
* Symmetrien er det, der gør det forudsigeligt. Uden `/`-halvdelen ville en
* fane, der én gang havde set /urd, blive ved med at hedde urd, også på
* opgavesiderne. I de installerede apps sker det aldrig — deres start_url er
* hver sin dør — men en browserfane springer mellem dem hele tiden.
*/
$effect(() => {
const here = $page.url.pathname;
if (here === '/urd') app.enterUrd();
else if (here === '/') app.leaveUrd();
});
/**
* Opgavernes sider findes ikke i noter-udgaven.
*
@@ -295,6 +315,15 @@
tabindex="-1"
></button>
{/if}
<!-- Tal en opgave ind. Kun på telefonen, og kun hvor der er opgaver — se
komponenten.
UBETINGET monteret, og det er ikke en forglemmelse: feltet inde i den
skal kunne få fokus i samme hug som klikket, ellers åbner iOS ikke
tastaturet. Et `{#if}` her ville tage feltet ud af DOM'en og gøre det
umuligt. Menuen dækker den i stedet, fordi den har højere z-index. -->
<DictateSheet />
</div>
<CommandPalette bind:open={paletteOpen} />
+26
View File
@@ -0,0 +1,26 @@
<script>
/**
* Urds dør.
*
* Serveren svarer på /urd med sin egen skal — egen titel, eget mærke og et link
* til sit eget manifest — og det er dét, styresystemet installerer. Siden her er
* hvad appen gør, når den så er startet: husker hvilken dør den kom ind ad, og
* går videre til noterne.
*
* Ansigtet sættes ikke her, men i layoutet, hvor begge døre står ved siden af
* hinanden — `/urd` sætter urd og `/` sætter verdande. Delt op, fordi en regel
* med to halvdele, der ligger i hver sin fil, er en regel, hvor kun den ene
* halvdel bliver vedligeholdt.
*
* `replaceState`, så telefonens tilbage-gestus ikke fører tilbage til en side,
* der kun omdirigerer.
*/
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
onMount(() => goto('/noter', { replaceState: true }));
</script>
<!-- Bevidst tom. Omdirigeringen sker i samme hug som tegningen, så alt her ville
være et glimt af noget, ingen nåede at læse. -->
<svelte:head><title>urd</title></svelte:head>