Compare commits

...
Author SHA1 Message Date
kristianwindandClaude Opus 4.8 247277e139 feat(users): always capitalise a display name's first letter
Andreas signed up as "andreas" and was shown that way everywhere. A name
is now capitalised on the way in — the first letter of each word raised,
the rest left as typed — in CreateUser (signup and invite-accept) and
UpdateProfile, so display, mail and export all agree.

Existing accounts are fixed by an idempotent startup pass
(NormalizeUserNames), the same shape as SealNotes: the rule is Unicode-aware
and per word, which SQLite's UPPER cannot spell, so it lives in Go rather
than a migration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 09:25:50 +02:00
Kristian Wind f3b037c7f4 Merge pull request #34 from kristianwind/task-linkify-urls
Release / Build and publish image (push) Failing after 8s
feat(tasks): make a URL in a task clickable (title and description)
2026-08-27 16:40:25 +02:00
kristianwindandClaude Opus 4.8 4103e1b0ca fix(tasks): open the task by clicking its text, links carved out
The first cut put an open-button overlay over the row, which intercepted
every click on the title text — nine smoke tests (and real users) open a
task by clicking its text, and the overlay stole those clicks. Make the
body a role=button instead, with each link stopping its own click from
bubbling: plain text opens the task, a link opens the page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-27 16:33:30 +02:00
kristianwindandClaude Opus 4.8 9d4a13c2c2 feat(tasks): make a URL in a task's title or description clickable
A task is plain text — the title is an input, the description a textarea —
so a pasted URL just sat there as words. It is now caught at display time
and shown as a link in the list and on the board, without the stored text
ever being anything but plain text.

The link cannot live inside the row's open-button (an <a> in a <button> is
invalid), so the body is a container with the open action as an overlay
behind it: plain text and the meta row still open the task, links are
raised above and take their own click. Conservative about what counts —
an explicit http(s):// or a bare www. host, not every dotted word.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-27 16:17:56 +02:00
Kristian Wind 923f911472 Merge pull request #33 from kristianwind/settings-buy-me-a-coffee
Release / Build and publish image (push) Failing after 6s
feat(settings): a quiet Buy me a coffee link
2026-08-27 14:55:59 +02:00
kristianwindandClaude Opus 4.8 272c4918b0 feat(settings): a quiet Buy me a coffee link
A donate link belongs in Settings and nowhere in the way — so it sits last,
understated, below the device list. A plain anchor with an inline SVG cup:
no third-party script, no webfont, no tracking, so it works offline and
cannot phone home. Wording says the app is MIT with nothing held back and
that nothing changes if you don't pay.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-27 14:49:24 +02:00
Kristian Wind ff866cc514 Merge pull request #32 from kristianwind/drop-todoist-pro-claim
docs(site): drop the Todoist-Pro claim, lead with the Rune
2026-08-27 14:12:21 +02:00
kristianwindandClaude Opus 4.8 e94d731c30 docs(site): lead with the Rune, and drop the Todoist comparison from filters
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>
2026-08-27 14:05:18 +02:00
kristianwindandClaude Opus 4.8 0aa0ec2989 docs: stop describing verdande as "Todoist Pro"
Claiming to be Todoist Pro is both inaccurate and an invitation to a
trademark complaint. Describe verdande on its own terms — a fast,
self-hosted task manager — and keep the Todoist references to what they
factually are: import/export interop, and a filter grammar close enough
that people coming from there already know it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-27 13:59:54 +02:00
Kristian Wind 21b60b007a Merge pull request #31 from kristianwind/site-refresh-delegate-snooze
docs(site): new features, refreshed screenshots and text
2026-08-27 13:08:34 +02:00
15 changed files with 413 additions and 21 deletions
+4 -3
View File
@@ -24,9 +24,10 @@ Documentation: [`docs/`](docs/), built with Material for MkDocs.
## Why this exists
Todoist Pro, self-hosted, on your own hardware, with your own data — and with the
integrations that a hosted product will not build for you: a real CalDAV server, a
mail-to-task address, and an MCP endpoint so Claude can work your task list directly.
A fast, self-hosted task manager — projects, quick add, filters, notes and shared
lists — on your own hardware, with your own data, and with the integrations that a
hosted product will not build for you: a real CalDAV server, a mail-to-task address,
and an MCP endpoint so Claude can work your task list directly.
## What it does
+10
View File
@@ -80,6 +80,16 @@ func run() error {
log.Info("notes sealed", "count", sealed)
}
// Names created before the app capitalised them on the way in — "andreas"
// rather than "Andreas". Idempotent, so it is a cheap pass of names once it has
// caught up. A failure here does not stop the start: a lower-case first name is
// a blemish, not a broken instance.
if fixed, err := db.NormalizeUserNames(context.Background()); err != nil {
log.Error("normalize user names", "err", err)
} else if fixed > 0 {
log.Info("user names normalized", "count", fixed)
}
httpapi.Version = version
api := httpapi.New(cfg, db, log, webAssets(log))
+2 -2
View File
@@ -1,7 +1,7 @@
# Saved filters
A filter is a small expression describing a slice of your tasks. The grammar is
Todoist's, so if you are coming from there you already know it.
A filter is a small expression describing a slice of your tasks. The grammar will
feel familiar if you have used query-based filters in other task apps.
```
today & p1
+2 -1
View File
@@ -11,7 +11,8 @@ of. Which is what a to-do list is.
## What it is for
Todoist Pro, on your own hardware, with your own data. And with the integrations a
A fast, self-hosted task manager — projects, quick add, filters, notes and shared
lists — on your own hardware, with your own data. And with the integrations a
hosted product will not build for you: a real CalDAV server, an address you can
forward mail to, and an MCP endpoint so Claude can work your task list directly.
+4 -3
View File
@@ -1,7 +1,8 @@
# Self-hosting
# With Docker
verdande is one static binary and one directory. There is no database server, no
cache, no queue and no second process.
Running it yourself outside a panel: one static binary and one directory. There is
no database server, no cache, no queue and no second process. (Yggdrasil is
self-hosting too — for that route see [As a Rune](rune.md).)
## Docker
+81
View File
@@ -0,0 +1,81 @@
package store
import (
"context"
"testing"
)
func TestNormalizeName(t *testing.T) {
cases := map[string]string{
"andreas": "Andreas",
"Andreas": "Andreas",
"anne mette": "Anne Mette",
" kristian ": "Kristian",
"anne mette": "Anne Mette",
"McDonald": "McDonald", // an existing inner capital is kept
"óscar": "Óscar", // Unicode initial
"æblegaard": "Æblegaard",
"": "",
" ": "",
"mette-marie sø": "Mette-marie Sø", // only word-initials are raised
"iPhone listen": "IPhone Listen", // deliberately: the first letter is raised
}
for in, want := range cases {
if got := NormalizeName(in); got != want {
t.Errorf("NormalizeName(%q) = %q, want %q", in, got, want)
}
}
}
func TestCreateUserCapitalisesName(t *testing.T) {
db := openTest(t)
ctx := context.Background()
u := &User{Email: "andreas@example.dk", Name: "andreas", PasswordHash: "x"}
if err := db.CreateUser(ctx, u, "Inbox"); err != nil {
t.Fatalf("create user: %v", err)
}
got, err := db.UserByEmail(ctx, "andreas@example.dk")
if err != nil {
t.Fatalf("read back: %v", err)
}
if got.Name != "Andreas" {
t.Errorf("stored name = %q, want %q", got.Name, "Andreas")
}
}
func TestNormalizeUserNamesBackfill(t *testing.T) {
db := openTest(t)
ctx := context.Background()
// An account as it looked before the app capitalised names: made properly,
// then set back to lower case behind the store's back to stand in for an old row.
u := &User{Email: "a@example.dk", Name: "Andreas", PasswordHash: "x"}
if err := db.CreateUser(ctx, u, "Inbox"); err != nil {
t.Fatalf("create user: %v", err)
}
if _, err := db.ExecContext(ctx, `UPDATE users SET name = 'andreas' WHERE id = ?`, u.ID); err != nil {
t.Fatalf("downgrade name: %v", err)
}
fixed, err := db.NormalizeUserNames(ctx)
if err != nil {
t.Fatalf("normalize: %v", err)
}
if fixed != 1 {
t.Fatalf("fixed = %d, want 1", fixed)
}
got, _ := db.UserByID(ctx, u.ID)
if got.Name != "Andreas" {
t.Errorf("name after backfill = %q, want %q", got.Name, "Andreas")
}
// Idempotent: nothing left to do on a second pass.
again, err := db.NormalizeUserNames(ctx)
if err != nil {
t.Fatalf("normalize again: %v", err)
}
if again != 0 {
t.Errorf("second pass fixed = %d, want 0", again)
}
}
+73
View File
@@ -8,6 +8,7 @@ import (
"fmt"
"strings"
"time"
"unicode"
"github.com/kristianwind/verdande/internal/auth"
)
@@ -62,6 +63,26 @@ func NormalizeEmail(email string) string {
return strings.ToLower(strings.TrimSpace(email))
}
// NormalizeName trims a display name and capitalises the first letter of every
// word, leaving the rest of each word exactly as typed. So "andreas" becomes
// "Andreas" and "anne mette" becomes "Anne Mette", while a name that already
// carries a capital in the middle — "McDonald", an "iPhone" — keeps it: only the
// first letter of a word is ever raised, never lowered.
//
// A name is the person's to spell; the one thing the instance insists on is that
// it does not start in lower case, because a column of lower-case first names
// reads as a bug the person cannot fix from their own keyboard. Unicode-aware, so
// "æblegaard" and "óscar" are raised the same as an ASCII initial.
func NormalizeName(name string) string {
fields := strings.Fields(name)
for i, f := range fields {
r := []rune(f)
r[0] = unicode.ToUpper(r[0])
fields[i] = string(r)
}
return strings.Join(fields, " ")
}
// CreateUser inserts a user and gives them an Inbox, in one transaction.
//
// The Inbox is created here rather than lazily on first use because every other
@@ -70,6 +91,7 @@ func NormalizeEmail(email string) string {
// of them end up racing to create the second one.
func (db *DB) CreateUser(ctx context.Context, u *User, inboxName string) error {
u.Email = NormalizeEmail(u.Email)
u.Name = NormalizeName(u.Name)
if u.ID == "" {
u.ID = NewID()
}
@@ -473,6 +495,7 @@ func (db *DB) UpdatePasswordHash(ctx context.Context, userID, hash, keepSessionI
// invites were sent to, and changing it is a re-verification flow rather than a
// field. Neither is is_admin — an account cannot promote itself.
func (db *DB) UpdateProfile(ctx context.Context, userID, name, timezone, locale string) error {
name = NormalizeName(name)
res, err := db.ExecContext(ctx,
`UPDATE users SET name = ?, timezone = ?, locale = ?, updated_at = ? WHERE id = ?`,
name, timezone, locale, time.Now().Unix(), userID)
@@ -485,6 +508,56 @@ func (db *DB) UpdateProfile(ctx context.Context, userID, name, timezone, locale
return nil
}
// NormalizeUserNames capitalises the first letter of every existing user's name,
// for the accounts created before the app started doing it on the way in.
//
// Idempotent: a name already normalised is left untouched, so it costs one pass
// of names at each start and writes nothing once it has caught up. Like SealNotes
// it runs at boot rather than as a migration, because the rule lives in Go —
// Unicode-aware and per word — and SQLite's own UPPER cannot spell it.
//
// `updated_at` is not touched: fixing the spelling of a name the person chose is
// the instance tidying up after itself, not the person editing their profile.
func (db *DB) NormalizeUserNames(ctx context.Context) (int, error) {
rows, err := db.QueryContext(ctx, `SELECT id, name FROM users`)
if err != nil {
return 0, err
}
type pending struct{ id, name string }
var todo []pending
for rows.Next() {
var id, name string
if err := rows.Scan(&id, &name); err != nil {
rows.Close()
return 0, err
}
if n := NormalizeName(name); n != name {
todo = append(todo, pending{id, n})
}
}
rows.Close()
if err := rows.Err(); err != nil {
return 0, err
}
if len(todo) == 0 {
return 0, nil
}
err = db.Tx(ctx, func(tx *sql.Tx) error {
for _, p := range todo {
if _, err := tx.ExecContext(ctx,
`UPDATE users SET name = ? WHERE id = ?`, p.name, p.id); err != nil {
return err
}
}
return nil
})
if err != nil {
return 0, err
}
return len(todo), nil
}
func (db *DB) SetTOTPSecret(ctx context.Context, userID, secret string, enabled bool) error {
// Sealed, like a mailbox password and for the same reason: the copies, not the
// server. A TOTP secret in a downloadable backup lets whoever holds the copy
+1 -1
View File
@@ -44,8 +44,8 @@ extra_css:
nav:
- Home: index.md
- Setting it up:
- Self-hosting: self-hosting.md
- As a Rune: rune.md
- With Docker: self-hosting.md
- Configuration: configuration.md
- Using it:
- Signing in: signing-in.md
+5 -2
View File
@@ -856,8 +856,11 @@
</p>
<div class="actions">
<a class="button primary" href="https://kristianwind.github.io/verdande/self-hosting/">
Self-host it
<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>
+28 -3
View File
@@ -86,6 +86,29 @@ test('hurtig tilføjelse opretter en opgave, og den kan lukkes', async ({ page }
expect(trouble).toEqual([]);
});
test('en URL i en opgave bliver et klikbart link, og resten åbner opgaven', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
const box = page.getByLabel('Ny opgave');
// The URL is one token, so the parser leaves it in the title; "i dag" is what it
// takes. The link is made at display time — the stored title is still plain text.
await box.fill('læs https://example.com/artikel i dag');
await box.press('Enter');
const row = page.locator('.row').filter({ hasText: 'læs' });
const link = row.getByRole('link', { name: 'https://example.com/artikel' });
await expect(link).toHaveAttribute('href', 'https://example.com/artikel');
await expect(link).toHaveAttribute('rel', /noopener/);
// Clicking the plain part of the title still opens the task; only the link is
// carved out. Aimed at the top-left, over "læs", not the URL in the middle.
await row.locator('.content').click({ position: { x: 3, y: 3 } });
await expect(page.getByRole('complementary', { name: 'Opgave' })).toBeVisible();
expect(trouble).toEqual([]);
});
test('hvert link i sidebjælken fører et sted hen', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
@@ -902,6 +925,8 @@ test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) =>
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
const andreas = await context.newPage();
await andreas.goto(link);
// Typed in lower case on purpose: the instance capitalises a name on the way
// in, so from here on this person is "Andreas" everywhere they are shown.
await andreas.getByLabel('Navn', { exact: true }).fill('andreas');
await andreas.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
await andreas.getByRole('button', { name: 'Opret konto' }).click();
@@ -920,7 +945,7 @@ test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) =>
await page.reload();
await projectAction(page, /Delt/);
await expect(page.getByText('Ejer')).toBeVisible();
await page.getByLabel('Rolle for andreas').selectOption('editor');
await page.getByLabel('Rolle for Andreas').selectOption('editor');
await andreas.reload();
await expect(andreas.getByLabel('Ny opgave')).toBeVisible();
@@ -933,11 +958,11 @@ test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) =>
await page.getByLabel('Ny opgave').press('Enter');
await page.getByText('brænde kaffe', { exact: true }).click();
const drawer = page.getByRole('complementary', { name: 'Opgave' });
await drawer.getByLabel('Ansvarlig').selectOption({ label: 'andreas' });
await drawer.getByLabel('Ansvarlig').selectOption({ label: 'Andreas' });
await page.keyboard.press('Escape');
const row = page.locator('.row').filter({ hasText: 'brænde kaffe' });
await expect(row.getByTitle('Uddelegeret til andreas')).toBeVisible();
await expect(row.getByTitle('Uddelegeret til Andreas')).toBeVisible();
// And not on a task that is nobody's.
await page.getByLabel('Ny opgave').fill('min egen');
await page.getByLabel('Ny opgave').press('Enter');
+59 -6
View File
@@ -11,6 +11,7 @@
import { app } from '$lib/stores.svelte.js';
import { t, tag } from '$lib/i18n.svelte.js';
import { repeatLabel } from '$lib/when.js';
import { linkify } from '$lib/linkify.js';
let { task, onedit } = $props();
@@ -99,13 +100,44 @@
</svg>
</button>
<!-- Opening the detail drawer is the default. `onedit` is for the callers that
want the click to mean something else. -->
<button class="body" onclick={() => (onedit ? onedit(task) : app.openDetail(task.id))}>
<span class="content">{task.content}</span>
<!-- A URL pasted into the title or the description is clickable, without the
text ever having been anything but plain text. The link is a real <a>, which
an <a> in a <button> may not be — so the body is a role=button rather than a
button, and each link stops its click from also opening the task. Clicking
any plain text still opens it. -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="body"
role="button"
tabindex="0"
onclick={() => (onedit ? onedit(task) : app.openDetail(task.id))}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onedit ? onedit(task) : app.openDetail(task.id);
}
}}
>
<span class="content"
>{#each linkify(task.content) as run}{#if run.href}<a
class="tlink"
href={run.href}
target="_blank"
rel="noopener noreferrer"
onclick={(e) => e.stopPropagation()}>{run.text}</a
>{:else}{run.text}{/if}{/each}</span
>
{#if task.description}
<span class="description">{task.description}</span>
<span class="description"
>{#each linkify(task.description) as run}{#if run.href}<a
class="tlink"
href={run.href}
target="_blank"
rel="noopener noreferrer"
onclick={(e) => e.stopPropagation()}>{run.text}</a
>{:else}{run.text}{/if}{/each}</span
>
{/if}
{#if assignee || due || task.labels?.length || repeats || task.subtask_count || task.attachment_count}
@@ -186,7 +218,7 @@
{/each}
</span>
{/if}
</button>
</div>
</div>
<style>
@@ -307,6 +339,13 @@
gap: 2px;
text-align: left;
min-width: 0;
cursor: pointer;
border-radius: var(--radius-sm);
}
.body:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.content {
@@ -315,6 +354,20 @@
overflow-wrap: anywhere;
}
/* A real link, so it can be middle-clicked, copied and previewed. Its own click
is stopped from bubbling, so opening the page does not also open the task. */
.tlink {
color: var(--accent);
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 2px;
overflow-wrap: anywhere;
}
.tlink:hover {
text-decoration-thickness: 2px;
}
.description {
font-size: var(--text-sm);
color: var(--ink-muted);
+68
View File
@@ -0,0 +1,68 @@
/**
* Splits a plain string into runs of text and the URLs inside it, so a task's
* title or description can be shown with the links clickable without the text
* ever having been anything but plain text on disk.
*
* A task is not rich text — the title is an `<input>` and the description a
* `<textarea>` — so there is nowhere to *store* a link. What there is instead is
* a person who pasted one in and expects to be able to click it. This finds the
* ones that are unambiguously links and leaves everything else exactly as typed.
*
* Deliberately conservative about what counts as a link: an explicit
* `http://`/`https://`, or a bare `www.` host. "example.com" on its own is not
* matched — most of those are sentences ("the file.txt is here"), and a false
* link in a title is worse than a real one left plain.
*/
// A trailing `.,;:!?` or a closing bracket is almost always punctuation the
// sentence owns rather than part of the URL, so the run stops before it. A `)`
// is only dropped when the URL holds no `(` of its own — Wikipedia-style links
// carry balanced parentheses that belong to the address.
const URL_RE = /\b(?:https?:\/\/|www\.)[^\s<>]+/gi;
function trimTrailing(url) {
let end = url.length;
while (end > 0) {
const ch = url[end - 1];
if ('.,;:!?"\''.includes(ch)) {
end--;
continue;
}
if (ch === ')' && !url.slice(0, end).includes('(')) {
end--;
continue;
}
break;
}
return url.slice(0, end);
}
/**
* @param {string} text
* @returns {Array<{ text: string, href?: string }>} runs in order; a run with an
* `href` is a link, one without is plain text. An empty or link-free string
* comes back as a single text run (possibly empty) so callers can render it the
* same way regardless.
*/
export function linkify(text) {
const value = text ?? '';
const runs = [];
let last = 0;
for (const match of value.matchAll(URL_RE)) {
const raw = trimTrailing(match[0]);
if (!raw) continue;
const start = match.index;
if (start > last) runs.push({ text: value.slice(last, start) });
const href = raw.startsWith('www.') ? `https://${raw}` : raw;
runs.push({ text: raw, href });
last = start + raw.length;
}
if (last < value.length) runs.push({ text: value.slice(last) });
if (!runs.length) runs.push({ text: value });
return runs;
}
+4
View File
@@ -266,6 +266,10 @@ export const da = {
'Hvor du er logget ind. Genkender du ikke en af dem, så log den ud og skift adgangskode.',
'account.thisDevice': 'denne enhed',
'account.signOutDevice': 'Log ud',
'account.support': 'Støt projektet',
'account.supportHint':
'verdande er fri, open source-software under MIT-licensen — ingen betalt version, intet holdt tilbage. Sparede den dig en eftermiddag, kan du give vedligeholderen en kop kaffe. Helt frivilligt, og intet i appen ændrer sig.',
'account.buyCoffee': 'Buy me a coffee',
'account.totpEnabled': 'Slået til.',
'account.recoveryLeftOne': '{n} gendannelseskode tilbage.',
+4
View File
@@ -262,6 +262,10 @@ export const en = {
'Where you are signed in. If you do not recognise one, sign it out and change your password.',
'account.thisDevice': 'this device',
'account.signOutDevice': 'Sign out',
'account.support': 'Support the project',
'account.supportHint':
'verdande is free, open-source software under the MIT licence — no paid tier, nothing held back. If it saved you an afternoon, you can buy the maintainer a coffee. Entirely optional, and nothing in the app changes either way.',
'account.buyCoffee': 'Buy me a coffee',
'account.totpEnabled': 'On.',
'account.recoveryLeftOne': '{n} recovery code left.',
+68
View File
@@ -571,6 +571,42 @@
</ul>
</section>
<!-- Last, and quietly: a donate link belongs in Settings, never in the way of the
work. A plain anchor with an inline SVG — no third-party script, no webfont,
no tracking — so it works offline and cannot phone home. See the coffee-cup
snippet for why not their generated button. -->
<section class="panel">
<header>
<h2>{t('account.support')}</h2>
<p class="hint">{t('account.supportHint')}</p>
</header>
<a
class="bmc"
href="https://buymeacoffee.com/kristianwind"
target="_blank"
rel="noopener noreferrer"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
focusable="false"
>
<path d="M4 9h13v6a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4V9Z" />
<path d="M17 10h1.5a2.5 2.5 0 0 1 0 5H17" />
<path d="M7.5 5.6c0-.9.8-1.1.8-2M11 5.6c0-.9.8-1.1.8-2M14.5 5.6c0-.9.8-1.1.8-2" />
</svg>
{t('account.buyCoffee')} ↗
</a>
</section>
<style>
.looks {
margin-top: var(--s4);
@@ -771,4 +807,36 @@
.uri {
overflow-wrap: anywhere;
}
/* The understated variant: present, not loud. Bordered and in the muted ink so
it reads as a footnote, brightening on hover. The cup is outline-only. */
.bmc {
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: var(--s2);
padding: var(--s2) var(--s3);
border: 1px solid var(--line-strong);
border-radius: var(--radius);
color: var(--ink-muted);
text-decoration: none;
font-size: var(--text-sm);
transition:
color var(--fast) var(--ease),
border-color var(--fast) var(--ease);
}
.bmc:hover {
color: var(--ink);
border-color: var(--ink-muted);
}
.bmc:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.bmc svg {
flex: none;
}
</style>