From 948b49bb49df60999018828ea1296e80bcbaff36 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Fri, 5 Jun 2026 11:37:54 +0100 Subject: [PATCH] Fix scrollbars and enter to submit --- frontend/e2e/tasks-overflow.spec.ts | 109 ++++++++++++++++++ .../components/modal/block-edit.component.ts | 20 ++++ .../app/components/tasks/tasks.component.ts | 7 +- .../app/components/tower/tower.component.ts | 15 +-- 4 files changed, 143 insertions(+), 8 deletions(-) create mode 100644 frontend/e2e/tasks-overflow.spec.ts diff --git a/frontend/e2e/tasks-overflow.spec.ts b/frontend/e2e/tasks-overflow.spec.ts new file mode 100644 index 0000000..8f42805 --- /dev/null +++ b/frontend/e2e/tasks-overflow.spec.ts @@ -0,0 +1,109 @@ +import { test, expect } from '@playwright/test'; +import { randomUUID } from 'node:crypto'; + +/** + * Regression guard for the tasks accordion ("todos") double-scrollbar bug. + * + * When the pending-task list is tall it must show EXACTLY ONE scrollbar (inside + * the white card), not two. The cause was two nested scroll containers both + * firing: the `lt-tasks` host (overflow:auto) AND the inner `.container` card + * (overflow-y:auto). The fix makes the host a height-bounding flex column that + * only CLIPS, leaving the inner card as the sole scroller. + * + * Seeds many pending tasks via a direct PUT (far more robust than driving the + * carousel ~18 times), then reloads so the store renders the seeded tree. All + * ids are generated here in Node — `crypto.randomUUID()` throws in the page + * because the dev origin is plain HTTP (not a secure context). + */ +test('tasks accordion with many tasks shows a single scrollbar', async ({ page }) => { + await page.goto('/'); + + // init() mints + registers a token on load; wait for it to land. + await page.waitForFunction(() => !!localStorage.getItem('life-towers.token.v4'), null, { + timeout: 15000, + }); + + // Build a tree: one page, one tower, many PENDING (is_done:false) tasks. + const now = Math.floor(Date.now() / 1000); + const tree = { + pages: [ + { + id: randomUUID(), + name: 'Hobbies', + hide_create_tower_button: false, + keep_tasks_open: false, + default_date_from: null, + default_date_to: null, + towers: [ + { + id: randomUUID(), + name: 'Reading', + base_color: { h: 0.92, s: 0.7, l: 0.55 }, + blocks: Array.from({ length: 18 }, (_, i) => ({ + id: randomUUID(), + tag: 'novel', + description: `Pending task ${i + 1} — read another chapter tonight`, + is_done: false, + difficulty: 1, + created_at: now - i * 3600, + })), + }, + ], + }, + ], + }; + + // PUT the tree (unguarded — no If-Match), then reload to render it. + const status = await page.evaluate(async (body) => { + const res = await fetch('api/v1/data', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${localStorage.getItem('life-towers.token.v4')}`, + }, + body: JSON.stringify(body), + }); + return res.status; + }, tree); + expect(status).toBe(200); + + await page.reload(); + await page.waitForSelector('lt-tower', { timeout: 15000 }); + + // Open the accordion (collapsed by default since keep_tasks_open is false). + await page.locator('lt-tasks .header').first().click(); + await page.waitForTimeout(400); // expand animation (200ms) + buffer + + // Measure the scroll topology in the accordion subtree. + const m = await page.locator('lt-tasks').first().evaluate((host) => { + const card = host.querySelector('.container') as HTMLElement; + const cs = (el: Element) => getComputedStyle(el); + const overflows = (el: HTMLElement) => el.scrollHeight > el.clientHeight + 1; + return { + hostOverflowY: cs(host).overflowY, + cardOverflowY: cs(card).overflowY, + cardOverflows: overflows(card), + hostHasOwnOverflow: host.scrollHeight > host.clientHeight + 1, + hostHeight: host.getBoundingClientRect().height, + cardHeight: card.getBoundingClientRect().height, + viewport30vh: window.innerHeight * 0.3, + }; + }); + + // The list must actually overflow, otherwise the test proves nothing. + expect(m.cardOverflows).toBe(true); + + // Exactly one scroller: the inner card. The host only clips. + expect(m.hostOverflowY).toBe('hidden'); + expect(m.cardOverflowY).toBe('auto'); + // The host has no overflowing content of its own → no second scrollbar. + expect(m.hostHasOwnOverflow).toBe(false); + // The card shrank to fit within the host's bound (not clipped past it). + expect(m.cardHeight).toBeLessThanOrEqual(m.hostHeight + 1); + // Host height is capped by min(30vh, 45%) → at most ~30vh. + expect(m.hostHeight).toBeLessThanOrEqual(m.viewport30vh + 2); + + await page.locator('lt-tasks .container').first().screenshot({ + path: 'visuals/04d-tasks-accordion-overflow-single-scrollbar.png', + }); +}); diff --git a/frontend/src/app/components/modal/block-edit.component.ts b/frontend/src/app/components/modal/block-edit.component.ts index 60223fc..c7ee5e1 100644 --- a/frontend/src/app/components/modal/block-edit.component.ts +++ b/frontend/src/app/components/modal/block-edit.component.ts @@ -187,6 +187,7 @@ export function createDoneValue(defaultDone: boolean, currentDone: boolean, edit maxlength="10000" [value]="newValue().description" (input)="updateNewDescription($any($event.target).value)" + (keydown.enter)="onNewDescriptionEnter($event)" >