diff --git a/CLAUDE.md b/CLAUDE.md
index 0fddce3..51bea9f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -198,14 +198,13 @@ Multiple clients can share one token (same user on phone + laptop). Three pieces
The `.block-container` has `transform: scaleY(-1)` so blocks visually fall from the TOP into the bottom of the tower. Each block default-positions at `translateY(500%)` via a `*` rule; the inline `[style.transform]="b._transform"` binding overrides per-block.
-When exactly **one** new done block is added, the `reconcile()` method:
-1. Sets the new block's `_transform: 'translateY(500%)'` and `_opacity: '0'` (off-screen)
-2. Calls `requestAnimationFrame` → `requestAnimationFrame` to let the browser paint the initial state
-3. Sets `_anim: 'descend'`, `_transform: 'translateY(0)'`, `_opacity: '1'` — the CSS transition fires
+`reconcile()` renders done blocks at their **resting** position, then decides what should fall via the pure, unit-tested `decideFalls()` helper. Two guarantees:
+- **No fall on page load.** A `hasRenderedStack` flag gates the very first render of a tower's stack: it never animates (blocks just appear at rest). Only the deliberate "Try an example" showcase (`animateInitialStack`) falls its whole initial stack. Do NOT key "first render" off effect-run ordering vs `ngAfterViewInit` measurement — that was non-deterministic and caused intermittent load-falls.
+- **Always fall on add / tick.** After the first render, any done-block id that's genuinely new this round (set-difference vs `prevDoneIds`) and is currently resting & visible falls.
-**Critical**: `grewByOne` detection is position-independent (set-difference). When a tickbox flips a pending block to done, the new entry inserts at its original `tower.blocks` index, not appended. Use the new ID, not `styled[length-1]`.
+The fall itself is played **imperatively via the Web Animations API** (`playFall`): the block is already rendered at rest, and WAAPI animates it in from `translateY(500%)`/`opacity:0` on the next frame (`fill: 'backwards'` so it never flashes at rest first). This is deterministic — the old approach snapped the block to `500%` then flipped it back with a `.descend` CSS transition across a double-`requestAnimationFrame`, which raced zoneless change detection and would intermittently drop the fall (block just appears) or misfire on load. **WAAPI is the right tool for block animations here precisely because it's immune to CD timing.**
-The **date range** slider asymmetry: blocks below `range.from` are removed from `visibleBlocks` entirely (instant shuffle, no gap), blocks above `range.to` get `_anim: 'ascend'` and stay in the list flying up. `prevDoneIds` tracks the full `allDone` array — not the filtered styled list — so range expansions don't mis-fire as "new block".
+The **date range** slider asymmetry: blocks below `range.from` are removed from `visibleBlocks` entirely (instant shuffle, no gap), blocks above `range.to` get `_anim: 'ascend'` and stay in the list flying up. The `.descend` CSS class survives **only** for the reverse case — an already-rendered block re-entering range as the slider widens glides back down (a real in-DOM transform change, so CSS transitions reliably; no WAAPI needed). `prevDoneIds` tracks the full `allDone` id set — not the filtered styled list — so range reshuffles keep the same ids and never mis-fire as "new block".
### Block-edit carousel (modal/block-edit.component)
diff --git a/frontend/e2e/carousel-active-card.spec.ts b/frontend/e2e/carousel-active-card.spec.ts
new file mode 100644
index 0000000..0e43b7f
--- /dev/null
+++ b/frontend/e2e/carousel-active-card.spec.ts
@@ -0,0 +1,112 @@
+import { test, expect, webkit, chromium, devices, type Browser, type Page } from '@playwright/test';
+
+/**
+ * Regression: tapping a block/task must open THAT entry in the block-edit
+ * carousel — centered and active — not a different one.
+ *
+ * This was a real, mobile-only bug (https://schmelczer.dev/towers/). It does NOT
+ * reproduce in desktop Chromium at a small viewport — it needs a true mobile
+ * engine. On WebKit (iOS Safari) the carousel's opening `scrollTo` ran in a
+ * microtask right after the modal host is reparented to
, before the
+ * snap container's scroll range was established, so it no-op'd and every tap
+ * surfaced the same wrong card. Hence this spec drives real WebKit + Chromium
+ * mobile profiles directly rather than relying on the configured project.
+ *
+ * docker compose -f docker-compose.dev.yml up --build -d
+ * PLAYWRIGHT_BASE_URL=http://life-towers:8000 npx playwright test carousel-active-card
+ * docker compose -f docker-compose.dev.yml down -v
+ */
+
+const BASE = process.env['PLAYWRIGHT_BASE_URL'] ?? 'http://localhost:8000';
+
+async function loadSample(page: Page): Promise {
+ await page.goto(BASE + '/');
+ await page.waitForSelector('text=Welcome to Life Towers', { timeout: 15000 });
+ await page.waitForTimeout(350);
+ await page.getByRole('button', { name: 'Load sample towers' }).click();
+ await page.waitForSelector('section.modal', { state: 'detached' });
+ await page.waitForTimeout(1800); // let the falling animation settle
+}
+
+/** Centre offset (px) of the active card relative to the carousel viewport. */
+async function activeCardOffCenter(page: Page): Promise {
+ return page.evaluate(() => {
+ const c = document.querySelector('lt-block-edit .carousel') as HTMLElement | null;
+ const card = document.querySelector('lt-block-edit .card.active') as HTMLElement | null;
+ if (!c || !card) return 9999;
+ const cr = c.getBoundingClientRect();
+ const kr = card.getBoundingClientRect();
+ return Math.round(kr.left + kr.width / 2 - (cr.left + cr.width / 2));
+ });
+}
+
+async function closeCarousel(page: Page): Promise {
+ await page.locator('lt-block-edit .exit').first().click({ force: true });
+ await page.waitForSelector('section.modal', { state: 'detached' });
+ await page.waitForTimeout(150);
+}
+
+async function runSuite(browser: Browser, deviceName: string): Promise {
+ const ctx = await browser.newContext({ ...devices[deviceName] });
+ const page = await ctx.newPage();
+ await loadSample(page);
+
+ // ── Done blocks ───────────────────────────────────────────────────────────
+ // Tap a sample (first / middle / last) of the Reading tower's falling squares
+ // and confirm each opens — and centers — its own card.
+ const reading = page.locator('lt-tower').nth(0);
+ const squares = reading.locator('lt-block');
+ const ids: string[] = [];
+ for (let i = 0; i < (await squares.count()); i++) {
+ const id = await squares.nth(i).getAttribute('data-block-id');
+ if (id && !ids.includes(id)) ids.push(id);
+ }
+ expect(ids.length).toBeGreaterThan(2);
+
+ for (const id of [ids[0], ids[Math.floor(ids.length / 2)], ids[ids.length - 1]]) {
+ await reading.locator(`lt-block[data-block-id="${id}"]`).first().tap();
+ await page.waitForSelector('section.modal.active');
+ await page.waitForTimeout(700); // open scroll + the 150ms adjustPosition settle
+ await expect(page.locator('lt-block-edit .card.active')).toHaveAttribute('data-block-id', id);
+ expect(Math.abs(await activeCardOffCenter(page))).toBeLessThan(30);
+ await closeCarousel(page);
+ }
+
+ // ── Pending task ──────────────────────────────────────────────────────────
+ // Tap the 2nd task of the Side projects tower (it has two); the active card's
+ // description must be the one we tapped.
+ const sideProjects = page.locator('lt-tower').nth(1);
+ const header = sideProjects.locator('lt-tasks .header');
+ if (await header.count()) {
+ await header.first().click();
+ await page.waitForTimeout(400);
+ }
+ const task = sideProjects.locator('lt-tasks .task-description').nth(1);
+ const taskText = (await task.innerText()).trim();
+ await task.tap();
+ await page.waitForSelector('section.modal.active');
+ await page.waitForTimeout(700);
+ await expect(page.locator('lt-block-edit .card.active textarea')).toHaveValue(taskText);
+ expect(Math.abs(await activeCardOffCenter(page))).toBeLessThan(30);
+ await closeCarousel(page);
+
+ await ctx.close();
+}
+
+test('WebKit mobile: tapping a block/task opens its own carousel card', async () => {
+ const browser = await webkit.launch();
+ try {
+ await runSuite(browser, 'iPhone 13');
+ } finally {
+ await browser.close();
+ }
+});
+
+test('Chromium mobile: tapping a block/task opens its own carousel card', async () => {
+ const browser = await chromium.launch();
+ try {
+ await runSuite(browser, 'Pixel 7');
+ } finally {
+ await browser.close();
+ }
+});
diff --git a/frontend/e2e/visuals.spec.ts b/frontend/e2e/visuals.spec.ts
index dc538ab..58ded5f 100644
--- a/frontend/e2e/visuals.spec.ts
+++ b/frontend/e2e/visuals.spec.ts
@@ -200,9 +200,24 @@ test.describe('Life Towers visuals', () => {
await page.waitForTimeout(1800);
await page.screenshot({ path: 'visuals/14-mobile-populated.png', fullPage: true });
- // Open the block-edit carousel for the first tower's first task.
- await page.locator('lt-tasks .header').first().click();
- await page.waitForTimeout(400);
+ // New-tower modal on mobile — the X button must not overlap the name input.
+ await page.locator('img[alt="Add tower"]').click();
+ await page.waitForSelector('section.modal.active');
+ await page.locator('input[placeholder="New tower"]').fill('A long tower name to test');
+ await page.waitForTimeout(350);
+ await page.screenshot({ path: 'visuals/14b-mobile-new-tower-modal.png', fullPage: true });
+ await page.locator('lt-tower-settings .exit').click({ force: true });
+ await page.waitForSelector('section.modal', { state: 'detached' });
+ await page.waitForTimeout(350);
+
+ // Open the block-edit carousel for the first tower's first task. The sample
+ // page keeps tasks open (keep_tasks_open: true), so the accordion header is
+ // absent — only click it when present (i.e. on a collapsed accordion).
+ const tasksHeader = page.locator('lt-tasks .header').first();
+ if (await tasksHeader.count()) {
+ await tasksHeader.click();
+ await page.waitForTimeout(400);
+ }
await page.locator('lt-tasks .task-description').first().click();
await page.waitForSelector('section.modal.active');
await page.waitForTimeout(400);
diff --git a/frontend/src/app/components/modal/block-edit.component.ts b/frontend/src/app/components/modal/block-edit.component.ts
index c7ee5e1..371f299 100644
--- a/frontend/src/app/components/modal/block-edit.component.ts
+++ b/frontend/src/app/components/modal/block-edit.component.ts
@@ -62,6 +62,7 @@ export function createDoneValue(defaultDone: boolean, currentDone: boolean, edit
@for (b of blocks(); track b.id; let i = $index) {
();
readonly activeBlockId = input(null);
readonly tags = input([]);
+ /** Tag to pre-select on the create card (the previous block's tag). */
+ readonly lastTag = input('');
readonly baseColor = input.required();
/** Default for `is_done` on the create card. */
readonly defaultDone = input(true);
@@ -633,15 +636,17 @@ export class BlockEditComponent implements AfterViewInit {
untracked(() => this.editedValues.set(m));
});
- // Seed the newValue tag from tags input on first run.
+ // Seed the newValue tag on first run: prefer the last tag the user picked
+ // in the create view, falling back to the tower's first tag.
effect(() => {
const t = this.tags();
+ const last = this.lastTag();
untracked(() => {
const cur = this.newValue();
if (!cur.tag) {
this.newValue.set({
...cur,
- tag: t.length > 0 ? t[0] : '',
+ tag: last || (t.length > 0 ? t[0] : ''),
});
}
});
@@ -659,14 +664,24 @@ export class BlockEditComponent implements AfterViewInit {
}
ngAfterViewInit(): void {
+ const blocks = this.blocks();
+ const focusId = this.activeBlockId();
+ const focusIdx = focusId
+ ? Math.max(0, blocks.findIndex((b) => b.id === focusId))
+ : blocks.length;
// Position scroll on the focused card (or the create card if none).
- queueMicrotask(() => {
- const blocks = this.blocks();
- const focusId = this.activeBlockId();
- const focusIdx = focusId
- ? Math.max(0, blocks.findIndex((b) => b.id === focusId))
- : blocks.length;
+ //
+ // Deferred to a rendered FRAME (not a microtask): the modal host is
+ // reparented to during the same change-detection flush (see
+ // modal.component), and on WebKit the carousel's horizontal scroll range
+ // isn't established until the next layout after that reparent. A
+ // microtask-time scroll therefore clamps to 0, leaving the wrong card
+ // centered — then `adjustPosition` locks `activeIdx` onto it. One frame
+ // later the layout is settled and the scroll lands. We re-assert across two
+ // frames to also survive the focus-trap's initial focus scroll.
+ this.afterFrame(() => {
this.scrollToChild(focusIdx + 1, false);
+ this.afterFrame(() => this.scrollToChild(focusIdx + 1, false));
});
}
@@ -858,27 +873,51 @@ export class BlockEditComponent implements AfterViewInit {
if (!container) return;
const card = container.children.item(idx) as HTMLElement | null;
if (!card) return;
- const left =
- card.offsetLeft - (container.clientWidth - card.offsetWidth) / 2;
+ // Use live viewport rects (getBoundingClientRect) rather than offsetLeft:
+ // the carousel is position:static, so the cards' offsetParent is the
+ // fixed :host, and on mobile the carousel's 15px horizontal padding skews
+ // offsetLeft out of the scroll container's coordinate space — enough that
+ // the snap tips to a neighbour card. Rects are absolute, so the delta
+ // between the card's centre and the container's centre is exact on every
+ // engine and at any padding.
+ const containerRect = container.getBoundingClientRect();
+ const cardRect = card.getBoundingClientRect();
+ const delta =
+ cardRect.left + cardRect.width / 2 - (containerRect.left + containerRect.width / 2);
+ const left = container.scrollLeft + delta;
// 'instant' (not 'auto') is required: the carousel sets `scroll-behavior:
- // smooth`, and 'auto' defers to that — so the initial open would *animate*
- // a scroll across the whole strip to reach the target card (very visible on
- // mobile, where the carousel can be thousands of px wide). Tap-to-navigate
- // still passes smooth=true for the nice slide between cards.
+ // smooth`, and 'auto' (and a bare `scrollLeft =`) defer to it — so the jump
+ // would *animate*, and the 150ms `adjustPosition` would then read a
+ // mid-flight position and snap to a neighbour. 'instant' lands in one frame.
+ // Tap-to-navigate keeps smooth=true for the nice slide between cards.
container.scrollTo({ left, behavior: smooth ? 'smooth' : 'instant' });
this.activeIdx.set(idx);
}
+ /** Run `cb` after the next rendered frame (falls back to sync where rAF is
+ * unavailable, e.g. SSR / unit tests). */
+ private afterFrame(cb: () => void): void {
+ if (typeof requestAnimationFrame !== 'function') {
+ cb();
+ return;
+ }
+ requestAnimationFrame(() => cb());
+ }
+
private adjustPosition(): void {
const container = this.container()?.nativeElement;
if (!container) return;
- const center = container.scrollLeft + container.clientWidth / 2;
+ // Live viewport centre of the scroll viewport (see scrollToChild for why
+ // rects beat offsetLeft here).
+ const containerRect = container.getBoundingClientRect();
+ const center = containerRect.left + containerRect.width / 2;
let nearestIdx = 1;
let minDist = Infinity;
// children[0] and children[last] are the placeholders — skip.
for (let i = 1; i < container.children.length - 1; i++) {
const child = container.children.item(i) as HTMLElement;
- const c = child.offsetLeft + child.offsetWidth / 2;
+ const rect = child.getBoundingClientRect();
+ const c = rect.left + rect.width / 2;
const d = Math.abs(c - center);
if (d < minDist) {
minDist = d;
diff --git a/frontend/src/app/components/modal/tower-settings.component.ts b/frontend/src/app/components/modal/tower-settings.component.ts
index 7443e40..32da88d 100644
--- a/frontend/src/app/components/modal/tower-settings.component.ts
+++ b/frontend/src/app/components/modal/tower-settings.component.ts
@@ -57,11 +57,6 @@ export interface TowerSettingsResult {
@include card();
width: 66vw;
max-width: 400px;
- @media (max-width: $mobile-width) {
- width: 88vw;
- max-width: 88vw;
- padding: var(--medium-padding);
- }
box-sizing: border-box;
padding: var(--large-padding);
padding-top: calc(var(--large-padding) + var(--medium-padding));
@@ -69,6 +64,13 @@ export interface TowerSettingsResult {
box-shadow: $shadow;
display: block;
+ @media (max-width: $mobile-width) {
+ width: 88vw;
+ max-width: 88vw;
+ padding: var(--medium-padding);
+ padding-top: calc(var(--large-padding) + 2 * var(--medium-padding));
+ }
+
.exit {
position: absolute;
top: var(--medium-padding);
diff --git a/frontend/src/app/components/page/page.component.scss b/frontend/src/app/components/page/page.component.scss
index 3c767f4..4630d62 100644
--- a/frontend/src/app/components/page/page.component.scss
+++ b/frontend/src/app/components/page/page.component.scss
@@ -57,7 +57,7 @@
}
}
- & > * {
+ &>* {
width: 100%;
max-width: 200px;
box-sizing: border-box;
@@ -68,13 +68,13 @@
position: relative;
- // Mobile: fixed-width towers with horizontal scroll (1.5-column rhythm).
@media (max-width: $mobile-width) {
+ width: auto;
+ margin-inline: calc(-1 * var(--large-padding));
+
--mobile-tower-width: calc(66vw - var(--small-padding));
- --mobile-tower-side-padding: max(
- var(--medium-padding),
- calc((100% - var(--mobile-tower-width)) / 2)
- );
+ --mobile-tower-side-padding: max(var(--medium-padding),
+ calc((100% - var(--mobile-tower-width)) / 2));
overflow-x: auto;
overflow-y: hidden;
@@ -85,14 +85,14 @@
flex-wrap: nowrap;
justify-content: flex-start;
padding: 0 var(--mobile-tower-side-padding);
- max-width: 100%;
+ max-width: none;
gap: var(--medium-padding);
&::-webkit-scrollbar {
display: none;
}
- & > * {
+ &>* {
width: var(--mobile-tower-width) !important;
max-width: var(--mobile-tower-width) !important;
min-width: var(--mobile-tower-width) !important;
@@ -201,4 +201,4 @@
transform: translateX(-50%) scale(1.1);
}
}
-}
+}
\ No newline at end of file
diff --git a/frontend/src/app/components/pages/pages.component.html b/frontend/src/app/components/pages/pages.component.html
index 9cbafba..00d59b3 100644
--- a/frontend/src/app/components/pages/pages.component.html
+++ b/frontend/src/app/components/pages/pages.component.html
@@ -2,6 +2,7 @@