fix(projects): the header stops crushing the project's name

"GarageRisteriet" rendered as "GarageRist / eriet" — broken mid-word. A name split
mid-syllable does not read as a wrap, it reads as a rendering fault.

Two causes. The row carried eight controls beside the heading — colour, show-done,
three view buttons, share, history, delete — and the heading was the only flexible
thing in it, so it was what gave way. And `overflow-wrap: anywhere` breaks at the
first opportunity rather than the last necessary one, so what gave way, gave way
inside a word.

Everything but the view switcher is behind one button now. What stays out is what
you use while working — which view you are in. What moves in is what you do *to* a
project rather than in it, and most of it once ever. The heading gets the row back,
and `break-word` only splits a word that could not fit on a line of its own.

The menu closes on Escape and on a click anywhere else, listened for in the capture
phase so a click whose own handler stops propagation still closes it. A menu you
can only leave by pressing its own button again is one you click twice to get out
of.

Three more untranslated strings turned up on the way: "Fjern {member.name}", "Luk
menu" and "En slettet konto". The guard missed them because none of its words
covered them — it knows fjern, opret, vælg, luk, tilføj, ryd, gem, slet and omdøb
now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-18 11:00:32 +02:00
co-authored by Claude Opus 5
parent ab5e3e35a6
commit 9cc7df69f0
5 changed files with 274 additions and 110 deletions
+71 -7
View File
@@ -19,6 +19,25 @@ import { USER } from './user.js';
* happened" is not an actionable test failure — "PATCH /api/v1/tasks/x returned * happened" is not an actionable test failure — "PATCH /api/v1/tasks/x returned
* 500" is. * 500" is.
*/ */
/**
* Opens the project header's overflow menu and clicks one of its items.
*
* The header used to carry eight controls in one row — title, colour, show-done,
* three view buttons, share, history, delete — and a project name is not a short
* word, so the heading was what gave way. Everything but the view switcher moved
* behind one button; this is how a test reaches them.
*/
async function projectAction(page, name) {
await page.getByRole('button', { name: 'Flere handlinger' }).click();
// `exact` only means anything for a string. The share item reads "Delt · 2" once
// somebody is in the project, so callers pass a regex for that one.
const item =
name instanceof RegExp
? page.getByRole('menuitem', { name })
: page.getByRole('menuitem', { name, exact: true });
await item.click();
}
function watchForTrouble(page) { function watchForTrouble(page) {
const trouble = []; const trouble = [];
page.on('console', (message) => { page.on('console', (message) => {
@@ -224,7 +243,7 @@ test('et projekt kan omdøbes, slettes og hentes tilbage', async ({ page }) => {
await expect(sidebar.getByRole('link', { name: 'Sommerferie' })).toBeVisible(); await expect(sidebar.getByRole('link', { name: 'Sommerferie' })).toBeVisible();
page.once('dialog', (dialog) => dialog.accept()); page.once('dialog', (dialog) => dialog.accept());
await page.getByRole('button', { name: 'Slet projektet' }).click(); await projectAction(page, 'Slet projektet');
await expect(sidebar.getByRole('link', { name: 'Sommerferie' })).toBeHidden(); await expect(sidebar.getByRole('link', { name: 'Sommerferie' })).toBeHidden();
@@ -540,7 +559,7 @@ test('et invitationslink opretter kontoen og giver adgang til projektet', async
await sidebar.getByLabel('Projektnavn').press('Enter'); await sidebar.getByLabel('Projektnavn').press('Enter');
await expect(page.getByRole('heading', { name: 'Fælleshuset' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Fælleshuset' })).toBeVisible();
await page.getByRole('button', { name: 'Del' }).click(); await projectAction(page, 'Del');
await page.getByLabel('Inviter via e-mail').fill('nabo@example.dk'); await page.getByLabel('Inviter via e-mail').fill('nabo@example.dk');
await page.getByRole('button', { name: 'Inviter' }).click(); await page.getByRole('button', { name: 'Inviter' }).click();
@@ -779,7 +798,7 @@ test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) =>
// controls below only exist once it has. // controls below only exist once it has.
await expect(page.getByRole('heading', { name: 'Fælles' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Fælles' })).toBeVisible();
await page.getByRole('button', { name: 'Del' }).click(); await projectAction(page, 'Del');
await page.getByLabel('Inviter via e-mail').fill('andreas@example.dk'); await page.getByLabel('Inviter via e-mail').fill('andreas@example.dk');
await page.getByLabel('Rolle').selectOption('viewer'); await page.getByLabel('Rolle').selectOption('viewer');
await page.getByRole('button', { name: 'Inviter' }).click(); await page.getByRole('button', { name: 'Inviter' }).click();
@@ -804,7 +823,7 @@ test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) =>
// The owner promotes them from the member list. The owner's own row is text, // The owner promotes them from the member list. The owner's own row is text,
// not a dropdown: ownership is transferred, not granted. // not a dropdown: ownership is transferred, not granted.
await page.reload(); await page.reload();
await page.getByRole('button', { name: /Delt/ }).click(); await projectAction(page, /Delt/);
await expect(page.getByText('Ejer')).toBeVisible(); await expect(page.getByText('Ejer')).toBeVisible();
await page.getByLabel('Rolle for andreas').selectOption('editor'); await page.getByLabel('Rolle for andreas').selectOption('editor');
@@ -832,7 +851,7 @@ test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) =>
).toHaveCount(0); ).toHaveCount(0);
// The log has recorded all of this since the beginning and nothing showed it. // The log has recorded all of this since the beginning and nothing showed it.
await page.getByRole('button', { name: 'Historik' }).click(); await projectAction(page, 'Historik');
await expect(page.getByText('oprettede projektet')).toBeVisible(); await expect(page.getByText('oprettede projektet')).toBeVisible();
await expect(page.getByText('ændrede rollen for')).toBeVisible(); await expect(page.getByText('ændrede rollen for')).toBeVisible();
@@ -1184,7 +1203,7 @@ test('færdige opgaver kan vises og skjules igen', async ({ page }) => {
await expect(page.getByText('male gavlen', { exact: true })).toBeVisible(); await expect(page.getByText('male gavlen', { exact: true })).toBeVisible();
// And it can be looked at again, under its own heading at the bottom. // And it can be looked at again, under its own heading at the bottom.
await page.getByRole('button', { name: 'Vis færdige' }).click(); await projectAction(page, 'Vis færdige');
const done = page.locator('section.done'); const done = page.locator('section.done');
await expect(done.getByRole('heading', { name: 'Færdige' })).toBeVisible(); await expect(done.getByRole('heading', { name: 'Færdige' })).toBeVisible();
await expect(done.getByText('rydde op', { exact: true })).toBeVisible(); await expect(done.getByText('rydde op', { exact: true })).toBeVisible();
@@ -1196,7 +1215,7 @@ test('færdige opgaver kan vises og skjules igen', async ({ page }) => {
await page.reload(); await page.reload();
await expect(page.locator('section.done').getByText('rydde op', { exact: true })).toBeVisible(); await expect(page.locator('section.done').getByText('rydde op', { exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Skjul færdige' }).click(); await projectAction(page, 'Skjul færdige');
await expect(page.locator('section.done')).toHaveCount(0); await expect(page.locator('section.done')).toHaveCount(0);
await expect(page.getByText('male gavlen', { exact: true })).toBeVisible(); await expect(page.getByText('male gavlen', { exact: true })).toBeVisible();
@@ -1272,6 +1291,51 @@ test('en sektion kan rumme flere opgaver, og rammen forsvinder bagefter @forms',
expect(trouble).toEqual([]); expect(trouble).toEqual([]);
}); });
/**
* The project header keeps its name readable.
*
* It was a flex row of eight controls and a heading, and the heading was the part
* that gave way: "GarageRisteriet" rendered as "GarageRist / eriet", broken
* mid-word, because `overflow-wrap: anywhere` breaks at the first opportunity
* rather than the last necessary one. A name split mid-syllable reads as a
* rendering fault, not as a wrap.
*
* Everything but the view switcher lives behind one button now. This measures the
* heading rather than counting controls, because the thing being protected is that
* the name is legible — not any particular arrangement of the row.
*/
test('projektets navn brydes ikke midt i et ord', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
await sidebar.getByLabel('Projektnavn').fill('GarageRisteriet');
await sidebar.getByLabel('Projektnavn').press('Enter');
const heading = page.getByRole('heading', { name: 'GarageRisteriet', level: 1 });
await expect(heading).toBeVisible();
// One line. The name is fifteen characters and the row now has room for it,
// which is the whole point of moving the rest behind a button.
const box = await heading.boundingBox();
const lineHeight = await heading.evaluate((el) => parseFloat(getComputedStyle(el).fontSize));
expect(box.height, 'overskriften er brudt over flere linjer').toBeLessThan(lineHeight * 2);
// And the actions are still reachable, just one click further in.
await page.getByRole('button', { name: 'Flere handlinger' }).click();
for (const item of ['Vis færdige', 'Del', 'Historik', 'Farve på projektet', 'Slet projektet']) {
await expect(page.getByRole('menuitem', { name: item, exact: true })).toBeVisible();
}
// Escape closes it — a menu you can only leave by pressing its own button again
// is one you click twice to get out of.
await page.keyboard.press('Escape');
await expect(page.getByRole('menuitem', { name: 'Historik', exact: true })).toHaveCount(0);
expect(trouble).toEqual([]);
});
/** /**
* The sidebar never scrolls sideways. * The sidebar never scrolls sideways.
* *
+6 -1
View File
@@ -596,5 +596,10 @@ export const da = {
'push.upToDate': 'Ingen nyere version fundet.', 'push.upToDate': 'Ingen nyere version fundet.',
'view.nothingMoreToday': 'Ikke mere i dag.', 'view.nothingMoreToday': 'Ikke mere i dag.',
'project.roleFor': 'Rolle for {name}' 'project.roleFor': 'Rolle for {name}',
'project.more': 'Flere handlinger',
'project.removeMember': 'Fjern {name}',
'nav.closeMenu': 'Luk menu'
}; };
+6 -1
View File
@@ -591,5 +591,10 @@ export const en = {
'push.upToDate': 'No newer version found.', 'push.upToDate': 'No newer version found.',
'view.nothingMoreToday': 'Nothing more today.', 'view.nothingMoreToday': 'Nothing more today.',
'project.roleFor': 'Role for {name}' 'project.roleFor': 'Role for {name}',
'project.more': 'More actions',
'project.removeMember': 'Remove {name}',
'nav.closeMenu': 'Close menu'
}; };
+1 -1
View File
@@ -142,7 +142,7 @@
<button <button
class="scrim" class="scrim"
onclick={() => (sidebarOpen = false)} onclick={() => (sidebarOpen = false)}
aria-label="Luk menu" aria-label={t('nav.closeMenu')}
tabindex="-1" tabindex="-1"
></button> ></button>
{/if} {/if}
+184 -94
View File
@@ -140,6 +140,24 @@
let editing = $state(false); let editing = $state(false);
let choosingColor = $state(false); let choosingColor = $state(false);
let showMenu = $state(false);
// Closes on a click anywhere else and on Escape. A menu that only closes by
// pressing its own button again is one you end up clicking twice to leave.
$effect(() => {
if (!showMenu) return;
const away = (event) => {
if (!event.target.closest?.('.more')) showMenu = false;
};
const escape = (event) => event.key === 'Escape' && (showMenu = false);
// Capture, so a click that its own handler stops still closes the menu.
window.addEventListener('click', away, true);
window.addEventListener('keydown', escape);
return () => {
window.removeEventListener('click', away, true);
window.removeEventListener('keydown', escape);
};
});
/** /**
* The colour is written to the store as well as here: the sidebar reads its own * The colour is written to the store as well as here: the sidebar reads its own
@@ -388,24 +406,6 @@
{/if} {/if}
</h1> </h1>
{/if} {/if}
{#if isOwner && !project.is_inbox}
<button
class="color"
style="background: {colorVar(project.color)}"
onclick={() => (choosingColor = !choosingColor)}
aria-expanded={choosingColor}
aria-label={t('project.color')}
title={t('project.color')}
></button>
{/if}
<button
class="ghost"
onclick={() => completedView.toggle()}
aria-pressed={completedView.shown}
>
{completedView.shown ? t('view.hideDone') : t('view.showDone')}
</button>
<div class="views" role="group" aria-label={t('view.mode')}> <div class="views" role="group" aria-label={t('view.mode')}>
{#each [['list', t('view.list')], ['board', t('view.board')], ['calendar', t('view.calendar')]] as [value, label]} {#each [['list', t('view.list')], ['board', t('view.board')], ['calendar', t('view.calendar')]] as [value, label]}
<button <button
@@ -416,25 +416,94 @@
{/each} {/each}
</div> </div>
{#if !project.is_inbox} <!-- Everything else lives behind one button.
<button class="share" onclick={() => (showShare = !showShare)}> The row had eight controls in it — title, colour, show-done, three view
{project.shared ? t('project.sharedWith', { n: project.member_count }) : t('project.share')} buttons, share, history, delete — and a project name is not a short
word. They fitted only on a wide window, and past that the heading was
the thing that gave way, which is the one part of the row that has to
be readable.
What stays out is what you use while working: which view you are in.
What moves in is what you do to a project rather than in it, and most
of it once ever. -->
<div class="more">
<button
class="more-toggle"
onclick={() => (showMenu = !showMenu)}
aria-expanded={showMenu}
aria-haspopup="menu"
aria-label={t('project.more')}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="5" cy="12" r="1.6" />
<circle cx="12" cy="12" r="1.6" />
<circle cx="19" cy="12" r="1.6" />
</svg>
</button>
{#if showMenu}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div class="menu" role="menu">
<button
role="menuitem"
onclick={() => {
completedView.toggle();
showMenu = false;
}}
>
{completedView.shown ? t('view.hideDone') : t('view.showDone')}
</button> </button>
{/if}
{#if !project.is_inbox} {#if !project.is_inbox}
<button class="share" onclick={() => (showLog = !showLog)} aria-expanded={showLog}> <button
role="menuitem"
onclick={() => {
showShare = !showShare;
showMenu = false;
}}
>
{project.shared
? t('project.sharedWith', { n: project.member_count })
: t('project.share')}
</button>
<button
role="menuitem"
onclick={() => {
showLog = !showLog;
showMenu = false;
}}
>
{t('project.history')} {t('project.history')}
</button> </button>
{/if} {/if}
{#if isOwner && !project.is_inbox} {#if isOwner && !project.is_inbox}
<button class="remove" onclick={remove} aria-label={t('project.delete')}> <button
<svg viewBox="0 0 24 24" aria-hidden="true"> role="menuitem"
<path d="M4 7h16M9 7V5a1 1 0 011-1h4a1 1 0 011 1v2M6 7l1 13h10l1-13" /> onclick={() => {
</svg> choosingColor = !choosingColor;
showMenu = false;
}}
>
<span class="swatch-dot" style="background: {colorVar(project.color)}"></span>
{t('project.color')}
</button>
<button
role="menuitem"
class="danger"
onclick={() => {
showMenu = false;
remove();
}}
>
{t('project.delete')}
</button> </button>
{/if} {/if}
</div>
{/if}
</div>
</header> </header>
{#if showLog} {#if showLog}
@@ -445,7 +514,7 @@
<!-- Empty when the account has been deleted: the record of what was <!-- Empty when the account has been deleted: the record of what was
done outlives whoever did it, so the row stays and the name goes. --> done outlives whoever did it, so the row stays and the name goes. -->
<span class="who" class:gone={!entry.user_name} <span class="who" class:gone={!entry.user_name}
>{entry.user_name || 'En slettet konto'}</span >{entry.user_name || t('history.deletedAccount')}</span
> >
<span class="what"> <span class="what">
{eventName(entry.event)}{#if eventDetail(entry)} {eventName(entry.event)}{#if eventDetail(entry)}
@@ -509,7 +578,7 @@
await api.removeMember(project.id, member.user_id); await api.removeMember(project.id, member.user_id);
members = (await api.listMembers(project.id)).members; members = (await api.listMembers(project.id)).members;
}} }}
aria-label="Fjern {member.name}">×</button aria-label={t('project.removeMember', { name: member.name })}>×</button
> >
{/if} {/if}
</li> </li>
@@ -682,22 +751,6 @@
</div> </div>
<style> <style>
/* A quiet control: it changes what you are looking at, not what is there. It
sits beside the view switcher because it is the same kind of decision. */
.ghost {
font-size: var(--text-sm);
color: var(--ink-faint);
padding: var(--s1) var(--s2);
border-radius: var(--radius);
flex: none;
}
.ghost:hover,
.ghost[aria-pressed='true'] {
color: var(--ink-muted);
background: var(--surface);
}
/* Recessed, because it is a record rather than a plan. The rows keep their own /* Recessed, because it is a record rather than a plan. The rows keep their own
completed styling; this is about the section around them. */ completed styling; this is about the section around them. */
.done { .done {
@@ -711,10 +764,6 @@
} }
/* A board and a month grid need the room; a list is easier to read narrow. */ /* A board and a month grid need the room; a list is easier to read narrow. */
.view:has(.board),
.view:has(.calendar) {
max-width: 1400px;
}
/* Wraps, and the title keeps a floor. /* Wraps, and the title keeps a floor.
* *
@@ -734,11 +783,15 @@
h1 { h1 {
font-size: var(--text-2xl); font-size: var(--text-2xl);
/* 8ch rather than 0: the basis is what stops flex shrinking it past the /* Takes the row, and lets the controls keep their own width. With the rest
point where the wrapping below is the better answer. */ of them behind one button there is room for a real project name. */
flex: 1 1 8ch; flex: 1 1 auto;
min-width: 8ch; min-width: 0;
overflow-wrap: anywhere; /* `break-word`, not `anywhere`. `anywhere` breaks inside a word at the first
opportunity, so "GarageRisteriet" came out as "GarageRist / eriet" the
moment the row was tight — a name split mid-syllable reads as a rendering
fault. This only breaks a word that cannot fit on a line of its own. */
overflow-wrap: break-word;
} }
/* The heading's button carries no button-ness: it is the heading, and the only /* The heading's button carries no button-ness: it is the heading, and the only
@@ -779,6 +832,83 @@
border-color: var(--accent); border-color: var(--accent);
} }
/* --- the overflow menu ------------------------------------------------------ */
.more {
position: relative;
flex: none;
}
.more-toggle {
width: 28px;
height: 28px;
display: grid;
place-items: center;
border-radius: var(--radius);
color: var(--ink-faint);
}
.more-toggle:hover,
.more-toggle[aria-expanded='true'] {
color: var(--ink);
background: var(--surface);
}
.more-toggle svg {
width: 18px;
height: 18px;
fill: currentColor;
}
/* Right-aligned, because the button is at the right end of the row and a menu
that opened leftward from there would hang off the page. */
.menu {
position: absolute;
top: calc(100% + var(--s1));
right: 0;
z-index: 20;
min-width: 190px;
display: flex;
flex-direction: column;
padding: var(--s1);
background: var(--surface-raised);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow-lg);
}
.menu button {
display: flex;
align-items: center;
gap: var(--s2);
width: 100%;
text-align: left;
padding: var(--s2) var(--s3);
border-radius: var(--radius-sm);
font-size: var(--text-sm);
color: var(--ink);
white-space: nowrap;
}
.menu button:hover {
background: var(--surface-sunken);
}
.menu button.danger {
color: var(--danger);
}
.menu button.danger:hover {
background: var(--danger-sunken);
}
.swatch-dot {
width: 10px;
height: 10px;
border-radius: var(--radius-full);
flex: none;
}
.remove { .remove {
flex: none; flex: none;
width: 30px; width: 30px;
@@ -797,34 +927,7 @@
background: var(--danger-sunken); background: var(--danger-sunken);
} }
.remove svg {
width: 16px;
height: 16px;
fill: none;
stroke: currentColor;
stroke-width: 1.6;
stroke-linecap: round;
stroke-linejoin: round;
}
/* A circle in the project's own colour, which is both the control and its own
preview: the thing you are about to change is what you press. */
.color {
flex: none;
width: 20px;
height: 20px;
border-radius: var(--radius-full);
box-shadow: 0 0 0 2px var(--ground);
transition: box-shadow var(--fast) var(--ease);
}
.color:hover,
.color:focus-visible {
box-shadow:
0 0 0 2px var(--ground),
0 0 0 3px var(--line-strong);
outline: none;
}
.swatches { .swatches {
display: flex; display: flex;
@@ -887,20 +990,7 @@
font-weight: 500; font-weight: 500;
} }
.share {
flex: none;
font-size: var(--text-sm);
color: var(--ink-muted);
padding: var(--s1) var(--s3);
border: 1px solid var(--line);
border-radius: var(--radius-full);
transition: border-color var(--fast) var(--ease);
}
.share:hover {
border-color: var(--line-strong);
color: var(--ink);
}
.panel { .panel {
background: var(--surface); background: var(--surface);