Compare commits

...
Author SHA1 Message Date
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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
46 changed files with 690 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: "[email protected]", Name: "andreas", PasswordHash: "x"}
if err := db.CreateUser(ctx, u, "Inbox"); err != nil {
t.Fatalf("create user: %v", err)
}
got, err := db.UserByEmail(ctx, "[email protected]")
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: "[email protected]", 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.
}
+131 -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;
@@ -1133,8 +1203,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 +1232,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 +1469,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 +1550,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 +1564,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;
}
+8
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',
+8
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',
+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 {
/**
+66 -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">
@@ -619,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));
+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.