Files
verdande/site/index.html
T
Kristian WindandClaude Opus 5 d02d8edfe9
Release / Build and publish image (push) Failing after 10m5s
feat(site): klik et skærmbillede og se det i læsbar størrelse
Et skærmbillede af en brugerflade siger ingenting ved 281 pixels, og det er der,
de fleste af dem sidder i gitteret. Et klik åbner nu det samme billede omkring
tre en halv gang større.

<dialog> frem for et selvbygget lag: Escape lukker, fokus bliver inde i den,
resten af siden bliver inert — tre ting browseren gør gratis og som et selvbygget
overlay skal have kode for hver af. Billedet får rolle og tabindex, så det også
kan åbnes fra tastaturet; en kontrol, kun en mus kan nå, er ikke en kontrol alle
har.

To ting rettet undervejs, som først kunne ses ved at kigge: billedet fik lov at
blive højere end skærmen, så billedtekst og lukkeknap røg op over kanten — og
lukkeknappen er den synlige vej ud. Og dialogen fyldte hele bredden i stedet for
at slutte om billedet, så skærmbilledet stod op ad venstre kant med knappen ude i
det modsatte hjørne. Nu er den fit-content og centreret, og alt er inden for
vinduet ned til en telefonbredde.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 11:14:13 +02:00

777 lines
20 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;
}
/* --- zoom -------------------------------------------------------------
A screenshot of an interface is unreadable at 280px in a three-column
grid, which is where most of them sit. Clicking one opens it at the
size it was taken.
<dialog> rather than a hand-built overlay: Escape closes it, focus is
trapped, the rest of the page is inert, and the browser does all three
without a line of script. */
.shot img {
cursor: zoom-in;
}
/* Sized so the whole thing fits: a screenshot is 1.55 times wider than it
is tall, so on a laptop the height is what runs out first. Left to grow,
it pushed the caption and the close button off the top of the screen —
and the button is the visible way out. */
dialog.zoom {
max-width: 96vw;
max-height: 100vh;
/* Wraps the image rather than the viewport. With a plain `auto` the box
stayed full width, so the screenshot sat against the left edge and
the close button ended up in the far corner, nowhere near it. */
width: fit-content;
margin: auto;
padding: 2.5rem 0 0;
border: 0;
background: transparent;
overflow: visible;
}
dialog.zoom::backdrop {
background: rgb(0 0 0 / 0.82);
}
dialog.zoom img {
max-width: 96vw;
max-height: 78vh;
width: auto;
height: auto;
display: block;
border-radius: var(--radius-lg);
cursor: zoom-out;
}
dialog.zoom figcaption {
margin-top: 0.75rem;
font-size: 0.8125rem;
color: #a8b0ac;
text-align: center;
}
/* The whole backdrop closes, so the button is a visible affordance rather
than the only way out. */
dialog.zoom .close {
position: absolute;
top: 0;
right: 0;
background: none;
border: 0;
color: #a8b0ac;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
padding: 0.25rem 0.5rem;
}
dialog.zoom .close:hover {
color: #fff;
}
@media (prefers-reduced-motion: no-preference) {
dialog.zoom[open] {
animation: zoom-in 0.15s ease-out;
}
@keyframes zoom-in {
from {
opacity: 0;
transform: scale(0.98);
}
}
}
.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>
<!-- Zoom. Added by script rather than written into the markup, so a page with
no JavaScript is exactly what it was before: the screenshots are still
there, still readable, just not clickable. -->
<dialog class="zoom" aria-label="Screenshot, enlarged">
<button class="close" type="button" aria-label="Close">&times;</button>
<img alt="" />
<figcaption></figcaption>
</dialog>
<script>
(function () {
var dialog = document.querySelector('dialog.zoom');
if (!dialog || typeof dialog.showModal !== 'function') return;
var big = dialog.querySelector('img');
var caption = dialog.querySelector('figcaption');
function open(img) {
big.src = img.currentSrc || img.src;
big.alt = img.alt;
var fig = img.closest('figure');
var text = fig && fig.querySelector('figcaption');
caption.textContent = text ? text.textContent : '';
dialog.showModal();
}
document.querySelectorAll('.shot img').forEach(function (img) {
// Focusable and operable from the keyboard: a control that only a
// mouse can reach is not a control everybody has.
img.tabIndex = 0;
img.setAttribute('role', 'button');
img.addEventListener('click', function () { open(img); });
img.addEventListener('keydown', function (event) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
open(img);
}
});
});
dialog.addEventListener('click', function (event) {
// The backdrop is the dialog itself; anything inside it is not.
if (event.target === dialog || event.target === big ||
event.target.classList.contains('close')) {
dialog.close();
}
});
// Let go of the image when it closes, so a large screenshot is not
// held in memory for the rest of the visit.
dialog.addEventListener('close', function () { big.removeAttribute('src'); });
})();
</script>
</body>
</html>