diff --git a/README.md b/README.md index e6ed83c..21bd4e4 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,10 @@ One binary, SQLite inside it, deployed as a Rune in Yggdrasil Panel.

+

+ The Today view: projects, filters and labels in the sidebar, beside the tasks due today. +

+ --- **Verdande** is one of the three Norns — the one who spins *that which is becoming*. @@ -85,10 +89,23 @@ works through the API. Search finds `grøn` when you type `gron`, because Unicode does not consider `ø` an accented `o` and a Danish-first app that cannot do this is broken for its own users. +## What it looks like + +| | | +|---|---| +| ![Board view](site/screenshots/board.png) | ![Light theme](site/screenshots/today-light.png) | +| Board view — one column per section. | The light theme. | + +Regenerate these with `go run ./tools/shots` against a running instance. + ## Running it ### As a Rune in Yggdrasil Panel +verdande is built to be deployed as a **Rune** in +[Yggdrasil Panel](https://github.com/kristianwind/yggdrasil) — that is its home, +and the manifest ships in this repository. It runs anywhere Docker does as well. + 1. **Runes → Carve a rune (upload)** and pick [`rune/verdande.yaml`](rune/verdande.yaml). 2. Create a server from it. 3. Set **Public address** to the address you will actually reach it on. verdande diff --git a/docs/index.md b/docs/index.md index 69039bb..13e483e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -7,6 +7,8 @@ One binary with SQLite inside it — no database to provision, no second process Not what has been, and not what is fated to be — the thing you are in the middle of. Which is what a to-do list is. +![The Today view](screenshots/today.png) + ## What it is for Todoist Pro, on your own hardware, with your own data. And with the integrations a @@ -29,6 +31,10 @@ forward mail to, and an MCP endpoint so Claude can work your task list directly. ## Getting it running +verdande is built to be deployed as a **Rune** in +[Yggdrasil Panel](rune.md) — that is its home, and the manifest ships with it. It +runs anywhere Docker does as well. + ```bash docker run -d --name verdande \ -p 8080:8080 \ diff --git a/docs/screenshots/board.png b/docs/screenshots/board.png new file mode 100644 index 0000000..0177166 Binary files /dev/null and b/docs/screenshots/board.png differ diff --git a/docs/screenshots/today-light.png b/docs/screenshots/today-light.png new file mode 100644 index 0000000..dbe7e58 Binary files /dev/null and b/docs/screenshots/today-light.png differ diff --git a/docs/screenshots/today.png b/docs/screenshots/today.png new file mode 100644 index 0000000..3ac99b8 Binary files /dev/null and b/docs/screenshots/today.png differ diff --git a/docs/screenshots/upcoming.png b/docs/screenshots/upcoming.png new file mode 100644 index 0000000..455ca93 Binary files /dev/null and b/docs/screenshots/upcoming.png differ diff --git a/shots b/shots new file mode 100755 index 0000000..645ba29 Binary files /dev/null and b/shots differ diff --git a/site/index.html b/site/index.html index b09ac73..c91b058 100644 --- a/site/index.html +++ b/site/index.html @@ -301,6 +301,60 @@ color: var(--accent); } + .rune-note { + margin: 1.25rem auto 0; + max-width: 32rem; + font-size: 0.9375rem; + color: var(--ink-muted); + } + + .demo-note { + margin: 1rem 0 0; + font-family: var(--font); + font-size: 0.8125rem; + color: var(--ink-faint); + } + + .demo-note code { + font-family: var(--font-mono); + color: var(--ink-muted); + } + + .shot { + margin: 0 0 1.5rem; + } + + .shot img { + width: 100%; + height: auto; + display: block; + border: 1px solid var(--line); + border-radius: var(--radius-lg); + } + + .shot figcaption { + margin-top: 0.625rem; + font-size: 0.8125rem; + color: var(--ink-faint); + text-align: center; + } + + .shots { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 1.5rem; + } + + h3.sub { + margin: 2rem 0 0.5rem; + font-size: 1rem; + font-weight: 560; + } + + h3.sub:first-of-type { + margin-top: 0; + } + footer { border-top: 1px solid var(--line); margin-top: 4rem; @@ -329,6 +383,11 @@ Self-hosted tasks and projects, shared with the people you share the work with. One binary, SQLite inside it.

+

+ Built to be deployed as a Rune in + Yggdrasil Panel — upload + the manifest, fill in two fields, press start. It also runs anywhere Docker does. +

Verdande is one of the three Norns — the one who spins that which is becoming. Not what has been, and not what is fated to be: the thing you are @@ -353,21 +412,63 @@

- betal moms i morgen kl 10 - p1 #Firma - @regnskab + file the VAT return next friday at 10am + p1 #Accounts + @tax
→ - betal moms - Tomorrow 10:00 + file the VAT return + Fri 10:00 P1 - Firma - regnskab + Accounts + tax
+

+ The same line works in Danish — + betal moms på fredag kl 10 p1 #Firma @regnskab — and the two + can be mixed mid-sentence. +

+
+

What it looks like

+
+ The Today view: a sidebar of projects, filters and labels beside a list of tasks due today, each with its priority ring, time and labels. +
Today. Dark is the default; there is a light theme too.
+
+ +
+
+ A project in board view: one column per section, with task cards showing due dates, labels and repetition. +
Board view — one column per section.
+
+
+ The same Today view in the light theme. +
The light theme.
+
+
+
+

What it does

@@ -418,6 +519,16 @@

Running it

+ +

As a Rune in Yggdrasil Panel

+

+ This is what it was built for. Runes → Carve a rune, upload + rune/verdande.yaml, create a server from it, set the public address + and your mail server, and start it. No port forwarding — give it a subdomain and + the panel routes it through your Cloudflare Tunnel. +

+ +

Or with Docker, anywhere

docker run -d --name verdande \
   -p 8080:8080 \
   -v verdande-data:/data \
@@ -448,7 +559,10 @@
 					MIT licensed ·
 					github.com/kristianwind/verdande
 				

-

Deployed as a Rune in Yggdrasil.

+

+ Built to run as a Rune in + Yggdrasil Panel. +

diff --git a/site/screenshots/board.png b/site/screenshots/board.png new file mode 100644 index 0000000..0177166 Binary files /dev/null and b/site/screenshots/board.png differ diff --git a/site/screenshots/today-light.png b/site/screenshots/today-light.png new file mode 100644 index 0000000..dbe7e58 Binary files /dev/null and b/site/screenshots/today-light.png differ diff --git a/site/screenshots/today.png b/site/screenshots/today.png new file mode 100644 index 0000000..3ac99b8 Binary files /dev/null and b/site/screenshots/today.png differ diff --git a/site/screenshots/upcoming.png b/site/screenshots/upcoming.png new file mode 100644 index 0000000..455ca93 Binary files /dev/null and b/site/screenshots/upcoming.png differ diff --git a/tools/shots/main.go b/tools/shots/main.go new file mode 100644 index 0000000..7117ccc --- /dev/null +++ b/tools/shots/main.go @@ -0,0 +1,225 @@ +// Command shots captures the screenshots used on the site and in the README. +// +// It drives a headless Chrome over the DevTools protocol rather than using +// `--screenshot`, because the app needs a session and that flag cannot set a +// cookie. Kept in the repository so the images can be regenerated when the +// interface changes, rather than being a thing somebody did once by hand. +// +// go run ./tools/shots -url http://localhost:8096 -cookie -out site/screenshots +package main + +import ( + "context" + "encoding/base64" + "encoding/json" + "flag" + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "time" + + "github.com/coder/websocket" +) + +var chromePath = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" + +func main() { + base := flag.String("url", "http://localhost:8096", "the running instance") + cookie := flag.String("cookie", "", "session cookie value") + out := flag.String("out", "site/screenshots", "where to write the PNGs") + width := flag.Int("width", 1400, "viewport width") + height := flag.Int("height", 900, "viewport height") + flag.Parse() + + if err := run(*base, *cookie, *out, *width, *height); err != nil { + fmt.Fprintln(os.Stderr, "shots:", err) + os.Exit(1) + } +} + +type shot struct { + name string + path string + // theme is applied before the capture, so both palettes can be shown without + // a second run. + theme string + wait time.Duration +} + +func run(base, cookie, out string, width, height int) error { + if err := os.MkdirAll(out, 0o755); err != nil { + return err + } + + profile, err := os.MkdirTemp("", "verdande-shots-*") + if err != nil { + return err + } + defer os.RemoveAll(profile) + + chrome := exec.Command(chromePath, + "--headless=new", "--disable-gpu", "--no-first-run", "--no-default-browser-check", + "--disable-extensions", "--disable-sync", "--hide-scrollbars", + "--remote-debugging-port=9223", "--user-data-dir="+profile, + fmt.Sprintf("--window-size=%d,%d", width, height), "about:blank") + if err := chrome.Start(); err != nil { + return fmt.Errorf("start chrome: %w", err) + } + // Killed rather than asked to quit: headless Chrome does not exit on its own + // after a screenshot, which is what made the plain --screenshot flag hang. + defer chrome.Process.Kill() + + wsURL, err := waitForDevTools() + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + conn, _, err := websocket.Dial(ctx, wsURL, nil) + if err != nil { + return fmt.Errorf("connect to devtools: %w", err) + } + defer conn.CloseNow() + // A screenshot arrives base64-encoded in one frame, so the default 32 KiB + // read limit rejects every capture bigger than a blank page. + conn.SetReadLimit(64 << 20) + + c := &client{conn: conn, ctx: ctx} + + if _, err := c.send("Network.enable", nil); err != nil { + return err + } + if _, err := c.send("Page.enable", nil); err != nil { + return err + } + // The session cookie, so the captures show the app rather than the sign-in page. + if _, err := c.send("Network.setCookie", map[string]any{ + "name": "verdande_session", "value": cookie, + "domain": "localhost", "path": "/", "httpOnly": true, + }); err != nil { + return err + } + + shots := []shot{ + {name: "today", path: "/", theme: "dark", wait: 2500 * time.Millisecond}, + {name: "today-light", path: "/", theme: "light", wait: 1500 * time.Millisecond}, + {name: "upcoming", path: "/upcoming", theme: "dark", wait: 2000 * time.Millisecond}, + } + if project := os.Getenv("SHOT_PROJECT"); project != "" { + shots = append(shots, + shot{name: "board", path: "/projekt/" + project, theme: "dark", wait: 2500 * time.Millisecond}) + } + + for _, s := range shots { + if err := c.capture(base+s.path, s.theme, s.wait, filepath.Join(out, s.name+".png")); err != nil { + return fmt.Errorf("%s: %w", s.name, err) + } + fmt.Println("wrote", filepath.Join(out, s.name+".png")) + } + return nil +} + +func waitForDevTools() (string, error) { + for i := 0; i < 60; i++ { + resp, err := http.Get("http://127.0.0.1:9223/json/list") + if err == nil { + var targets []struct { + Type string `json:"type"` + WS string `json:"webSocketDebuggerUrl"` + } + json.NewDecoder(resp.Body).Decode(&targets) + resp.Body.Close() + for _, t := range targets { + if t.Type == "page" && t.WS != "" { + return t.WS, nil + } + } + } + time.Sleep(250 * time.Millisecond) + } + return "", fmt.Errorf("devtools never came up") +} + +type client struct { + conn *websocket.Conn + ctx context.Context + id int +} + +// send issues one command and waits for the reply with the matching id. Events +// arrive on the same socket and are skipped — they have no id. +func (c *client) send(method string, params map[string]any) (json.RawMessage, error) { + c.id++ + id := c.id + + body, err := json.Marshal(map[string]any{"id": id, "method": method, "params": params}) + if err != nil { + return nil, err + } + if err := c.conn.Write(c.ctx, websocket.MessageText, body); err != nil { + return nil, err + } + + for { + _, raw, err := c.conn.Read(c.ctx) + if err != nil { + return nil, err + } + var msg struct { + ID int `json:"id"` + Result json.RawMessage `json:"result"` + Error *struct { + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(raw, &msg); err != nil { + continue + } + if msg.ID != id { + continue + } + if msg.Error != nil { + return nil, fmt.Errorf("%s: %s", method, msg.Error.Message) + } + return msg.Result, nil + } +} + +func (c *client) capture(url, theme string, wait time.Duration, path string) error { + if _, err := c.send("Page.navigate", map[string]any{"url": url}); err != nil { + return err + } + // A fixed wait rather than waiting on a load event: the interesting part is + // after hydration and after the first data fetch, neither of which the load + // event says anything about. + time.Sleep(wait) + + if theme != "" { + if _, err := c.send("Runtime.evaluate", map[string]any{ + "expression": fmt.Sprintf("document.documentElement.dataset.theme=%q", theme), + }); err != nil { + return err + } + time.Sleep(400 * time.Millisecond) + } + + result, err := c.send("Page.captureScreenshot", map[string]any{"format": "png"}) + if err != nil { + return err + } + var shot struct { + Data string `json:"data"` + } + if err := json.Unmarshal(result, &shot); err != nil { + return err + } + decoded, err := base64.StdEncoding.DecodeString(shot.Data) + if err != nil { + return err + } + return os.WriteFile(path, decoded, 0o644) +} diff --git a/web/vite.config.js.timestamp-1786947770614-506f9339d94dd8.mjs b/web/vite.config.js.timestamp-1786947770614-506f9339d94dd8.mjs new file mode 100644 index 0000000..4c78b13 --- /dev/null +++ b/web/vite.config.js.timestamp-1786947770614-506f9339d94dd8.mjs @@ -0,0 +1,23 @@ +// vite.config.js +import { sveltekit } from "file:///Users/kw/Documents/Code/ToDoApp/web/node_modules/@sveltejs/kit/src/exports/vite/index.js"; +var vite_config_default = { + plugins: [sveltekit()], + server: { + port: 5173, + // In development the app runs on 5173 and the API on 8080. Proxying rather + // than pointing fetch at another origin keeps the session cookie + // first-party, which is what production looks like — otherwise every + // cookie and CSRF behaviour would differ between dev and the real thing. + proxy: { + "/api": { + target: "http://localhost:8080", + changeOrigin: false, + ws: true + } + } + } +}; +export { + vite_config_default as default +}; +//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZS5jb25maWcuanMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImNvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9kaXJuYW1lID0gXCIvVXNlcnMva3cvRG9jdW1lbnRzL0NvZGUvVG9Eb0FwcC93ZWJcIjtjb25zdCBfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfZmlsZW5hbWUgPSBcIi9Vc2Vycy9rdy9Eb2N1bWVudHMvQ29kZS9Ub0RvQXBwL3dlYi92aXRlLmNvbmZpZy5qc1wiO2NvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9pbXBvcnRfbWV0YV91cmwgPSBcImZpbGU6Ly8vVXNlcnMva3cvRG9jdW1lbnRzL0NvZGUvVG9Eb0FwcC93ZWIvdml0ZS5jb25maWcuanNcIjtpbXBvcnQgeyBzdmVsdGVraXQgfSBmcm9tICdAc3ZlbHRlanMva2l0L3ZpdGUnO1xuXG5leHBvcnQgZGVmYXVsdCB7XG5cdHBsdWdpbnM6IFtzdmVsdGVraXQoKV0sXG5cdHNlcnZlcjoge1xuXHRcdHBvcnQ6IDUxNzMsXG5cdFx0Ly8gSW4gZGV2ZWxvcG1lbnQgdGhlIGFwcCBydW5zIG9uIDUxNzMgYW5kIHRoZSBBUEkgb24gODA4MC4gUHJveHlpbmcgcmF0aGVyXG5cdFx0Ly8gdGhhbiBwb2ludGluZyBmZXRjaCBhdCBhbm90aGVyIG9yaWdpbiBrZWVwcyB0aGUgc2Vzc2lvbiBjb29raWVcblx0XHQvLyBmaXJzdC1wYXJ0eSwgd2hpY2ggaXMgd2hhdCBwcm9kdWN0aW9uIGxvb2tzIGxpa2UgXHUyMDE0IG90aGVyd2lzZSBldmVyeVxuXHRcdC8vIGNvb2tpZSBhbmQgQ1NSRiBiZWhhdmlvdXIgd291bGQgZGlmZmVyIGJldHdlZW4gZGV2IGFuZCB0aGUgcmVhbCB0aGluZy5cblx0XHRwcm94eToge1xuXHRcdFx0Jy9hcGknOiB7XG5cdFx0XHRcdHRhcmdldDogJ2h0dHA6Ly9sb2NhbGhvc3Q6ODA4MCcsXG5cdFx0XHRcdGNoYW5nZU9yaWdpbjogZmFsc2UsXG5cdFx0XHRcdHdzOiB0cnVlXG5cdFx0XHR9XG5cdFx0fVxuXHR9XG59O1xuIl0sCiAgIm1hcHBpbmdzIjogIjtBQUE4UixTQUFTLGlCQUFpQjtBQUV4VCxJQUFPLHNCQUFRO0FBQUEsRUFDZCxTQUFTLENBQUMsVUFBVSxDQUFDO0FBQUEsRUFDckIsUUFBUTtBQUFBLElBQ1AsTUFBTTtBQUFBO0FBQUE7QUFBQTtBQUFBO0FBQUEsSUFLTixPQUFPO0FBQUEsTUFDTixRQUFRO0FBQUEsUUFDUCxRQUFRO0FBQUEsUUFDUixjQUFjO0FBQUEsUUFDZCxJQUFJO0FBQUEsTUFDTDtBQUFBLElBQ0Q7QUFBQSxFQUNEO0FBQ0Q7IiwKICAibmFtZXMiOiBbXQp9Cg==