Compare commits

...
Author SHA1 Message Date
kristianwindandClaude Opus 4.8 01001d0980 fix(settings): the "open completed tasks" link was unstyled
It carried class="ghost", which nothing in the data page styles — so it
fell back to the browser's blue underline while every link beside it is
the accent-coloured .link. Now it matches them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 14:27:20 +02:00
Kristian Wind f89fb21502 Merge pull request #42 from kristianwind/sidebar-drop-chevrons
CI / OpenAPI is valid (push) Successful in 12s
CI / End-to-end (push) Successful in 8m44s
CI / Docker image builds (push) Successful in 1m49s
Release / Build and publish image (push) Failing after 5s
CI / Go (push) Failing after 14m26s
Drop the fold arrows from sidebar headers and groups
2026-09-03 13:39:35 +02:00
kristianwindandClaude Opus 4.8 ec147cd1ba feat(sidebar): drop the fold arrows from headers and groups
The chevron beside every section and group was saying "clickable" a
second time next to an icon and a bold name that already said it. Gone
now: a system section folds from anywhere on its heading, and a group
folds from its folder icon while the name still opens the group's page.
No arrow on the rail.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 13:34:33 +02:00
Kristian Wind 1f9f14099b Merge pull request #41 from kristianwind/calendar-lighter-pastels
CI / OpenAPI is valid (push) Successful in 53s
CI / End-to-end (push) Successful in 9m3s
CI / Docker image builds (push) Successful in 2m10s
Release / Build and publish image (push) Failing after 7s
CI / Go (push) Failing after 15m11s
Calendar: lighter grid, per-calendar pastels, today marked at top
2026-09-03 13:17:31 +02:00
kristianwindandClaude Opus 4.8 6cf63113df feat(calendar): lighter grid, per-calendar pastels, today marked at top
The calendar was a grey grid where every event was the same sunken block
edged in a colour you had to already know. Now the day itself is the
brightest surface and each event is a soft, dusty wash of its own
calendar's colour — so a screen of them reads as colour laid on white,
and two calendars are told apart at a glance. Tasks stay outlined cards,
the thing you can pick up, against the solid washes you cannot.

Today is marked where the day is named: its number sits in a filled disc
at the top of the column, the same one the month grid gives it, over a
faint wash down the whole column.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 13:09:07 +02:00
Kristian Wind e24a0fa849 Merge pull request #40 from kristianwind/sidebar-header-unify
Unify section and group headers with icons; scrollbar flush to the divider
2026-09-03 13:02:15 +02:00
36 changed files with 87 additions and 101 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 249 KiB

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 310 KiB

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 KiB

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 334 KiB

After

Width:  |  Height:  |  Size: 333 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 246 KiB

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 266 KiB

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 354 KiB

After

Width:  |  Height:  |  Size: 353 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 338 KiB

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 230 KiB

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 254 KiB

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 KiB

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 245 KiB

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 KiB

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 218 KiB

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 KiB

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 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: 61 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

+15 -14
View File
@@ -371,9 +371,9 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
await sidebar.getByLabel('Gruppens navn').fill('Arbejde');
await sidebar.getByLabel('Gruppens navn').press('Enter');
// The name is a link to the group's own page; the chevron beside it is a
// The name is a link to the group's own page; the folder icon beside it is a
// separate button that folds. Two intentions, two targets — the single one they
// used to share could only ever serve the smaller.
// used to share could only ever serve the smaller, and neither wears an arrow.
//
// `exact` on the link, because a group's name is a user-chosen word that can
// turn up inside another control's name, and Playwright matches an accessible
@@ -389,13 +389,14 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
// The clipping half is here because it was once broken: "Omdøb" and "Slet" sat
// in the flow reserving their width even while invisible, so ARBEJDE was drawn
// as "ARBE". The alignment half changed meaning with the redesign: a group now
// carries a fold chevron and a section icon just like "Projekter" above it, and
// belongs to that heading tier rather than to the project rows below.
// carries a section icon just like "Projekter" above it, and belongs to that
// heading tier rather than to the project rows below.
const shape = await sidebar.locator('.folder-head').evaluate((el) => {
const link = el.querySelector('h2 a');
// The leading mark is now the section icon, the same folder/funnel/tag glyph
// the system headings carry — a group and a section read identically.
const name = { ...link.querySelector('.head-icon').getBoundingClientRect().toJSON() };
// The leading mark is the section icon in its own fold button, the same
// folder/funnel/tag glyph the system headings carry — a group and a section
// read identically, and the icon sits before the name, not inside the link.
const name = { ...el.querySelector('.head-icon').getBoundingClientRect().toJSON() };
return {
clipped: link.scrollWidth > link.clientWidth + 1,
left: name.left,
@@ -408,7 +409,7 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
expect(shape.count, 'navnet og tallet overlapper').toBeGreaterThanOrEqual(shape.right);
// A group is a heading, not a project row: it belongs to the same tier as
// "Projekter" and the other system sections — chevron, icon, bold name — and
// "Projekter" and the other system sections — icon, bold name, no arrow — and
// starts where they start, not where the projects filed under it do. Measured
// icon to icon, the one mark both headings share.
const sectionLeft = await sidebar
@@ -440,13 +441,13 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
'false'
);
// A group's chevron is in the flow now, the same one the system sections carry —
// no absolute mark with a --surface field behind it, so a folded group no longer
// leaves a pale square beside its name on the sunken bar.
const chevronBg = await sidebar
.locator('.folder-head .chevron-button')
// The fold control is the bare folder icon now — no chevron, and no --surface
// field behind it, so a folded group leaves no pale square beside its name on
// the sunken bar.
const foldBg = await sidebar
.locator('.folder-head .fold-icon')
.evaluate((el) => getComputedStyle(el).backgroundColor);
expect(chevronBg, 'chevronen har en firkant bag sig').toBe('rgba(0, 0, 0, 0)');
expect(foldBg, 'foldeknappen har en firkant bag sig').toBe('rgba(0, 0, 0, 0)');
await sidebar.getByRole('button', { name: /^Fold Arbejde/ }).click();
+49 -17
View File
@@ -651,6 +651,7 @@
<div
class="allday"
data-allday={iso(date)}
class:today={iso(date) === todayISO}
class:over={over === iso(date)}
ondragover={(e) => onDragOver(e, date)}
ondragleave={() => (over = null)}
@@ -876,12 +877,16 @@
overflow: hidden;
}
/* White, not the sidebar's sunken ground: a calendar reads lightest when the
day itself is the brightest thing on the screen and the events are the soft
colour laid on it — the opposite of the grey grid it was, where the events
had to shout to be seen at all. */
.corner,
.head,
.gutter,
.allday,
.daycol {
background: var(--ground);
background: var(--surface);
}
.head {
@@ -889,6 +894,16 @@
text-align: center;
}
/* Today's whole column, faintly, so the eye finds it before it reads a number —
the marker on the date above is the label, this is the glow it sits in. Kept
under the events, so a pastel block still reads as its own calendar's colour
rather than as this wash tinted. */
.head.today,
.allday.today,
.daycol.today {
background: color-mix(in oklab, var(--accent) 6%, var(--surface));
}
.head .wd {
display: block;
font-size: var(--text-xs);
@@ -901,9 +916,19 @@
font-size: var(--text-sm);
}
/* The marker at the top the day is named by: today's number in a filled disc,
the same one the month grid gives today, so "this is today" is one glance in
either view rather than a colour you have to already know to read. */
.head.today .num {
color: var(--accent);
color: var(--accent-ink);
background: var(--accent);
border-radius: var(--radius-full);
width: 20px;
height: 20px;
display: inline-grid;
place-items: center;
font-weight: 560;
margin-top: 1px;
}
.gutter {
@@ -977,6 +1002,10 @@
/* Placeringen kommer fra `place()` som en inline `style`; alt andet står her.
To ting på samme tid deler bredden, så `margin-right` er den ene hårstrå
luft, der gør to kasser til to kasser. */
/* A soft wash of the calendar's own colour rather than a grey block edged in it:
each calendar keeps a dusty, unmistakable nuance, and a screen of them reads
as colour laid on white instead of a grid of grey bars. The left edge stays,
a shade stronger than the fill, so the block still has a spine. */
.tevent {
position: absolute;
margin-right: 2px;
@@ -984,16 +1013,20 @@
padding: 1px var(--s1);
border-radius: var(--radius-sm);
border-left: 3px solid var(--event-colour, var(--line-strong));
background: var(--surface-sunken);
color: var(--ink-muted);
background: color-mix(in oklab, var(--event-colour, var(--line-strong)) 16%, var(--surface));
color: var(--ink);
font-size: var(--text-xs);
line-height: 1.3;
text-align: left;
text-decoration: none;
}
/* A task is a card you can pick up, not an event you cannot — so on the white
column it is an outlined card, where an event is a solid wash. The priority
colour rides the left edge, as it does everywhere else. */
.tevent.task {
background: var(--surface-raised);
background: var(--surface);
box-shadow: inset 0 0 0 1px var(--line);
border-left-color: var(--line-strong);
color: var(--ink);
cursor: grab;
@@ -1101,7 +1134,8 @@
text-align: left;
padding: 1px var(--s1);
border-radius: var(--radius-sm);
background: var(--surface-raised);
background: var(--surface);
box-shadow: inset 0 0 0 1px var(--line);
border-left: 2px solid var(--line-strong);
color: var(--ink);
overflow: hidden;
@@ -1120,7 +1154,7 @@
}
.chip:hover {
background: var(--surface);
background: var(--surface-sunken);
}
/* An event reads as a different kind of thing at a glance, before any of the
@@ -1140,8 +1174,8 @@
padding: 1px var(--s1);
border-radius: var(--radius-sm);
border-left: 3px solid var(--event-colour);
background: var(--surface-sunken);
color: var(--ink-muted);
background: color-mix(in oklab, var(--event-colour) 16%, var(--surface));
color: var(--ink);
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
@@ -1156,7 +1190,7 @@
}
.event:hover {
background: var(--surface);
background: color-mix(in oklab, var(--event-colour) 26%, var(--surface));
color: var(--ink);
}
@@ -1165,7 +1199,7 @@
every calendar anybody has used. */
.event.allday {
border-left: none;
background: color-mix(in oklab, var(--event-colour) 22%, var(--ground));
background: color-mix(in oklab, var(--event-colour) 22%, var(--surface));
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--event-colour) 45%, transparent);
}
@@ -1267,20 +1301,18 @@
padding: 1px var(--s1);
border-radius: var(--radius-sm);
border-left: 2px solid var(--line-strong);
background: var(--surface-raised);
background: var(--surface);
box-shadow: inset 0 0 0 1px var(--line);
}
.calendar.week .chip[data-priority='1'] {
border-left-color: var(--p1);
background: var(--surface-raised);
}
.calendar.week .chip[data-priority='2'] {
border-left-color: var(--p2);
background: var(--surface-raised);
}
.calendar.week .chip[data-priority='3'] {
border-left-color: var(--p3);
background: var(--surface-raised);
}
/* Readable again in the week, for the reason the task chips are: a
@@ -1292,12 +1324,12 @@
padding: 1px var(--s1);
border-radius: var(--radius-sm);
border-left: 3px solid var(--event-colour);
background: var(--surface-sunken);
background: color-mix(in oklab, var(--event-colour) 16%, var(--surface));
}
.calendar.week .event.allday {
border-left: none;
background: color-mix(in oklab, var(--event-colour) 22%, var(--ground));
background: color-mix(in oklab, var(--event-colour) 22%, var(--surface));
}
.calendar.week .at {
+22 -69
View File
@@ -682,13 +682,14 @@
<!--
A fixed heading that folds, the same way a project group's does.
One snippet for all four rather than four copies of a chevron and an aria
One snippet for all four rather than four copies of an icon and an aria
attribute: they have to behave identically, and the group heading is already
the proof that they drift otherwise — it grew a fold, a colour and two
actions while these stayed plain text.
The whole heading is the target, not the chevron. At this size a lone chevron
is something you miss, and the name is where the eye already is.
The whole heading is the target, and the icon is the mark: no arrow beside it.
At this size an extra glyph is clutter, and the name is where the eye already
is.
-->
<!-- One monochrome mark per kind of heading, so a section and a group read as the
same kind of thing — a folder of projects, a funnel of filters, a tag of
@@ -749,19 +750,15 @@
{/snippet}
{#snippet foldHeading(key, label)}
<!-- No chevron: a system section has no page and nothing to rename, so the
whole heading is one button that folds it. The icon and the bold name are
the whole control — an arrow beside them was only ever saying "clickable"
twice. -->
<h2 class="fold">
<button
onclick={() => app.toggleSection(key)}
aria-expanded={!app.sectionCollapsed(key)}
>
<svg
class="chevron"
class:collapsed={app.sectionCollapsed(key)}
viewBox="0 0 24 24"
aria-hidden="true"
>
<path d="M6 9l6 6 6-6" />
</svg>
{@render sectionIcon(key)}
{label}
</button>
@@ -930,31 +927,22 @@
</div>
</form>
{:else}
<!-- The button sits inside the heading, not around it, the same way a
project's title does: a group *is* a heading in the sidebar, and
wrapping an h2 in a button both invalidates the markup and takes
the heading away from a screen reader. The whole heading folds —
at this size a lone chevron is a target you miss, and the name is
where the eye already is. -->
<!-- The chevron folds; the name opens the group's own page. Two
targets in one heading, because they are two different intentions
and the old single one could only ever serve the smaller. -->
<!-- No chevron: the folder icon itself folds the group, and the name
beside it opens the group's own page. Two intentions, two targets,
but no arrow to clutter the rail — the icon a section carries is
the same one that folds it here. The button sits inside the
heading, not around it: a group *is* a heading, and wrapping an h2
in a button both invalidates the markup and takes the heading from
a screen reader. -->
<button
class="chevron-button"
class="fold-icon"
onclick={() => app.toggleGroup(group.id)}
aria-expanded={!group.collapsed}
aria-label={group.collapsed
? t('sidebar.unfold', { name: group.name })
: t('sidebar.fold', { name: group.name })}
>
<svg
class="chevron"
class:collapsed={group.collapsed}
viewBox="0 0 24 24"
aria-hidden="true"
>
<path d="M6 9l6 6 6-6" />
</svg>
{@render sectionIcon('group')}
</button>
<h2 class="fold">
<!-- Navigation waits two tenths of a second for a second click. A
@@ -975,7 +963,6 @@
}, 200);
}}
>
{@render sectionIcon('group')}
{group.name}
</a>
</h2>
@@ -1425,35 +1412,17 @@
min-width: 0;
}
/* The chevron on its own, so the name beside it can be a link to the group's
page. Sized as a target rather than as a glyph: 11px of arrow is something
you miss. */
/* The chevron sits on top of the group's dot rather than before it. In the flow
it pushed the name 22px further in than a project's, so groups read as if
they were filed under the projects above them — the opposite of what they
are. Out of the flow, a group's name starts exactly where a project's does.
The dot is the resting state; the chevron takes over on hover or focus, and
stays put while the group is folded, so a folded group still shows the way
back open. */
/* Uden baggrund. Knappen dækkede prikken med et felt i --surface, og på den
sænkede bjælke er det en lys firkant: en foldet gruppe stod med en hvid
klods ud for navnet permanent, fordi chevronen bliver stående, når gruppen
er lukket. Prikken tones ud i stedet for at blive dækket — det er samme
skifte, uden noget at male henover den med. */
/* A group folds from its own chevron, the same one the system sections carry —
in the flow before the name now, not an absolute mark that appeared on hover,
so a group and a section read identically at rest. The name beside it is still
a link to the group's page; the chevron is the second intention. */
.chevron-button {
/* The folder icon is the fold toggle now — no chevron beside it. A bare icon
button the size of the icon it holds: the name next to it opens the group's
page, this folds it, and neither wears an arrow to say so. */
.fold-icon {
flex: none;
width: 14px;
height: 14px;
display: grid;
place-items: center;
color: inherit;
}
.chevron-button:hover {
.fold-icon:hover {
color: var(--ink);
}
@@ -1482,22 +1451,6 @@
text-decoration: none;
}
.chevron {
width: 11px;
height: 11px;
flex: none;
fill: none;
stroke: currentColor;
stroke-width: 2.4;
stroke-linecap: round;
stroke-linejoin: round;
transition: transform var(--fast) var(--ease);
}
.chevron.collapsed {
transform: rotate(-90deg);
}
/* Hidden until the heading is hovered, like the section actions on a project
page: two buttons permanently beside a label stop it reading as a label. */
.folder-head .count {
@@ -285,7 +285,7 @@
<p class="hint">{t('data.doneHint')}</p>
</header>
<div class="row">
<a class="ghost" href="/faerdige">{t('data.openDone')}</a>
<a class="link" href="/faerdige">{t('data.openDone')}</a>
</div>
</section>