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
* 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) {
const trouble = [];
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();
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();
@@ -540,7 +559,7 @@ test('et invitationslink opretter kontoen og giver adgang til projektet', async
await sidebar.getByLabel('Projektnavn').press('Enter');
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.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.
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('Rolle').selectOption('viewer');
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,
// not a dropdown: ownership is transferred, not granted.
await page.reload();
await page.getByRole('button', { name: /Delt/ }).click();
await projectAction(page, /Delt/);
await expect(page.getByText('Ejer')).toBeVisible();
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);
// 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('æ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();
// 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');
await expect(done.getByRole('heading', { name: 'Færdige' })).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 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.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([]);
});
/**
* 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.
*