docs(site): real screenshots, an English example, and the Rune said plainly

The landing page and README now show the app rather than describing it.
The images are captured by tools/shots, which drives a headless Chrome
over the DevTools protocol — Chrome's own --screenshot flag cannot set a
session cookie, so it would only ever photograph the sign-in page. Kept
in the repository so they can be regenerated when the interface changes,
rather than being something somebody did once by hand.

The quick-add example on the site is in English, with the Danish
equivalent shown underneath — the page is read by people who do not
speak Danish, and the point being made is that both work.

Yggdrasil is stated up front rather than left to the footer: being a
Rune is what this was built for, and somebody running the panel should
learn that in the first paragraph instead of the last line.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Kristian Wind
2026-08-17 08:37:10 +02:00
co-authored by Claude Opus 5
parent 5546e191ac
commit a8e523b377
14 changed files with 393 additions and 8 deletions
+17
View File
@@ -5,6 +5,10 @@
One binary, SQLite inside it, deployed as a Rune in Yggdrasil Panel.
</p>
<p align="center">
<img src="site/screenshots/today.png" alt="The Today view: projects, filters and labels in the sidebar, beside the tasks due today." width="900">
</p>
---
**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
+6
View File
@@ -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 \
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Executable
BIN
View File
Binary file not shown.
+122 -8
View File
@@ -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.
</p>
<p class="rune-note">
Built to be deployed as a <strong>Rune</strong> in
<a href="https://github.com/kristianwind/yggdrasil">Yggdrasil Panel</a> — upload
the manifest, fill in two fields, press start. It also runs anywhere Docker does.
</p>
<p class="norn">
Verdande is one of the three Norns — the one who spins <em>that which is
becoming</em>. Not what has been, and not what is fated to be: the thing you are
@@ -353,21 +412,63 @@
</div>
<div class="demo-body">
<div class="typed">
betal moms <mark>i morgen</mark> <mark>kl 10</mark>
<mark class="p">p1</mark> <mark class="proj">#Firma</mark>
<mark class="lbl">@regnskab</mark>
file the VAT return <mark>next friday</mark> <mark>at 10am</mark>
<mark class="p">p1</mark> <mark class="proj">#Accounts</mark>
<mark class="lbl">@tax</mark>
</div>
<div class="result">
<span class="arrow">→</span>
<strong>betal moms</strong>
<span class="chip">Tomorrow 10:00</span>
<strong>file the VAT return</strong>
<span class="chip">Fri 10:00</span>
<span class="chip">P1</span>
<span class="chip">Firma</span>
<span class="chip">regnskab</span>
<span class="chip">Accounts</span>
<span class="chip">tax</span>
</div>
<p class="demo-note">
The same line works in Danish —
<code>betal moms på fredag kl 10 p1 #Firma @regnskab</code> — and the two
can be mixed mid-sentence.
</p>
</div>
</div>
<section>
<h2>What it looks like</h2>
<figure class="shot">
<img
src="screenshots/today.png"
alt="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."
width="1400"
height="900"
loading="lazy"
/>
<figcaption>Today. Dark is the default; there is a light theme too.</figcaption>
</figure>
<div class="shots">
<figure class="shot">
<img
src="screenshots/board.png"
alt="A project in board view: one column per section, with task cards showing due dates, labels and repetition."
width="1400"
height="900"
loading="lazy"
/>
<figcaption>Board view — one column per section.</figcaption>
</figure>
<figure class="shot">
<img
src="screenshots/today-light.png"
alt="The same Today view in the light theme."
width="1400"
height="900"
loading="lazy"
/>
<figcaption>The light theme.</figcaption>
</figure>
</div>
</section>
<section>
<h2>What it does</h2>
<div class="grid">
@@ -418,6 +519,16 @@
<section>
<h2>Running it</h2>
<h3 class="sub">As a Rune in Yggdrasil Panel</h3>
<p class="muted">
This is what it was built for. <strong>Runes → Carve a rune</strong>, upload
<code>rune/verdande.yaml</code>, 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.
</p>
<h3 class="sub">Or with Docker, anywhere</h3>
<pre><code>docker run -d --name verdande \
-p 8080:8080 \
-v verdande-data:/data \
@@ -448,7 +559,10 @@
MIT licensed ·
<a href="https://github.com/kristianwind/verdande">github.com/kristianwind/verdande</a>
</p>
<p>Deployed as a Rune in <a href="https://github.com/kristianwind/yggdrasil">Yggdrasil</a>.</p>
<p>
Built to run as a Rune in
<a href="https://github.com/kristianwind/yggdrasil">Yggdrasil Panel</a>.
</p>
</footer>
</div>
</body>
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

+225
View File
@@ -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 <session> -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)
}
@@ -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==