From 9cc7df69f05d84b3df0c3e76efb805e13ed81814 Mon Sep 17 00:00:00 2001 From: Kristian Wind Date: Tue, 18 Aug 2026 11:00:32 +0200 Subject: [PATCH] fix(projects): the header stops crushing the project's name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "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 --- web/e2e/smoke.spec.js | 78 +++++- web/src/lib/locales/da.js | 7 +- web/src/lib/locales/en.js | 7 +- web/src/routes/+layout.svelte | 2 +- web/src/routes/projekt/[id]/+page.svelte | 290 +++++++++++++++-------- 5 files changed, 274 insertions(+), 110 deletions(-) diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index f2241c2..7edd9f4 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -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. * diff --git a/web/src/lib/locales/da.js b/web/src/lib/locales/da.js index 6b8e6ad..321d9ad 100644 --- a/web/src/lib/locales/da.js +++ b/web/src/lib/locales/da.js @@ -596,5 +596,10 @@ export const da = { 'push.upToDate': 'Ingen nyere version fundet.', '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' }; diff --git a/web/src/lib/locales/en.js b/web/src/lib/locales/en.js index 25cfa02..f405f07 100644 --- a/web/src/lib/locales/en.js +++ b/web/src/lib/locales/en.js @@ -591,5 +591,10 @@ export const en = { 'push.upToDate': 'No newer version found.', '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' }; diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 8b9179f..cb513e8 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -142,7 +142,7 @@ {/if} diff --git a/web/src/routes/projekt/[id]/+page.svelte b/web/src/routes/projekt/[id]/+page.svelte index 3782ac7..3924d8c 100644 --- a/web/src/routes/projekt/[id]/+page.svelte +++ b/web/src/routes/projekt/[id]/+page.svelte @@ -140,6 +140,24 @@ let editing = $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 @@ -388,24 +406,6 @@ {/if} {/if} - {#if isOwner && !project.is_inbox} - - {/if} - -
{#each [['list', t('view.list')], ['board', t('view.board')], ['calendar', t('view.calendar')]] as [value, label]} - {/if} + +
+ - {/if} + + {#if showMenu} + + + {/if} +
{#if showLog} @@ -445,7 +514,7 @@ {entry.user_name || 'En slettet konto'}{entry.user_name || t('history.deletedAccount')} {eventName(entry.event)}{#if eventDetail(entry)} @@ -509,7 +578,7 @@ await api.removeMember(project.id, member.user_id); members = (await api.listMembers(project.id)).members; }} - aria-label="Fjern {member.name}">×× {/if} @@ -682,22 +751,6 @@