Skærmbillederne lå som PNG i fuld skærmopløsning — 2,1 MB for syv billeder på en side, der viser dem under 900 pixels bred. Som WebP i dobbelt visningsbredde fylder de 424 kB. Samme billeder, samme skarphed, en femtedel af vægten. Indholdet fangede aldrig op på det, der blev bygget: postkasser over IMAP ved siden af Gmail, passkeys, at man kan skrive hvor som helst for at fange noget, at hemmeligheder ligger krypteret med nøglen uden for databasen, og historikken. Ni felter nu, hvilket også fylder gitteret ud i stedet for at efterlade et tomt. Og en fejl, jeg selv lavede undervejs: jeg lagde en description-meta ind, fordi min søgning ikke så den, der lå i forvejen — den var delt over flere linjer. Dubletten er væk, og den oprindelige er opdateret i stedet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
641 lines
16 KiB
HTML
641 lines
16 KiB
HTML
<!doctype html>
|
|
<html lang="en" data-theme="dark">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>verdande — self-hosted tasks, shared</title>
|
|
<meta
|
|
name="description"
|
|
content="Self-hosted tasks and projects, shared. One Go binary with SQLite inside it. Your mail as an inbox, CalDAV, passkeys, and an MCP connector for Claude."
|
|
/>
|
|
<meta name="theme-color" content="#111312" />
|
|
|
|
<meta property="og:title" content="verdande" />
|
|
<meta property="og:description" content="Self-hosted tasks and projects, shared. One binary, SQLite inside it." />
|
|
<meta property="og:type" content="website" />
|
|
<meta property="og:image" content="screenshots/today.webp" />
|
|
<meta property="og:image:alt" content="The Today view: projects and filters beside a list of what is due." />
|
|
<!-- Summary rather than the large card: the screenshot is a dense interface,
|
|
and shrunk into a wide banner it reads as grey noise. -->
|
|
<meta name="twitter:card" content="summary" />
|
|
|
|
<link rel="icon" href="icon.svg" type="image/svg+xml" />
|
|
|
|
<style>
|
|
/*
|
|
* The same tokens as the app, so the site and the thing it describes
|
|
* look like one project rather than two.
|
|
*/
|
|
:root {
|
|
--ground: #111312;
|
|
--surface: #171a19;
|
|
--surface-raised: #1d211f;
|
|
--line: #262b29;
|
|
--line-strong: #343a37;
|
|
--ink: #e8ebe9;
|
|
--ink-muted: #9aa39e;
|
|
--ink-faint: #6b736f;
|
|
--accent: #5fbf8f;
|
|
--accent-ink: #0d1411;
|
|
--radius: 8px;
|
|
--radius-lg: 12px;
|
|
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Inter, system-ui, sans-serif;
|
|
--font-mono: ui-monospace, 'SF Mono', 'JetBrains Mono', Menlo, monospace;
|
|
}
|
|
|
|
@media (prefers-color-scheme: light) {
|
|
:root:not([data-theme='dark']) {
|
|
--ground: #faf9f7;
|
|
--surface: #ffffff;
|
|
--surface-raised: #ffffff;
|
|
--line: #e5e3de;
|
|
--line-strong: #d3d0c9;
|
|
--ink: #1c201e;
|
|
--ink-muted: #5f6763;
|
|
--ink-faint: #8b918d;
|
|
--accent: #2f8f63;
|
|
--accent-ink: #ffffff;
|
|
}
|
|
}
|
|
|
|
*,
|
|
*::before,
|
|
*::after {
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
margin: 0;
|
|
background: var(--ground);
|
|
color: var(--ink);
|
|
font-family: var(--font);
|
|
font-size: 16px;
|
|
line-height: 1.6;
|
|
-webkit-font-smoothing: antialiased;
|
|
}
|
|
|
|
.wrap {
|
|
max-width: 940px;
|
|
margin: 0 auto;
|
|
padding: 0 1.5rem;
|
|
}
|
|
|
|
/* --- hero --- */
|
|
|
|
header {
|
|
padding: 6rem 0 4rem;
|
|
text-align: center;
|
|
}
|
|
|
|
.mark {
|
|
font-size: 3rem;
|
|
color: var(--accent);
|
|
line-height: 1;
|
|
display: block;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
h1 {
|
|
margin: 0;
|
|
font-size: clamp(2.5rem, 7vw, 3.75rem);
|
|
font-weight: 560;
|
|
letter-spacing: -0.03em;
|
|
line-height: 1.05;
|
|
}
|
|
|
|
.lede {
|
|
margin: 1.25rem auto 0;
|
|
max-width: 30rem;
|
|
font-size: 1.125rem;
|
|
color: var(--ink-muted);
|
|
}
|
|
|
|
.norn {
|
|
margin: 2rem auto 0;
|
|
max-width: 34rem;
|
|
font-size: 0.9375rem;
|
|
color: var(--ink-faint);
|
|
}
|
|
|
|
.actions {
|
|
display: flex;
|
|
gap: 0.75rem;
|
|
justify-content: center;
|
|
flex-wrap: wrap;
|
|
margin-top: 2.5rem;
|
|
}
|
|
|
|
.button {
|
|
display: inline-block;
|
|
padding: 0.625rem 1.25rem;
|
|
border-radius: var(--radius);
|
|
text-decoration: none;
|
|
font-size: 0.9375rem;
|
|
font-weight: 550;
|
|
border: 1px solid var(--line-strong);
|
|
color: var(--ink);
|
|
transition: border-color 150ms ease, background 150ms ease;
|
|
}
|
|
|
|
.button:hover {
|
|
border-color: var(--accent);
|
|
}
|
|
|
|
.button.primary {
|
|
background: var(--accent);
|
|
color: var(--accent-ink);
|
|
border-color: var(--accent);
|
|
}
|
|
|
|
.button.primary:hover {
|
|
opacity: 0.9;
|
|
}
|
|
|
|
/* --- the one screenshot substitute: the thing it actually does --- */
|
|
|
|
.demo {
|
|
margin: 4rem 0;
|
|
background: var(--surface);
|
|
border: 1px solid var(--line);
|
|
border-radius: var(--radius-lg);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.demo-bar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.75rem 1rem;
|
|
border-bottom: 1px solid var(--line);
|
|
font-size: 0.8125rem;
|
|
color: var(--ink-faint);
|
|
}
|
|
|
|
.demo-body {
|
|
padding: 1.5rem;
|
|
font-family: var(--font-mono);
|
|
font-size: 0.875rem;
|
|
line-height: 2;
|
|
overflow-x: auto;
|
|
}
|
|
|
|
.typed {
|
|
white-space: nowrap;
|
|
}
|
|
|
|
/* The tints match the app's own parse highlighting. */
|
|
mark {
|
|
background: color-mix(in srgb, var(--accent) 20%, transparent);
|
|
color: var(--ink);
|
|
border-radius: 3px;
|
|
padding: 1px 2px;
|
|
}
|
|
|
|
mark.p {
|
|
background: color-mix(in srgb, #cf6b6b 22%, transparent);
|
|
}
|
|
|
|
mark.proj {
|
|
background: color-mix(in srgb, #6b9ecf 22%, transparent);
|
|
}
|
|
|
|
mark.lbl {
|
|
background: color-mix(in srgb, #d69f5c 22%, transparent);
|
|
}
|
|
|
|
.arrow {
|
|
color: var(--ink-faint);
|
|
padding: 0 0.5rem;
|
|
}
|
|
|
|
.result {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.5rem;
|
|
align-items: center;
|
|
margin-top: 0.75rem;
|
|
font-family: var(--font);
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.chip {
|
|
border: 1px solid var(--line-strong);
|
|
border-radius: 999px;
|
|
padding: 1px 0.625rem;
|
|
color: var(--ink-muted);
|
|
font-size: 0.75rem;
|
|
}
|
|
|
|
/* --- features --- */
|
|
|
|
section {
|
|
padding: 3rem 0;
|
|
}
|
|
|
|
h2 {
|
|
font-size: 1.5rem;
|
|
font-weight: 560;
|
|
letter-spacing: -0.02em;
|
|
margin: 0 0 2rem;
|
|
}
|
|
|
|
.grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
|
gap: 1px;
|
|
background: var(--line);
|
|
border: 1px solid var(--line);
|
|
border-radius: var(--radius-lg);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.cell {
|
|
background: var(--ground);
|
|
padding: 1.5rem;
|
|
}
|
|
|
|
.cell h3 {
|
|
margin: 0 0 0.5rem;
|
|
font-size: 1rem;
|
|
font-weight: 560;
|
|
}
|
|
|
|
.cell p {
|
|
margin: 0;
|
|
font-size: 0.9375rem;
|
|
color: var(--ink-muted);
|
|
}
|
|
|
|
/* --- install --- */
|
|
|
|
pre {
|
|
background: var(--surface);
|
|
border: 1px solid var(--line);
|
|
border-radius: var(--radius);
|
|
padding: 1rem 1.25rem;
|
|
overflow-x: auto;
|
|
font-family: var(--font-mono);
|
|
font-size: 0.8125rem;
|
|
line-height: 1.7;
|
|
color: var(--ink);
|
|
}
|
|
|
|
.muted {
|
|
color: var(--ink-muted);
|
|
font-size: 0.9375rem;
|
|
}
|
|
|
|
ul.plain {
|
|
list-style: none;
|
|
padding: 0;
|
|
margin: 1rem 0 0;
|
|
color: var(--ink-muted);
|
|
font-size: 0.9375rem;
|
|
}
|
|
|
|
ul.plain li {
|
|
padding-left: 1.25rem;
|
|
position: relative;
|
|
margin-bottom: 0.375rem;
|
|
}
|
|
|
|
ul.plain li::before {
|
|
content: '·';
|
|
position: absolute;
|
|
left: 0.375rem;
|
|
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;
|
|
padding: 2rem 0 4rem;
|
|
text-align: center;
|
|
color: var(--ink-faint);
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
footer a {
|
|
color: var(--ink-muted);
|
|
}
|
|
|
|
a {
|
|
color: var(--accent);
|
|
}
|
|
</style>
|
|
</head>
|
|
|
|
<body>
|
|
<div class="wrap">
|
|
<header>
|
|
<span class="mark" aria-hidden="true">ᚹ</span>
|
|
<h1>verdande</h1>
|
|
<p class="lede">
|
|
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
|
|
in the middle of. Which is what a to-do list is.
|
|
</p>
|
|
|
|
<div class="actions">
|
|
<a class="button primary" href="https://kristianwind.github.io/verdande/self-hosting/">
|
|
Self-host it
|
|
</a>
|
|
<a class="button" href="https://github.com/kristianwind/verdande">Source</a>
|
|
<a class="button" href="https://kristianwind.github.io/verdande/">Documentation</a>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- No screenshot: the quick-add parser is the thing worth showing, and it
|
|
shows better as the actual text than as a picture of it. -->
|
|
<div class="demo">
|
|
<div class="demo-bar">
|
|
<span aria-hidden="true">+</span>
|
|
<span>Add a task</span>
|
|
</div>
|
|
<div class="demo-body">
|
|
<div class="typed">
|
|
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>file the VAT return</strong>
|
|
<span class="chip">Fri 10:00</span>
|
|
<span class="chip">P1</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.webp"
|
|
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.webp"
|
|
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.webp"
|
|
alt="The same Today view in the light theme."
|
|
width="1400"
|
|
height="900"
|
|
loading="lazy"
|
|
/>
|
|
<figcaption>The light theme.</figcaption>
|
|
</figure>
|
|
<figure class="shot">
|
|
<img
|
|
src="screenshots/week.webp"
|
|
alt="Upcoming as a single week: seven columns of days, each holding the tasks due that day."
|
|
width="1400"
|
|
height="900"
|
|
loading="lazy"
|
|
/>
|
|
<figcaption>A week at a time — so a task can be dragged across a month boundary.</figcaption>
|
|
</figure>
|
|
<figure class="shot">
|
|
<img
|
|
src="screenshots/detail.webp"
|
|
alt="A task open in the detail drawer, with its description, due date, labels, sub-tasks and comments."
|
|
width="1400"
|
|
height="900"
|
|
loading="lazy"
|
|
/>
|
|
<figcaption>A task, opened.</figcaption>
|
|
</figure>
|
|
<figure class="shot">
|
|
<img
|
|
src="screenshots/themes.webp"
|
|
alt="The settings page showing the five themes as swatches."
|
|
width="1400"
|
|
height="900"
|
|
loading="lazy"
|
|
/>
|
|
<figcaption>Five themes, all of them measured for contrast.</figcaption>
|
|
</figure>
|
|
<figure class="shot">
|
|
<img
|
|
src="screenshots/history.webp"
|
|
alt="The instance-wide history page: who did what, in which project, filtered by person and event."
|
|
width="1400"
|
|
height="900"
|
|
loading="lazy"
|
|
/>
|
|
<figcaption>What has been done on the server, across every project.</figcaption>
|
|
</figure>
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<h2>What it does</h2>
|
|
<div class="grid">
|
|
<div class="cell">
|
|
<h3>Reads what you type</h3>
|
|
<p>
|
|
Dates, times, priorities, projects, labels and repetition, parsed out of
|
|
one line — in Danish and English, mixed freely.
|
|
</p>
|
|
</div>
|
|
<div class="cell">
|
|
<h3>Shared, properly</h3>
|
|
<p>
|
|
Owner, editor and viewer roles. People outside your instance join by
|
|
invite link. Changes appear live.
|
|
</p>
|
|
</div>
|
|
<div class="cell">
|
|
<h3>A real CalDAV server</h3>
|
|
<p>
|
|
Two-way. Tick something off in Apple Reminders and it is ticked off
|
|
here. Not a read-only feed pretending.
|
|
</p>
|
|
</div>
|
|
<div class="cell">
|
|
<h3>Claude, connected</h3>
|
|
<p>
|
|
A built-in MCP server. Ask what is due this week, or have a task added,
|
|
in conversation.
|
|
</p>
|
|
</div>
|
|
<div class="cell">
|
|
<h3>Your mail, as an inbox</h3>
|
|
<p>
|
|
Star a mail in Gmail, or flag one in iCloud or Fastmail, and it becomes
|
|
a task. Forward one to your own address instead and the subject is
|
|
parsed like anything else you type. One way: unstarring it later does
|
|
nothing to the task you made.
|
|
</p>
|
|
</div>
|
|
<div class="cell">
|
|
<h3>Nothing to remember</h3>
|
|
<p>
|
|
Start typing anywhere and you are writing a task, with the letter you
|
|
pressed already in the field. Sign in with a passkey and there is no
|
|
password to forget either.
|
|
</p>
|
|
</div>
|
|
<div class="cell">
|
|
<h3>Yours to leave</h3>
|
|
<p>
|
|
Import from Todoist, export back to it, or take everything as JSON. The
|
|
round trip is tested, not promised.
|
|
</p>
|
|
</div>
|
|
<div class="cell">
|
|
<h3>Keys stay behind</h3>
|
|
<p>
|
|
Mail tokens and mailbox passwords are encrypted in the database, and the
|
|
key lives in a file beside it — not inside it. So a backup you download,
|
|
or one that ends up somewhere it should not, does not open your mail.
|
|
</p>
|
|
</div>
|
|
<div class="cell">
|
|
<h3>Who did what</h3>
|
|
<p>
|
|
Every change is written down — who moved it, who closed it, who let
|
|
somebody in. Delete the account and the trail stays: it is the
|
|
instance's record, not the person's.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<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 \
|
|
-e VERDANDE_BASE_URL=https://todo.example.dk \
|
|
ghcr.io/kristianwind/verdande:latest</code></pre>
|
|
<p class="muted" style="margin-top: 1rem">
|
|
Open the address and create the first account. That one is the administrator.
|
|
</p>
|
|
<ul class="plain">
|
|
<li>No database to provision — SQLite is inside the binary.</li>
|
|
<li>Distroless image: no shell, no package manager, one static file.</li>
|
|
<li>amd64 and arm64, so a Raspberry Pi is fine.</li>
|
|
<li>Nightly backups, fourteen kept, written with <code>VACUUM INTO</code>.</li>
|
|
</ul>
|
|
</section>
|
|
|
|
<section>
|
|
<h2>What it will not do</h2>
|
|
<p class="muted">
|
|
No karma or points. No team workspaces. No native apps. No mail integration
|
|
beyond Gmail and the forwarding address. No languages beyond Danish and English.
|
|
And no sync protocol of its own — CalDAV and the API <em>are</em> the sync.
|
|
</p>
|
|
</section>
|
|
|
|
<footer>
|
|
<p>
|
|
MIT licensed ·
|
|
<a href="https://github.com/kristianwind/verdande">github.com/kristianwind/verdande</a>
|
|
</p>
|
|
<p>
|
|
Built to run as a Rune in
|
|
<a href="https://github.com/kristianwind/yggdrasil">Yggdrasil Panel</a>.
|
|
</p>
|
|
</footer>
|
|
</div>
|
|
</body>
|
|
</html>
|