Yggdrasil is self-hosting too, so labelling only the Docker page "Self-hosting" was misleading and put it above the Rune it was built for. Reorder "As a Rune" above the Docker page, rename that page "With Docker", and make the landing hero's primary call to action the Rune, with Docker beside it. Also drop the "the grammar is Todoist's" line from the filters page — the factual import/export interoperability keeps naming Todoist, but the positioning and soft comparisons do not. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1455 lines
43 KiB
HTML
1455 lines
43 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, notes and projects, shared. One Go binary with SQLite inside it. Your mail as an inbox, a CalDAV server, notes in Markdown, 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, notes 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;
|
|
}
|
|
|
|
/* Written twice on purpose. The media query is what a first-time visitor
|
|
gets; the attribute is what somebody who has chosen gets, and it has to
|
|
win in both directions — a chosen light theme on a dark system as well
|
|
as the other way round. A single rule cannot do both. */
|
|
@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;
|
|
}
|
|
}
|
|
|
|
:root[data-theme='light'] {
|
|
--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;
|
|
}
|
|
|
|
.theme-toggle {
|
|
/* In the bar now rather than floating over the corner. Fixed, it sat
|
|
on top of whatever happened to scroll under it — on a narrow window
|
|
that was the first screenshot's own corner. */
|
|
flex: none;
|
|
width: 2.25rem;
|
|
height: 2.25rem;
|
|
display: grid;
|
|
place-items: center;
|
|
border: 1px solid var(--line);
|
|
border-radius: 999px;
|
|
background: var(--surface);
|
|
color: var(--ink-muted);
|
|
cursor: pointer;
|
|
font-size: 1rem;
|
|
line-height: 1;
|
|
}
|
|
|
|
.theme-toggle:hover {
|
|
color: var(--ink);
|
|
border-color: var(--line-strong);
|
|
}
|
|
|
|
/* Whichever screenshot does not belong to the theme in force is simply not
|
|
there. Both are in the markup so neither has to be fetched at the moment
|
|
somebody flips the switch. */
|
|
:root:not([data-theme='light']) .only-light,
|
|
:root[data-theme='dark'] .only-light {
|
|
display: none;
|
|
}
|
|
|
|
:root[data-theme='light'] .only-dark {
|
|
display: none;
|
|
}
|
|
|
|
@media (prefers-color-scheme: light) {
|
|
:root:not([data-theme='dark']) .only-light {
|
|
display: block;
|
|
}
|
|
:root:not([data-theme='dark']) .only-dark {
|
|
display: none;
|
|
}
|
|
}
|
|
|
|
*,
|
|
*::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;
|
|
}
|
|
|
|
/* --- the bar that stays -------------------------------------------
|
|
|
|
A page this long had its only way out at the very top: somebody who
|
|
read as far as "What it will not do" had to scroll back past nine
|
|
screenshots to reach the documentation. The bar is the way out from
|
|
wherever you are.
|
|
|
|
`sticky` and not `fixed`: sticky keeps its space in the flow, so
|
|
nothing below has to be pushed down by a number that then has to be
|
|
kept in step with the bar's own height.
|
|
|
|
Full width, with the wrap *inside* it. Put the other way round, the
|
|
background would end where the column ends and the page would scroll
|
|
past it on both sides. */
|
|
.topbar {
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 20;
|
|
background: var(--ground);
|
|
border-bottom: 1px solid var(--line);
|
|
}
|
|
|
|
.bar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1.25rem;
|
|
padding: 0.625rem 1.5rem;
|
|
}
|
|
|
|
.bar-brand {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
color: var(--ink);
|
|
text-decoration: none;
|
|
font-weight: 560;
|
|
letter-spacing: -0.01em;
|
|
}
|
|
|
|
.bar-brand .rune {
|
|
color: var(--accent);
|
|
font-size: 1.125rem;
|
|
line-height: 1;
|
|
}
|
|
|
|
.bar-links {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1.25rem;
|
|
margin-left: auto;
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.bar-links a {
|
|
color: var(--ink-muted);
|
|
text-decoration: none;
|
|
}
|
|
|
|
.bar-links a:hover {
|
|
color: var(--ink);
|
|
}
|
|
|
|
/* The three in-page links go first on a narrow screen: they are the ones
|
|
a phone can reach by scrolling anyway. Documentation and Source cannot
|
|
be reached any other way, so they stay. */
|
|
@media (max-width: 640px) {
|
|
.bar-links .inpage {
|
|
display: none;
|
|
}
|
|
}
|
|
|
|
/* Or the bar covers the heading you just jumped to. */
|
|
:target {
|
|
scroll-margin-top: 4.5rem;
|
|
}
|
|
|
|
/* --- hero --- */
|
|
|
|
.hero {
|
|
padding: 5rem 0 3.5rem;
|
|
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: 0 auto 1.75rem;
|
|
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 --- */
|
|
|
|
/* A hairline between sections rather than under every heading: the page
|
|
is one column, and the line says where one subject stops. */
|
|
section {
|
|
padding: 3.5rem 0;
|
|
border-top: 1px solid var(--line);
|
|
}
|
|
|
|
h2 {
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: 0.75rem;
|
|
font-size: 1.5rem;
|
|
font-weight: 560;
|
|
letter-spacing: -0.02em;
|
|
margin: 0 0 2rem;
|
|
}
|
|
|
|
/* A short accent rule in front of every h2. The sections had nothing to
|
|
mark them but their own words, and at a glance the page read as one
|
|
long run of paragraphs. */
|
|
h2::before {
|
|
content: '';
|
|
flex: none;
|
|
width: 1.125rem;
|
|
height: 2px;
|
|
border-radius: 1px;
|
|
background: var(--accent);
|
|
transform: translateY(-0.35em);
|
|
}
|
|
|
|
/* Three groups rather than eighteen cards in a row. Grouped, the list is
|
|
read as three answers — what it does, what it talks to, what it
|
|
promises — and any of the three can be skipped. */
|
|
.group-label {
|
|
margin: 2.5rem 0 1rem;
|
|
font-size: 0.75rem;
|
|
font-weight: 560;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.08em;
|
|
color: var(--ink-faint);
|
|
}
|
|
|
|
.group-label:first-of-type {
|
|
margin-top: 0;
|
|
}
|
|
|
|
/* Cards with their own edges, not cells in one table.
|
|
The table shared a hairline grid, which meant the last row had to be
|
|
full or it was a hole in the drawing — and it was a hole, for as long
|
|
as there were ten of them. A card that stands alone on its row is a
|
|
card; an empty cell is a mistake. */
|
|
.grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
|
|
gap: 0.875rem;
|
|
}
|
|
|
|
.cell {
|
|
background: var(--surface);
|
|
border: 1px solid var(--line);
|
|
border-radius: var(--radius-lg);
|
|
padding: 1.125rem 1.25rem 1.25rem;
|
|
transition: border-color 150ms ease;
|
|
}
|
|
|
|
.cell:hover {
|
|
border-color: var(--line-strong);
|
|
}
|
|
|
|
.cell h3 {
|
|
margin: 0 0 0.4375rem;
|
|
font-size: 0.9375rem;
|
|
font-weight: 560;
|
|
}
|
|
|
|
.cell p {
|
|
margin: 0;
|
|
font-size: 0.875rem;
|
|
color: var(--ink-muted);
|
|
}
|
|
|
|
.cell code {
|
|
font-family: var(--font-mono);
|
|
font-size: 0.8125em;
|
|
color: var(--ink);
|
|
}
|
|
|
|
/* A card that has something to admit rather than something to offer.
|
|
It is not a footnote and it is not fine print: it is the same size as
|
|
everything else, and it says the same thing the README says. */
|
|
.cell .caveat {
|
|
margin: 0.5rem 0 0;
|
|
padding-top: 0.5rem;
|
|
border-top: 1px solid var(--line);
|
|
font-size: 0.8125rem;
|
|
color: var(--ink-faint);
|
|
}
|
|
|
|
/* --- 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);
|
|
}
|
|
|
|
/* Four facts under the buttons, in the place where a landing page usually
|
|
puts logos it has not earned. They are the four questions somebody
|
|
self-hosting asks before anything else. */
|
|
ul.facts {
|
|
list-style: none;
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
justify-content: center;
|
|
gap: 0.5rem 1.25rem;
|
|
margin: 2rem 0 0;
|
|
padding: 0;
|
|
font-size: 0.8125rem;
|
|
color: var(--ink-faint);
|
|
}
|
|
|
|
ul.facts li {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1.25rem;
|
|
}
|
|
|
|
/* The separator belongs between two facts, not after the last one — so it
|
|
is drawn by the item that follows it. */
|
|
ul.facts li + li::before {
|
|
content: '';
|
|
width: 3px;
|
|
height: 3px;
|
|
border-radius: 50%;
|
|
background: var(--line-strong);
|
|
}
|
|
|
|
/* On a phone the row wraps, and a separator drawn by the item that follows
|
|
it lands at the *start* of the new line — a dot with nothing before it.
|
|
No separators there; the wrap is the separation. */
|
|
@media (max-width: 640px) {
|
|
ul.facts {
|
|
gap: 0.375rem 1.125rem;
|
|
}
|
|
|
|
ul.facts li + li::before {
|
|
display: none;
|
|
}
|
|
}
|
|
|
|
/* Two ways to run it, side by side, because they are alternatives rather
|
|
than steps. Stacked, the second read as what you do after the first. */
|
|
.panels {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(19rem, 1fr));
|
|
gap: 0.875rem;
|
|
}
|
|
|
|
.panel {
|
|
background: var(--surface);
|
|
border: 1px solid var(--line);
|
|
border-radius: var(--radius-lg);
|
|
padding: 1.25rem;
|
|
}
|
|
|
|
.panel h3 {
|
|
margin: 0 0 0.5rem;
|
|
font-size: 0.9375rem;
|
|
font-weight: 560;
|
|
}
|
|
|
|
/* Sunk rather than raised: inside a card, a block in --surface would be
|
|
the same colour as the card it sits in. */
|
|
.panel pre {
|
|
background: var(--ground);
|
|
margin: 1rem 0 0;
|
|
}
|
|
|
|
.panel p {
|
|
margin: 0;
|
|
font-size: 0.875rem;
|
|
color: var(--ink-muted);
|
|
}
|
|
|
|
/* Versionsnummeret i sin egen søjle, så øjet kan løbe ned ad dem uden at
|
|
læse sætningerne. `ch` frem for pixels: søjlen er lige så bred som de
|
|
tal, der står i den, og den følger med, hvis skriften skifter. */
|
|
ul.lately {
|
|
list-style: none;
|
|
margin: 1.5rem 0 0;
|
|
padding: 0;
|
|
}
|
|
|
|
ul.lately li {
|
|
display: grid;
|
|
grid-template-columns: 5ch 1fr;
|
|
gap: 0 1rem;
|
|
padding: 0.875rem 0;
|
|
border-top: 1px solid var(--line);
|
|
font-size: 0.9375rem;
|
|
color: var(--ink-muted);
|
|
}
|
|
|
|
ul.lately .ver {
|
|
font-family: var(--font-mono);
|
|
font-size: 0.8125rem;
|
|
color: var(--ink-faint);
|
|
/* Tallet står på linje med den første tekstlinje og ikke over den. */
|
|
line-height: 1.7;
|
|
}
|
|
|
|
@media (max-width: 520px) {
|
|
ul.lately li {
|
|
grid-template-columns: 1fr;
|
|
gap: 0.25rem;
|
|
}
|
|
}
|
|
|
|
/* Risikoen står i kort, ikke i småt.
|
|
Et forbehold sat med mindre skrift end resten er et forbehold, der er
|
|
skrevet for at kunne peges på bagefter — og det er den ene ting, denne
|
|
side ikke skal gøre. Samme størrelse som alt andet. */
|
|
.risk {
|
|
border: 1px solid var(--line-strong);
|
|
border-radius: var(--radius-lg);
|
|
padding: 1.5rem;
|
|
background: var(--surface);
|
|
}
|
|
|
|
.risk p {
|
|
margin: 0 0 1rem;
|
|
font-size: 0.9375rem;
|
|
color: var(--ink-muted);
|
|
}
|
|
|
|
.risk p:last-child {
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
.risk strong {
|
|
color: var(--ink);
|
|
}
|
|
|
|
/* The non-goals were one paragraph of seven sentences, which is the shape
|
|
nobody reads. Each is its own line now, and they can be counted. */
|
|
ul.nogo {
|
|
list-style: none;
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
|
|
gap: 0.5rem 1.75rem;
|
|
margin: 0;
|
|
padding: 0;
|
|
font-size: 0.9375rem;
|
|
color: var(--ink-muted);
|
|
}
|
|
|
|
ul.nogo li {
|
|
position: relative;
|
|
padding-left: 1.25rem;
|
|
}
|
|
|
|
ul.nogo li::before {
|
|
content: '—';
|
|
position: absolute;
|
|
left: 0;
|
|
color: var(--ink-faint);
|
|
}
|
|
|
|
.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="topbar">
|
|
<div class="wrap bar">
|
|
<a class="bar-brand" href="#top">
|
|
<span class="rune" aria-hidden="true">ᚹ</span>
|
|
verdande
|
|
</a>
|
|
<nav class="bar-links" aria-label="Sections">
|
|
<a class="inpage" href="#screens">Screens</a>
|
|
<a class="inpage" href="#features">Features</a>
|
|
<a class="inpage" href="#running">Running it</a>
|
|
<a href="https://kristianwind.github.io/verdande/">Documentation</a>
|
|
<a href="https://github.com/kristianwind/verdande">Source</a>
|
|
<button class="theme-toggle" type="button" aria-label="Switch between light and dark" title="Light or dark">
|
|
<span aria-hidden="true">◐</span>
|
|
</button>
|
|
</nav>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="wrap" id="top">
|
|
<header class="hero">
|
|
<span class="mark" aria-hidden="true">ᚹ</span>
|
|
<h1>verdande</h1>
|
|
<p class="lede">
|
|
Self-hosted tasks, notes 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>
|
|
|
|
<div class="actions">
|
|
<a class="button primary" href="https://kristianwind.github.io/verdande/rune/">
|
|
As a Rune
|
|
</a>
|
|
<a class="button" href="https://kristianwind.github.io/verdande/self-hosting/">
|
|
With Docker
|
|
</a>
|
|
<a class="button" href="https://github.com/kristianwind/verdande">Source</a>
|
|
<a class="button" href="https://kristianwind.github.io/verdande/">Documentation</a>
|
|
</div>
|
|
|
|
<ul class="facts">
|
|
<li>One Go binary</li>
|
|
<li>SQLite inside it</li>
|
|
<li>amd64 and arm64</li>
|
|
<li>No account anywhere but yours</li>
|
|
<li>MIT</li>
|
|
</ul>
|
|
</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 id="screens">
|
|
<h2>What it looks like</h2>
|
|
<figure class="shot only-dark">
|
|
<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>
|
|
|
|
<figure class="shot only-light">
|
|
<img
|
|
src="screenshots/today-light.webp"
|
|
alt="The Today view in the light theme: a sidebar of projects, filters and labels beside a list of tasks due today."
|
|
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/calendar-light.webp"
|
|
alt="A week in the calendar: timed tasks drawn to their length, a fifteen-minute stand-up short and a ninety-minute planning block tall, each on the day and at the time it is due."
|
|
width="1400"
|
|
height="900"
|
|
loading="lazy"
|
|
/>
|
|
<figcaption>The calendar draws a task to its length — drag its foot to change it.</figcaption>
|
|
</figure>
|
|
<figure class="shot">
|
|
<img
|
|
src="screenshots/notes-shared-light.webp"
|
|
alt="Notes with a Shared with me group at the top of the list, holding a note somebody else shared, marked with whose it is, open on the right."
|
|
width="1400"
|
|
height="900"
|
|
loading="lazy"
|
|
/>
|
|
<figcaption>A note shared with you, gathered under Shared with me.</figcaption>
|
|
</figure>
|
|
<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, an arrow to whoever a task is delegated to, and a snoozed card greyed at the foot of its column."
|
|
width="1400"
|
|
height="900"
|
|
loading="lazy"
|
|
/>
|
|
<figcaption>Board view — one column per section. A delegated card carries an arrow to whoever has it; a snoozed one greys at the foot.</figcaption>
|
|
</figure>
|
|
<figure class="shot">
|
|
<img
|
|
src="screenshots/notes.webp"
|
|
alt="Notes: a list of notes on the left, one open on the right with headings, bullet lists, a quotation and a shell command in a code block."
|
|
width="1400"
|
|
height="900"
|
|
loading="lazy"
|
|
/>
|
|
<figcaption>Notes. Rich text on top, Markdown underneath.</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, priority, due date, labels, sub-tasks and an attached file."
|
|
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 five themes as swatches and, below them, four looks written in the typeface each one selects."
|
|
width="1400"
|
|
height="900"
|
|
loading="lazy"
|
|
/>
|
|
<figcaption>Five themes and four looks: how bright, and how it reads.</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>
|
|
<figure class="shot">
|
|
<img
|
|
src="screenshots/delegated.webp"
|
|
alt="Waiting on others: tasks that have been handed to somebody else, grouped under the person who has them, each row carrying an arrow to that person."
|
|
width="1400"
|
|
height="900"
|
|
loading="lazy"
|
|
/>
|
|
<figcaption>Waiting on others — grouped by who has it, each marked with an arrow to them.</figcaption>
|
|
</figure>
|
|
<figure class="shot">
|
|
<img
|
|
src="screenshots/notes-source.webp"
|
|
alt="A note with the source view open: the same note as Markdown, with headings, a list, a quotation, a wiki link and a fenced code block."
|
|
width="1400"
|
|
height="900"
|
|
loading="lazy"
|
|
/>
|
|
<figcaption>The same note as Markdown. What is on disk is what you see.</figcaption>
|
|
</figure>
|
|
<figure class="shot">
|
|
<img
|
|
src="screenshots/settings.webp"
|
|
alt="The integrations settings page: connecting Gmail with an OAuth client, and below it the calendar feed address."
|
|
width="1400"
|
|
height="900"
|
|
loading="lazy"
|
|
/>
|
|
<figcaption>Integrations: your mailbox, your calendar feed, your address.</figcaption>
|
|
</figure>
|
|
</div>
|
|
</section>
|
|
|
|
<section id="features">
|
|
<h2>What it does</h2>
|
|
|
|
<p class="group-label">Doing the work</p>
|
|
<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>Nothing to remember</h3>
|
|
<p>
|
|
Start typing anywhere and you are writing a task, with the letter you
|
|
pressed already in the field. <code>⌘K</code> goes anywhere else.
|
|
</p>
|
|
</div>
|
|
<div class="cell">
|
|
<h3>Search that reads Danish</h3>
|
|
<p>
|
|
Type <code>gron</code> and find <code>grøn</code>. Unicode does not
|
|
consider <code>ø</code> an accented <code>o</code>, so a search that
|
|
leans on that alone is broken for the people this was built for.
|
|
</p>
|
|
</div>
|
|
<div class="cell">
|
|
<h3>Repeats, and says so in words</h3>
|
|
<p>
|
|
Recurring tasks are RRULE underneath and "every other Tuesday" on the
|
|
screen. Reminders are absolute or relative, and go out as Web Push to
|
|
an iPhone or a Mac you have allowed them on.
|
|
</p>
|
|
</div>
|
|
<div class="cell">
|
|
<h3>Notes, beside the work</h3>
|
|
<p>
|
|
Rich text to write in, Markdown on disk — and a source view to see it.
|
|
Images are pasted or dragged in. <code>#project</code> and
|
|
<code>[[another note]]</code> are links, and a task shows what has been
|
|
written about it. Archive what is done with it; the bin is for mistakes.
|
|
</p>
|
|
</div>
|
|
<div class="cell">
|
|
<h3>Share a note, or a page</h3>
|
|
<p>
|
|
Hand a single note to a person — to read or to edit — without standing up
|
|
a project for it. Notes other people share with you gather under
|
|
<em>Shared with me</em>, each marked with whose it is. File it in a project
|
|
instead and everyone who can read the project reads the note.
|
|
</p>
|
|
</div>
|
|
<div class="cell">
|
|
<h3>How long it takes</h3>
|
|
<p>
|
|
A task can carry a length, and the calendar draws it to scale. Drag the
|
|
foot of a task to make it longer or shorter, or drag its body to another
|
|
day or time — the length comes along.
|
|
</p>
|
|
</div>
|
|
<div class="cell">
|
|
<h3>Waiting on others</h3>
|
|
<p>
|
|
What you have handed to somebody else, on its own page, grouped under
|
|
the person who has it — and marked where you meet it too, with an arrow
|
|
out of your hands, so a list reads at a glance as which tasks are yours
|
|
and which you are waiting on.
|
|
</p>
|
|
</div>
|
|
<div class="cell">
|
|
<h3>Snooze it out of the way</h3>
|
|
<p>
|
|
Park a task to this evening, tomorrow, next week, or a moment you pick.
|
|
It greys and sinks to the bottom of the list until then, and comes back
|
|
by itself — without touching when it is actually due.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<p class="group-label">Connected to what you already use</p>
|
|
<div class="grid">
|
|
<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>A calendar of its own</h3>
|
|
<p>
|
|
A month or a week of what is due, and a secret ICS address to subscribe
|
|
to from Apple Calendar, Google or Thunderbird. A Google calendar can be
|
|
laid over the top, read-only.
|
|
</p>
|
|
<p class="caveat">
|
|
The Google half is written to Google's documentation and has not yet
|
|
been run against Google. The feed and CalDAV have.
|
|
</p>
|
|
</div>
|
|
<div class="cell">
|
|
<h3>Your mail, as an inbox</h3>
|
|
<p>
|
|
Star a mail in Gmail, or flag one over IMAP 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>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>AI only if you ask for it</h3>
|
|
<p>
|
|
Off by default, your own key, and your choice of Anthropic, OpenAI,
|
|
Google or something local. Nothing leaves the server until you have
|
|
filled that in, and everything works without it.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<p class="group-label">Yours, and checkable</p>
|
|
<div class="grid">
|
|
<div class="cell">
|
|
<h3>More than one way in</h3>
|
|
<p>
|
|
Passkeys, or a password hashed with Argon2id and a second factor with
|
|
recovery codes. Every device you are signed in on is listed, and any of
|
|
them can be signed out from here.
|
|
</p>
|
|
</div>
|
|
<div class="cell">
|
|
<h3>Keys stay behind</h3>
|
|
<p>
|
|
Mail tokens, mailbox passwords, second-factor secrets and AI keys 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>Nobody signs themselves up</h3>
|
|
<p>
|
|
The first account is the administrator, and after that people arrive by
|
|
invitation. An address on the internet is not an open door.
|
|
</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 class="cell">
|
|
<h3>Yours to leave</h3>
|
|
<p>
|
|
Import from Todoist, export back to it, take everything as JSON, or
|
|
hand the notes a zip of Markdown and get the same zip back out. The
|
|
round trip is tested, not promised.
|
|
</p>
|
|
</div>
|
|
<div class="cell">
|
|
<h3>It counts itself, out loud</h3>
|
|
<p>
|
|
Once a day an instance says it exists — a random id, the version, and
|
|
nothing else. No tasks, no names, no addresses. It is on to begin with,
|
|
the settings page prints the exact payload, and one switch stops it.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section id="recently">
|
|
<h2>Lately</h2>
|
|
<p class="muted">
|
|
A landing page can say a project is alive; a list of what landed says it
|
|
better. This is not a changelog — every release has notes, and they are on
|
|
<a href="https://github.com/kristianwind/verdande/releases">the releases page</a>.
|
|
</p>
|
|
<ul class="lately">
|
|
<li>
|
|
<span class="ver">0.41</span>
|
|
A task you have delegated is marked where you meet it, not only on the
|
|
"Waiting on others" page. A task can be snoozed to the bottom of the list
|
|
until a moment you choose, and comes back by itself.
|
|
</li>
|
|
<li>
|
|
<span class="ver">0.41</span>
|
|
Web Push reminders reach an iPhone and a Mac again, and every dated task
|
|
now shows up in a calendar you have subscribed to — not only the timed ones.
|
|
</li>
|
|
<li>
|
|
<span class="ver">0.41</span>
|
|
The calendar draws each task to its length: drag the foot to resize, or the
|
|
body to move it. A single note can be shared with one person, and a
|
|
<code>[[note]]</code> is a link you follow inline.
|
|
</li>
|
|
<li>
|
|
<span class="ver">0.34</span>
|
|
Sections can be dragged into the order the work actually happens in. The
|
|
sidebar keeps its head still while the projects scroll.
|
|
</li>
|
|
<li>
|
|
<span class="ver">0.33</span>
|
|
Notes grew an archive, selection of many at once, pictures you can paste
|
|
or drag in, a source view, and a copy button on code blocks. Search
|
|
stopped answering by date and started answering by relevance.
|
|
</li>
|
|
</ul>
|
|
</section>
|
|
|
|
<section id="running">
|
|
<h2>Running it</h2>
|
|
|
|
<div class="panels">
|
|
<div class="panel">
|
|
<h3>As a Rune in Yggdrasil Panel</h3>
|
|
<p>
|
|
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>
|
|
</div>
|
|
<div class="panel">
|
|
<h3>Or with Docker, anywhere</h3>
|
|
<p>Open the address afterwards and create the first account. That one is the administrator.</p>
|
|
<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>
|
|
</div>
|
|
</div>
|
|
|
|
<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>
|
|
<li>Twenty-four migrations, run on start. Upgrading is pulling the new image.</li>
|
|
</ul>
|
|
</section>
|
|
|
|
<section id="limits">
|
|
<h2>What it will not do</h2>
|
|
<ul class="nogo">
|
|
<li>No karma, streaks or points.</li>
|
|
<li>No team workspaces — projects and the people in them are the whole model.</li>
|
|
<li>No native apps. It is a PWA, and it installs on a phone.</li>
|
|
<li>No Outlook or Exchange: Microsoft has closed IMAP to passwords and wants its own flow.</li>
|
|
<li>No languages beyond Danish and English.</li>
|
|
<li>No sync protocol of its own — CalDAV and the API <em>are</em> the sync.</li>
|
|
<li>No hosted version. There is nowhere to sign up, and that is the point.</li>
|
|
</ul>
|
|
</section>
|
|
|
|
<section id="risk">
|
|
<h2>At your own risk</h2>
|
|
<div class="risk">
|
|
<p>
|
|
verdande is <strong>MIT licensed and provided as is, without warranty of
|
|
any kind</strong>. That sentence is in every MIT licence and it is usually
|
|
skipped, so here it is in the open: if you run this, you run it at your
|
|
own risk, and nobody is on the hook if it loses your work.
|
|
</p>
|
|
<p>
|
|
It is one person's project, at version 0.x, and 0.x means the shape can
|
|
still change. <strong>You host it, so the data and the backups are
|
|
yours.</strong> It writes a backup every night and keeps fourteen — and a
|
|
backup nobody has ever restored is not yet a backup. Restore one before
|
|
you need to.
|
|
</p>
|
|
<p>
|
|
Security is worked on rather than claimed. There have been two full
|
|
reviews of the code, every finding is written down and closed, and what is
|
|
protected and what is not is spelled out in
|
|
<a href="https://github.com/kristianwind/verdande/blob/main/SECURITY.md">SECURITY.md</a>
|
|
— including the parts that are a deliberate trade-off. <strong>Nobody
|
|
outside the project has audited it.</strong> The last review found two ways
|
|
to reach something you should not and closed both; a third review will find
|
|
more, which is the point of having one.
|
|
</p>
|
|
<p>
|
|
It is built to sit behind something — a tunnel, a reverse proxy, a VPN —
|
|
put there by somebody who knows what they exposed. Found a hole? Mail it
|
|
rather than opening an issue; the address is in SECURITY.md and you get an
|
|
answer within a week.
|
|
</p>
|
|
</div>
|
|
</section>
|
|
|
|
<footer>
|
|
<!-- The Norn belongs at the end. It was the third paragraph of the hero,
|
|
where it stood between somebody arriving and the buttons they came
|
|
for — and it is a closing thought, not an opening one. -->
|
|
<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>
|
|
<p>
|
|
MIT licensed ·
|
|
<a href="https://github.com/kristianwind/verdande">github.com/kristianwind/verdande</a>
|
|
· <a href="https://kristianwind.github.io/verdande/">Documentation</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">×</button>
|
|
<img alt="" />
|
|
<figcaption></figcaption>
|
|
</dialog>
|
|
|
|
<script>
|
|
// Runs before the zoom script and before anything is painted below it, so
|
|
// a chosen theme does not flash the other one first.
|
|
(function () {
|
|
var root = document.documentElement;
|
|
var stored = null;
|
|
try {
|
|
stored = localStorage.getItem('verdande-site-theme');
|
|
} catch (e) {
|
|
// Private browsing. The choice simply will not be remembered.
|
|
}
|
|
if (stored === 'light' || stored === 'dark') root.setAttribute('data-theme', stored);
|
|
|
|
document.querySelector('.theme-toggle').addEventListener('click', function () {
|
|
// What is on the screen right now, whether it came from a choice or
|
|
// from the system — otherwise the first press does nothing visible
|
|
// for somebody whose system already says light.
|
|
var showing =
|
|
root.getAttribute('data-theme') ||
|
|
(matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
|
|
var next = showing === 'light' ? 'dark' : 'light';
|
|
root.setAttribute('data-theme', next);
|
|
try {
|
|
localStorage.setItem('verdande-site-theme', next);
|
|
} catch (e) {}
|
|
});
|
|
})();
|
|
|
|
(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>
|