Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
159029675c | ||
|
|
b18d9a26e6 | ||
|
|
60795126a5 | ||
|
|
664614b924 | ||
|
|
807aa83e58 | ||
|
|
9199709aa5 |
+59
-4
@@ -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**.
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user