Compare commits

...
Author SHA1 Message Date
kristianwindandClaude Opus 4.8 378980de0c fix(sidebar): the scrollbar sits at the edge, thin and dim
It sat a pad-width inside the right edge because the scroller lives inside the
sidebar's padding, so it read as floating in the middle. Pull the scroller out
to the border and pad the content back — the rows do not move — and make the
bar thin and dim so it is present without drawing the eye.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 12:14:39 +02:00
Kristian Wind 04c7a52a5a Merge pull request #38 from kristianwind/sidebar-color-polish
Release / Build and publish image (push) Failing after 5s
feat(ui): colour fixed-view icons, lift notes surfaces, pin the create header
2026-09-03 12:08:32 +02:00
kristianwindandClaude Opus 4.8 52df528994 test(e2e): retry the project→group drop until it nests
A native HTML5 drag occasionally does not register under CI load, leaving
the group empty and the group-page assertion failing for a reason unrelated
to the test. Retry the drop until the project actually sits under the group.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 12:00:02 +02:00
kristianwindandClaude Opus 4.8 2f00246d6f feat(ui): colour the fixed-view icons, lift the notes surfaces, fix the create header
Following the Things pass, four bits of polish:

- The fixed views carry their own colour — amber star for Today, blue tray
  for the Inbox, red Upcoming, violet Waiting on others, teal Notes, indigo
  Calendar — and keep it whether or not they are the active row.
- The active row was all but invisible on the dark sidebar (a half-step of
  surface); it is now an accent-tinted selection that reads in both themes.
- The Projekter heading — the only place to add a project or a group — no
  longer scrolls away with a long list; it is pinned to the top of the
  scroller.
- The note list and the editor lift off the page: white in light, a nuance
  lighter than the ground in dark, so the two read as one writing surface.

Screenshots regenerated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 11:47:17 +02:00
Kristian Wind acec77a20c Merge pull request #37 from kristianwind/sidebar-things-nav
Release / Build and publish image (push) Failing after 6s
feat(sidebar): icons for fixed views, bold areas, one straight edge
2026-09-03 10:17:56 +02:00
kristianwindandClaude Opus 4.8 59413a8b64 test(e2e): a grouped project now shares the left margin, not indented
The sidebar redesign puts every project at the same left edge; flip the
assertion from "indented more than a fixed view" to "in line with it".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 10:09:32 +02:00
kristianwindandClaude Opus 4.8 cf69cac410 feat(sidebar): icons for the fixed views, bold areas, one straight edge
Toward Things' calm: the fixed views carry icons instead of dots — a star
for Today, a calendar for Upcoming, a person for Waiting on others, a tray
for the Inbox. Areas (the user's groups) are set in bold, plainly cased, so
the eye lands on them the way it lands on an area in Things; the system
sections (Projekter, Filtre, Etiketter) stay quiet. And projects sit at the
same left margin whether or not they are in an area — one straight edge down
the sidebar rather than a staircase, with the bold heading doing the
grouping the indent used to.

Screenshots regenerated to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 10:02:53 +02:00
Kristian Wind b0998fe524 Merge pull request #36 from kristianwind/settings-font-sizes
Release / Build and publish image (push) Failing after 11s
feat(settings): menu and body-text font size options
2026-09-02 15:39:06 +02:00
kristianwindandClaude Opus 4.8 8dfab618f7 feat(settings): menu and body-text font size, and a bolder menu by default
The sidebar read as muted 13px; the menu items now use their own size at
medium weight so they carry at a glance (Todoist-ish). Both sizes are
choices under Settings → Appearance:

- Menu font size drives a dedicated --menu-size the sidebar uses.
- Body text size re-scales the shared type tokens (the list and the notes),
  the same mechanism a look uses.

The two are independent on purpose — one is a glance down a list of names,
the other a paragraph you read. Kept in localStorage like theme and look,
and applied in app.html before first paint so a chosen size does not flash
in and resize. Four steps each (Small/Default/Large/Largest); Default
carries no attribute so the base tokens stand.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 15:30:30 +02:00
Kristian Wind c0c1281415 Merge pull request #35 from kristianwind/capitalise-user-names
Release / Build and publish image (push) Failing after 5s
feat(users): always capitalise a display name's first letter
2026-08-30 09:32:33 +02:00
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
46 changed files with 790 additions and 34 deletions
+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))
Binary file not shown.

Before

Width:  |  Height:  |  Size: 240 KiB

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 300 KiB

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 219 KiB

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 325 KiB

After

Width:  |  Height:  |  Size: 331 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 237 KiB

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 KiB

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 350 KiB

After

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 335 KiB

After

Width:  |  Height:  |  Size: 333 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 220 KiB

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 KiB

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 229 KiB

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 234 KiB

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 234 KiB

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 217 KiB

After

Width:  |  Height:  |  Size: 222 KiB

+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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 40 KiB

+80 -11
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('/');
@@ -222,6 +245,38 @@ test('hver fane under indstillinger renderer', async ({ page }) => {
expect(trouble).toEqual([]);
});
test('menu- og brødtekststørrelse sættes hver for sig og overlever en genindlæsning', async ({
page
}) => {
const trouble = watchForTrouble(page);
await page.goto('/indstillinger');
// Two independent knobs under Udseende — the sidebar's size, and the body text's.
await page.getByLabel('Menu', { exact: true }).selectOption('large');
await page.getByLabel('Brødtekst', { exact: true }).selectOption('xl');
await expect(page.getByLabel('Menu', { exact: true })).toHaveValue('large');
const attrs = () =>
page.evaluate(() => [
document.documentElement.dataset.menuSize,
document.documentElement.dataset.textSize
]);
expect(await attrs()).toEqual(['large', 'xl']);
// They live in localStorage like the theme, so they survive a reload — and are
// applied before first paint, so the picker comes back showing the saved choice.
await page.reload();
expect(await attrs()).toEqual(['large', 'xl']);
await expect(page.getByLabel('Menu', { exact: true })).toHaveValue('large');
// Put them back, so the shared account does not leak a size into other tests.
await page.getByLabel('Menu', { exact: true }).selectOption('default');
await page.getByLabel('Brødtekst', { exact: true }).selectOption('default');
expect(await attrs()).toEqual([undefined, undefined]);
expect(trouble).toEqual([]);
});
/**
* The PWA's own files. Both of the bugs found last in this project were of this
* kind — a manifest promising icons that were never generated — and they are
@@ -430,10 +485,10 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
.evaluate((el) => el.getBoundingClientRect().left + parseFloat(getComputedStyle(el).paddingLeft));
expect(headLeft, 'gruppehovedet er rykket ind').toBeLessThanOrEqual(looseLeft);
// A project inside a group starts further in than a row outside one. Measured
// rather than asserted on a class, because indentation is a fact about where
// the row's contents land — and measured at the *content* edge, not the box:
// the indent is padding, so the element's own left edge does not move.
// A project sits at the same left margin whether or not it is in a group — one
// straight edge down the sidebar, with the bold heading doing the grouping the
// indent used to. Measured at the *content* edge, not the box, because what the
// eye follows is where the text lands.
await sidebar.getByRole('link', { name: 'Regnskab' }).dragTo(heading);
const contentLeft = (el) =>
el.getBoundingClientRect().left + parseFloat(getComputedStyle(el).paddingLeft);
@@ -441,7 +496,10 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
sidebar.locator('.folder > a').first().evaluate(contentLeft),
sidebar.locator('.views a').first().evaluate(contentLeft)
]);
expect(inside, 'et projekt i en gruppe er ikke rykket ind').toBeGreaterThan(outside);
expect(
Math.abs(inside - outside),
'et projekt i en gruppe står ikke i flugt med de faste visninger'
).toBeLessThan(2);
await sidebar
.getByRole('link', { name: 'Regnskab' })
@@ -902,6 +960,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 +980,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 +993,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');
@@ -1465,9 +1525,18 @@ test('en gruppe er en side med sine projekter, en beskrivelse og filer', async (
// Into the group, then onto its page — which is what the heading is a link to
// now. The chevron beside it still folds; they are two intentions and the old
// single target could only serve the smaller one.
await sidebar
.getByRole('link', { name: 'Drejebænk' })
.dragTo(sidebar.getByRole('link', { name: 'Værksted' }));
//
// The drop is retried until the project actually nests under the group: a native
// HTML5 drag occasionally does not register the first time under CI load, and a
// single dragTo then leaves the group empty and the page assertion failing for a
// reason that has nothing to do with what the test is about.
const nested = sidebar.locator('.folder > a').filter({ hasText: 'Drejebænk' });
await expect(async () => {
await sidebar
.getByRole('link', { name: 'Drejebænk' })
.dragTo(sidebar.getByRole('link', { name: 'Værksted' }));
await expect(nested).toHaveCount(1, { timeout: 1000 });
}).toPass({ timeout: 10000 });
await sidebar.getByRole('link', { name: 'Værksted' }).click();
await expect(page.getByRole('heading', { name: 'Værksted', level: 1 })).toBeVisible();
+44
View File
@@ -86,6 +86,12 @@
--text-xl: 1.375rem;
--text-2xl: 1.75rem;
/* The sidebar's own text size, its own knob (Settings → Appearance) so the menu
can be made easier to read without dragging the whole list with it. Default is
a step up from the old --text-sm the nav used, at medium weight, so the menu
items read as menu items. [data-menu-size] on <html> overrides it. */
--menu-size: 0.9375rem;
/* Spacing on a 4px grid. */
--s1: 0.25rem;
--s2: 0.5rem;
@@ -516,3 +522,41 @@ button {
line-height: 1.5;
letter-spacing: -0.01em;
}
/* Body text size — its own control in Settings → Appearance, for reading the list
and the notes larger (or smaller) without touching the menu. It re-scales the
shared type tokens the same way a look does; placed after the looks so that an
explicit size choice wins the tokens they both set. The menu has its own knob
below and does not move with this one. */
[data-text-size='small'] {
--text-base: 0.875rem;
--text-sm: 0.75rem;
--text-lg: 1rem;
}
[data-text-size='large'] {
--text-base: 1.0625rem;
--text-sm: 0.9375rem;
--text-lg: 1.1875rem;
}
[data-text-size='xl'] {
--text-base: 1.1875rem;
--text-sm: 1.0625rem;
--text-lg: 1.3125rem;
}
/* Menu (sidebar) font size — independent of the body size above, because the two
are separate questions: a menu you can read at a glance, and a list you can read
a paragraph of. */
[data-menu-size='small'] {
--menu-size: 0.8125rem;
}
[data-menu-size='large'] {
--menu-size: 1.0625rem;
}
[data-menu-size='xl'] {
--menu-size: 1.1875rem;
}
+8
View File
@@ -28,6 +28,14 @@
// face and again in another, which is a visible jump.
const look = localStorage.getItem('verdande:look');
if (look && look !== 'verdande') document.documentElement.dataset.look = look;
// The font-size choices change layout too, so they belong before first
// paint for the same reason: otherwise the sidebar and the list are drawn
// once at one size and again at another.
const menuSize = localStorage.getItem('verdande:menu-size');
if (menuSize && menuSize !== 'default') document.documentElement.dataset.menuSize = menuSize;
const textSize = localStorage.getItem('verdande:text-size');
if (textSize && textSize !== 'default') document.documentElement.dataset.textSize = textSize;
} catch (e) {
// Private browsing can refuse localStorage; the dark default stands.
}
+155 -15
View File
@@ -487,6 +487,7 @@
it, which is the second time that drop has earned its test. -->
<a
href={item.href}
data-view={item.key}
class:active={current === item.href}
class:receiving={item.key === 'today' && overToday}
onclick={onnavigate}
@@ -529,6 +530,52 @@
stroke-linecap="round"
/>
</svg>
{:else if item.key === 'today'}
<!-- A star, the way Things marks Today: the one view you open first. -->
<svg class="mark" viewBox="0 0 16 16" aria-hidden="true">
<path
d="M8 2l1.7 3.6 3.9.5-2.9 2.7.7 3.9L8 10.9 4.6 13.4l.7-3.9L2.4 6.1l3.9-.5z"
fill="none"
stroke="currentColor"
stroke-width="1.3"
stroke-linejoin="round"
/>
</svg>
{:else if item.key === 'upcoming'}
<!-- A calendar with a day marked — the Calendar view carries the plain
grid, so the marked day keeps the two apart. -->
<svg class="mark" viewBox="0 0 16 16" aria-hidden="true">
<rect
x="2.2"
y="3.2"
width="11.6"
height="10.6"
rx="1.4"
fill="none"
stroke="currentColor"
stroke-width="1.4"
/>
<path
d="M2.2 6.4h11.6M5.6 2.2v2M10.4 2.2v2"
fill="none"
stroke="currentColor"
stroke-width="1.4"
stroke-linecap="round"
/>
<rect x="8.8" y="8.6" width="2.6" height="2.6" rx="0.5" fill="currentColor" />
</svg>
{:else if item.key === 'delegated'}
<!-- A person: the tasks you are waiting on someone else for. -->
<svg class="mark" viewBox="0 0 16 16" aria-hidden="true">
<circle cx="8" cy="5.4" r="2.5" fill="none" stroke="currentColor" stroke-width="1.4" />
<path
d="M3.4 13.4c0-2.6 2.1-4.2 4.6-4.2s4.6 1.6 4.6 4.2"
fill="none"
stroke="currentColor"
stroke-width="1.4"
stroke-linecap="round"
/>
</svg>
{:else}
<span class="dot" class:today={item.key === 'today'} aria-hidden="true"></span>
{/if}
@@ -570,6 +617,7 @@
{#snippet projectRow(project, sortable)}
<a
href="/projekt/{project.id}"
data-view={project.is_inbox ? 'inbox' : undefined}
class:sortable
class:active={current === `/projekt/${project.id}`}
class:dragging={draggingId === project.id}
@@ -593,7 +641,29 @@
? onTaskDrop(e, project)
: sortable && onDrop(e, project)}
>
<span class="dot" style="background: {colorVar(project.color)}" aria-hidden="true"></span>
{#if project.is_inbox}
<!-- A tray, not a coloured dot: the Inbox is a fixed view that happens to
be a project, and it reads as one of the views above, not as the first
of the projects below. -->
<svg class="mark" viewBox="0 0 16 16" aria-hidden="true">
<path
d="M2.3 9.2L4 3.4a1.1 1.1 0 011-.8h6a1.1 1.1 0 011 .8l1.7 5.8"
fill="none"
stroke="currentColor"
stroke-width="1.3"
stroke-linejoin="round"
/>
<path
d="M2.3 9.2v2.9A1.3 1.3 0 003.6 13.4h8.8a1.3 1.3 0 001.3-1.3V9.2h-3.3l-1 1.7H6.6l-1-1.7z"
fill="none"
stroke="currentColor"
stroke-width="1.3"
stroke-linejoin="round"
/>
</svg>
{:else}
<span class="dot" style="background: {colorVar(project.color)}" aria-hidden="true"></span>
{/if}
{projectName(project)}
<!-- Open tasks, not people. The same grey number meant three things in this
one column — people at a project, projects at a group, tasks at a label
@@ -656,7 +726,7 @@
and with every project filed there would be no loose row left to aim at. -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="group-head"
class="group-head sticky-head"
class:over={overGroup === ''}
ondragover={(e) => {
if (!carries(e, PROJECT)) return;
@@ -1005,6 +1075,30 @@
display: flex;
flex-direction: column;
gap: var(--s5);
/* The sidebar is padded, so the scrollbar sat a pad-width inside the edge and
read as floating in the middle. Pull the scroller out to the border and pad
the content back, so the bar sits against the edge where a scrollbar
belongs — the rows do not move. Thin and dim, so it is present without
drawing the eye. */
margin-right: calc(var(--s3) * -1);
padding-right: var(--s3);
scrollbar-width: thin;
scrollbar-color: var(--line-strong) transparent;
}
.scroller::-webkit-scrollbar {
width: 10px;
}
.scroller::-webkit-scrollbar-thumb {
background: var(--line-strong);
border-radius: 999px;
border: 3px solid transparent;
background-clip: padding-box;
}
.scroller::-webkit-scrollbar-track {
background: transparent;
}
/* Wider than it looks: a 1px target is a target you miss. The visible line stays
@@ -1133,8 +1227,26 @@
flex: none;
}
a.active .mark {
color: var(--accent);
/* The fixed views carry a colour of their own, the way Things does — the star
amber, the Inbox blue — and keep it whether or not they are the active row.
So no active-state override here; the colour is the icon's identity. */
[data-view='inbox'] .mark {
color: var(--color-blue);
}
[data-view='today'] .mark {
color: var(--color-amber);
}
[data-view='upcoming'] .mark {
color: var(--color-tomato);
}
[data-view='delegated'] .mark {
color: var(--color-violet);
}
[data-view='notes'] .mark {
color: var(--color-teal);
}
[data-view='calendar'] .mark {
color: var(--color-indigo);
}
.group-head {
@@ -1144,6 +1256,17 @@
padding: 0 var(--s2) var(--s2);
}
/* The Projekter heading holds the only way to add a project or a group, so it
must not scroll away with a long list. Pinned to the top of the scroller, on
the sidebar's own ground so the rows pass under it. */
.sticky-head {
position: sticky;
top: 0;
z-index: 3;
padding-top: var(--s2);
background: var(--surface-sunken);
}
h2 {
font-size: var(--text-xs);
font-weight: 560;
@@ -1370,21 +1493,31 @@
flex: none;
}
/* Projects inside a group are indented to the group's name, not to its dot:
the eye follows the text, and lining up under the dot leaves the names
looking a step short of where the heading starts. */
/* Projects sit at the same left margin whether or not they are in an area —
one straight edge down the whole sidebar reads calmer than a staircase, and
the bold area heading above already does the grouping the indent used to. */
.folder > a {
padding-left: calc(var(--s2) + var(--s4));
padding-left: var(--s2);
}
/* The line is indented with them, so a drop between two projects in a group
reads as landing in the group rather than across the whole sidebar. */
.folder > a.sortable::before {
left: calc(var(--s2) + var(--s4));
left: var(--s2);
}
.folder > .empty {
padding-left: calc(var(--s2) + var(--s4));
padding-left: var(--s2);
}
/* An area is the sidebar's own heading — Things treats it as bold and plainly
cased, a thing the eye lands on, not another faint uppercase label like the
system sections. So an area's name is set apart while "Projekter", "Filtre"
and "Etiketter" stay quiet. */
.folder-head h2 {
font-size: var(--menu-size);
font-weight: 600;
text-transform: none;
letter-spacing: 0;
color: var(--ink);
}
/* Inside the rename form now, so it needs no padding of its own — the form
@@ -1441,7 +1574,10 @@
border-radius: var(--radius);
color: var(--ink-muted);
text-decoration: none;
font-size: var(--text-sm);
/* Its own size (Settings → Appearance) and medium weight, so the menu items
carry more than the muted small text they used to and read at a glance. */
font-size: var(--menu-size);
font-weight: 500;
transition:
background var(--fast) var(--ease),
color var(--fast) var(--ease);
@@ -1452,10 +1588,14 @@
color: var(--ink);
}
/* An accent-tinted selection, not a half-step of surface: on the sunken dark
sidebar --surface-raised was all but invisible, so the active row read as no
different from the rest. --accent-sunken carries the accent hue and stands out
in both themes. */
a.active {
background: var(--surface-raised);
background: var(--accent-sunken);
color: var(--ink);
font-weight: 500;
font-weight: 600;
}
/* Only your own projects reorder. A shared one sits where its owner put it —
+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;
}
+12
View File
@@ -230,6 +230,14 @@ export const da = {
'Gemmes i denne browser. Det er en egenskab ved den skærm, du sidder ved — en bærbar i solen og en skærm i et mørkt rum vil have hvert sit svar.',
'account.look': 'Udseende',
'account.lookHint': 'Temaet siger hvor lyst. Det her siger hvordan det læses — skrift, luft og hjørner. De to kan kombineres frit.',
'account.sizes': 'Skriftstørrelse',
'account.sizesHint': 'Menuen og brødteksten kan sættes hver for sig — den ene er et blik ned over en liste, den anden en tekst, man læser.',
'account.menuSize': 'Menu',
'account.textSize': 'Brødtekst',
'size.small': 'Lille',
'size.default': 'Standard',
'size.large': 'Stor',
'size.xl': 'Størst',
'look.verdande': 'Verdande',
'look.verdandeNote': 'Som du kender den.',
'look.rolig': 'Rolig',
@@ -266,6 +274,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.',
+12
View File
@@ -226,6 +226,14 @@ export const en = {
'Kept in this browser. It is a property of the screen you are sitting at — a laptop in the sun and a monitor in a dark room want different answers.',
'account.look': 'Appearance',
'account.lookHint': 'The theme says how bright. This says how it reads — typeface, air and corners. The two combine freely.',
'account.sizes': 'Text size',
'account.sizesHint': 'The menu and the body text set separately — one is a glance down a list, the other a paragraph you read.',
'account.menuSize': 'Menu',
'account.textSize': 'Body text',
'size.small': 'Small',
'size.default': 'Default',
'size.large': 'Large',
'size.xl': 'Largest',
'look.verdande': 'Verdande',
'look.verdandeNote': 'As you know it.',
'look.rolig': 'Calm',
@@ -262,6 +270,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.',
+46
View File
@@ -1051,6 +1051,52 @@ class Look {
export const look = new Look();
/**
* The font-size steps, shared by the menu and the body-text controls. `default`
* carries no attribute, so the base tokens in app.css stand unchanged.
*/
export const SIZES = [
{ id: 'small', name: 'size.small' },
{ id: 'default', name: 'size.default' },
{ id: 'large', name: 'size.large' },
{ id: 'xl', name: 'size.xl' }
];
/**
* A font-size preference — the same shape as Look: kept in localStorage and
* mirrored onto <html>, where app.html sets it before first paint so a chosen size
* does not flash in at the default and resize. Two instances below, because the
* menu and the body text are separate questions.
*/
class Sizing {
current = $state('default');
#key;
#attr;
constructor(key, attr) {
this.#key = key;
this.#attr = attr;
if (typeof document !== 'undefined') {
this.current = document.documentElement.dataset[attr] ?? 'default';
}
}
set(id) {
if (!SIZES.some((s) => s.id === id)) return;
this.current = id;
if (id === 'default') delete document.documentElement.dataset[this.#attr];
else document.documentElement.dataset[this.#attr] = id;
try {
localStorage.setItem(this.#key, id);
} catch {
// Private browsing; the choice simply will not persist.
}
}
}
export const menuSize = new Sizing('verdande:menu-size', 'menuSize');
export const textSize = new Sizing('verdande:text-size', 'textSize');
/** Theme, kept in localStorage and applied to the document element. */
class Theme {
/**
+134 -1
View File
@@ -1,7 +1,7 @@
<script>
/** Konto: who you are, your password, and the second factor. */
import { api, humanMessage } from '$lib/api.js';
import { app, theme, THEMES, look, LOOKS } from '$lib/stores.svelte.js';
import { app, theme, THEMES, look, LOOKS, menuSize, textSize, SIZES } from '$lib/stores.svelte.js';
import { t, plural, i18n } from '$lib/i18n.svelte.js';
import { supported, register } from '$lib/passkey.js';
import { ago } from '$lib/when.js';
@@ -362,6 +362,40 @@
{/each}
</div>
</div>
<!-- Two more axes: how large the menu reads and how large the body text reads.
Separate on purpose — one is a glance down a list of names, the other is a
paragraph you sit and read. -->
<div class="sizes">
<h3>{t('account.sizes')}</h3>
<p class="hint">{t('account.sizesHint')}</p>
<div class="size-row">
<div class="size-field">
<label for="menu-size">{t('account.menuSize')}</label>
<select
id="menu-size"
value={menuSize.current}
onchange={(e) => menuSize.set(e.currentTarget.value)}
>
{#each SIZES as option (option.id)}
<option value={option.id}>{t(option.name)}</option>
{/each}
</select>
</div>
<div class="size-field">
<label for="text-size">{t('account.textSize')}</label>
<select
id="text-size"
value={textSize.current}
onchange={(e) => textSize.set(e.currentTarget.value)}
>
{#each SIZES as option (option.id)}
<option value={option.id}>{t(option.name)}</option>
{/each}
</select>
</div>
</div>
</div>
</section>
<section class="panel">
@@ -571,6 +605,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);
@@ -583,6 +653,37 @@
margin-bottom: var(--s1);
}
.sizes {
margin-top: var(--s4);
padding-top: var(--s3);
border-top: 1px solid var(--line);
}
.sizes h3 {
font-size: var(--text-sm);
margin-bottom: var(--s1);
}
.size-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: var(--s3);
margin-top: var(--s3);
}
.size-field {
display: flex;
flex-direction: column;
gap: var(--s1);
}
.size-field label {
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--ink-faint);
}
.look-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
@@ -771,4 +872,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>
+8 -1
View File
@@ -1081,6 +1081,11 @@
min-width: 0;
border-right: 1px solid var(--line);
padding-right: var(--s3);
/* White in light, a nuance lighter than the ground in dark — the note list
is a surface you read down, so it lifts off the page like the editor does.
--surface-raised is #fff in the light themes and one step up in the dark. */
background: var(--surface-raised);
padding-left: var(--s3);
}
.head {
@@ -1546,7 +1551,9 @@
flex-direction: column;
min-height: 0;
min-width: 0;
background: var(--surface);
/* A nuance lighter than the ground in dark, still white in light — matches
the note list beside it so the two read as one writing surface. */
background: var(--surface-raised);
border: 1px solid var(--line);
border-radius: var(--radius);
/* Luft nok til at teksten ikke rører kanten.