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 @@
- @for (item of resolvedItems(); track item) { + @for (item of displayedItems(); track item) { @if (editing()) { ('Select…'); readonly alwaysDropShadow = input(false); readonly onlyShadowBorder = input(false); + /** Trim the chip's padding/min-height on mobile (e.g. the page selector). */ + readonly compact = input(false); // ── Legacy compat API (used by pages.component.html until Agent B updates) ─ /** @deprecated Use items instead */ @@ -412,6 +446,17 @@ export class SelectAddComponent { return newItems.length ? newItems : oldOptions; } + /** + * The options shown in the drawer. Excludes the currently-selected value — + * re-picking what's already selected is a no-op, so it just adds noise. + * In rename mode we list everything so every item stays editable. + */ + protected displayedItems(): string[] { + if (this.editing()) return this.resolvedItems(); + const selected = this.resolvedSelected(); + return this.resolvedItems().filter((item) => item !== selected); + } + protected resolvedSelected(): string | null { // New API: string const s = this.selected(); diff --git a/frontend/src/app/components/tower/tower.component.ts b/frontend/src/app/components/tower/tower.component.ts index a40dc93..1053bf3 100644 --- a/frontend/src/app/components/tower/tower.component.ts +++ b/frontend/src/app/components/tower/tower.component.ts @@ -33,7 +33,12 @@ export function editEntryForNewBlock(keepTasksOpen: boolean): EditEntry { }; } -/** A done block augmented with per-render animation state. */ +/** A done block augmented with per-render animation state. + * - `ascend`: flying up and out past the upper date bound (CSS transition). + * - `descend`: a CSS transition back DOWN to rest, used only when an already + * rendered block re-enters range as the date slider widens. A brand-new + * block's "fall" is played imperatively via the Web Animations API instead + * (see `playFall`) — a fresh element can't CSS-transition from off-screen. */ export interface StyledBlock extends Block { _anim: '' | 'descend' | 'ascend'; _transform: string; @@ -117,6 +122,33 @@ export function selectVisibleStyledBlocks( }; } +/** + * Decide which visible blocks should play the gravity "fall" this reconcile. + * + * The two guarantees the user asked for live here: + * - **No fall on page load.** The very first render of a tower's stack never + * animates (`firstRender` ⇒ []), except the deliberate example showcase. + * - **Always fall on add / tick.** After that first render, any block whose id + * is genuinely new this round (a ticked task, an added done block, or a + * remote add picked up over SSE) and is currently resting & visible falls. + * + * Date-range reshuffles never appear here: a block re-entering range keeps its + * id (so it isn't "new"), and blocks flying out aren't resting (`restingVisibleIds` + * only holds opacity-1 blocks). + */ +export function decideFalls(opts: { + firstRender: boolean; + animateInitialStack: boolean; + newIds: readonly string[]; + restingVisibleIds: ReadonlySet; +}): string[] { + const { firstRender, animateInitialStack, newIds, restingVisibleIds } = opts; + if (firstRender) { + return animateInitialStack ? [...restingVisibleIds] : []; + } + return newIds.filter((id) => restingVisibleIds.has(id)); +} + @Component({ selector: 'lt-tower', standalone: true, @@ -209,6 +241,7 @@ export function selectVisibleStyledBlocks( [blocks]="filteredForEntry()" [activeBlockId]="entry.activeId" [tags]="towerTags()" + [lastTag]="lastBlockTag()" [baseColor]="tower().base_color" [defaultDone]="entry.filter === 'done'" (save)="onBlockSave($event)" @@ -413,6 +446,10 @@ export function selectVisibleStyledBlocks( transform: translateY(500%); } + /* A block re-entering range (slider widened) glides back down to + rest. Brand-new blocks fall via the Web Animations API, not + this transition — a freshly inserted element has no prior + value to transition from. */ .descend { transition: transform 1.5s cubic-bezier(0.5, 0, 1, 0), opacity 500ms cubic-bezier(0.5, 0, 1, 0); @@ -584,11 +621,22 @@ export class TowerComponent implements AfterViewInit, OnDestroy { return [...set]; }); + /** Tag of the most recently added block (blocks are appended on create, and + * that order round-trips through the backend's `position` columns). The + * create card pre-selects it so repeated adds keep the same category. */ + readonly lastBlockTag = computed(() => { + const blocks = this.tower().blocks; + return blocks.length > 0 ? blocks[blocks.length - 1].tag : ''; + }); + // ── Falling animation ────────────────────────────────────────────────────── - // Same approach as the legacy: detect "exactly one done block was added" - // and snap that last block to translateY(500%)/opacity:0, then on next - // tick flip it back to translateY(0)/opacity:1 with .descend so the - // 1.5s gravity transition fires. + // Render done blocks at their RESTING position, then — only for blocks that + // genuinely arrived this round (a ticked task, a new done block, or a remote + // add over SSE) — play the gravity "fall" imperatively with the Web Animations + // API (`playFall`). Doing it imperatively makes the fall deterministic: it no + // longer depends on a CSS class flip landing across two animation frames of + // zoneless change detection, which used to drop the fall (block just appears) + // or fire it on load. The first time the stack renders, nothing falls. private readonly _visibleBlocks = signal([]); readonly visibleBlocks = this._visibleBlocks.asReadonly(); @@ -613,8 +661,12 @@ export class TowerComponent implements AfterViewInit, OnDestroy { return rows === 0 ? '0px' : `${Number(cqw.toFixed(4))}cqw`; }); - private prevDoneIds: string[] = []; - private isFirstRun = true; + /** Done-block ids present at the previous reconcile — diffed to find arrivals. */ + private prevDoneIds = new Set(); + /** False until the stack has been rendered once; gates "no fall on load". */ + private hasRenderedStack = false; + /** WAAPI fall animations in flight; cancelled on destroy. */ + private readonly runningAnimations = new Set(); constructor() { effect(() => { @@ -648,6 +700,8 @@ export class TowerComponent implements AfterViewInit, OnDestroy { this.destroyed = true; for (const id of this.animationFrames) cancelAnimationFrame(id); this.animationFrames.clear(); + for (const anim of this.runningAnimations) anim.cancel(); + this.runningAnimations.clear(); this.resizeObserver?.disconnect(); } @@ -700,111 +754,140 @@ export class TowerComponent implements AfterViewInit, OnDestroy { maxVisibleBlocks: number | null, animateInitialStack: boolean, ): void { - if (this.isFirstRun && animateInitialStack && maxVisibleBlocks === null) { + const ids = allDone.map((b) => b.id); + const idSet = new Set(ids); + const firstRender = !this.hasRenderedStack; + const newIds = ids.filter((id) => !this.prevDoneIds.has(id)); + + // Build the styled list: in-range blocks rest at the bottom; blocks past the + // upper date bound fly up and out (declarative `.ascend` transition); blocks + // below the lower bound drop out of the list instantly. In-range blocks carry + // `.descend` so an already-rendered block re-entering range (slider widened) + // glides back down; it's inert for fresh elements (which fall via WAAPI). + const styled: StyledBlock[] = []; + for (const b of allDone) { + if (range && b.created_at < range.from) continue; + if (range && b.created_at > range.to) { + styled.push({ ...b, _anim: 'ascend', _transform: 'translateY(500%)', _opacity: '0' }); + continue; + } + styled.push({ ...b, _anim: 'descend', _transform: 'translateY(0)', _opacity: '1' }); + } + + // The example showcase wants its whole stack to fall in on first paint, but + // it can only pick the right (capped) blocks once the tower is measured. + // Until then render nothing and wait — WITHOUT marking the stack rendered, so + // the post-measurement run still triggers the showcase fall on the right set + // (and the blocks fall in clean, never flashing at rest first). + if (firstRender && animateInitialStack && maxVisibleBlocks === null) { this._visibleBlocks.set([]); this.hiddenBlockCount.set(0); - this.prevDoneIds = allDone.map((b) => b.id); - this.isFirstRun = false; + this.prevDoneIds = idSet; return; } - const ids = allDone.map((b) => b.id); - const prev = this.prevDoneIds; - const prevSet = new Set(prev); - const newIds = ids.filter((id) => !prevSet.has(id)); - const grewByOne = - !this.isFirstRun && - ids.length === prev.length + 1 && - newIds.length === 1 && - prev.every((id) => ids.includes(id)); // no IDs disappeared - - const styled: StyledBlock[] = []; - for (const b of allDone) { - if (range && b.created_at < range.from) { - // Below min-thumb boundary → drop entirely (instant shuffle, no animation). - continue; - } - if (range && b.created_at > range.to) { - // Above max-thumb boundary → fly up off the tower with gravity animation. - styled.push({ - ...b, - _anim: 'ascend', - _transform: 'translateY(500%)', - _opacity: '0', - }); - continue; - } - // In range — descend into position (or appear instantly on first run). - styled.push({ - ...b, - _anim: this.isFirstRun ? '' : 'descend', - _transform: 'translateY(0)', - _opacity: '1', - }); - } - - const newInRangeId = grewByOne ? newIds[0] : null; - // Captured before any `_visibleBlocks.set` below — lets the cap keep + // Reserve a visible slot for the newest just-added block so a capped stack + // still shows (and can fall) it. + const reserveId = !firstRender && newIds.length > 0 ? newIds[newIds.length - 1] : null; + // Captured before the `_visibleBlocks.set` below — lets the cap keep // currently-shown blocks that are now flying out so their exit animates. const prevVisibleIds = new Set(this._visibleBlocks().map((b) => b.id)); - const visibleLimit = - maxVisibleBlocks === null ? Number.POSITIVE_INFINITY : Math.max(0, maxVisibleBlocks); - let visibleStyled = styled; - let hiddenCount = 0; - if (Number.isFinite(visibleLimit)) { - ({ visibleStyled, hiddenCount } = selectVisibleStyledBlocks( - styled, - visibleLimit, - newInRangeId, - prevVisibleIds, - )); - } + const { visibleStyled, hiddenCount } = this.capStyled( + styled, + maxVisibleBlocks, + reserveId, + prevVisibleIds, + ); + this._visibleBlocks.set(visibleStyled); this.hiddenBlockCount.set(hiddenCount); - if (this.isFirstRun && animateInitialStack) { - const initialBlocks = visibleStyled.filter((b) => b._opacity === '1'); - if (initialBlocks.length > 0) { - this.startDescendAnimation(visibleStyled, initialBlocks); - this.prevDoneIds = ids; - this.isFirstRun = false; - return; - } - } + const restingVisibleIds = new Set( + visibleStyled.filter((b) => b._opacity === '1').map((b) => b.id), + ); - if (grewByOne) { - const newId = newIds[0]; - const newBlock = visibleStyled.find((b) => b.id === newId); - if (newBlock) { - // Snap newly-added in-range block to start position, then on the next - // paint flip it back to rest — that's what makes it visibly fall. - this.startDescendAnimation(visibleStyled, [newBlock]); - this.prevDoneIds = ids; - this.isFirstRun = false; - return; - } - } - // existing fall-through path (no growth, first run, or new block out of range): - this._visibleBlocks.set(visibleStyled); + const toFall = decideFalls({ firstRender, animateInitialStack, newIds, restingVisibleIds }); - this.prevDoneIds = ids; - this.isFirstRun = false; + this.prevDoneIds = idSet; + this.hasRenderedStack = true; + + if (toFall.length > 0) this.scheduleFall(toFall); } - private startDescendAnimation(visibleStyled: StyledBlock[], blocks: StyledBlock[]): void { - for (const block of blocks) { - block._anim = ''; - block._transform = 'translateY(500%)'; - block._opacity = '0'; + /** Cap the styled stack to the measured square budget (or pass it through + * untouched while still unmeasured). */ + private capStyled( + styled: StyledBlock[], + maxVisibleBlocks: number | null, + reserveId: string | null, + prevVisibleIds: ReadonlySet = new Set(), + ): { visibleStyled: StyledBlock[]; hiddenCount: number } { + if (maxVisibleBlocks === null) { + return { visibleStyled: styled, hiddenCount: 0 }; } - this._visibleBlocks.set(visibleStyled); + return selectVisibleStyledBlocks( + styled, + Math.max(0, maxVisibleBlocks), + reserveId, + prevVisibleIds, + ); + } + + /** + * Play the gravity "fall" on the given blocks' square elements via the Web + * Animations API, after they've rendered at their resting position. WAAPI + * animates the elements in from above on the next frame regardless of + * change-detection timing, so the fall always fires (the old approach flipped + * a CSS class across two rAFs and would intermittently drop the animation). + */ + private scheduleFall(blockIds: string[], attempt = 0): void { this.requestFrame(() => { - this.requestFrame(() => { - if (this.destroyed) return; - this.finishDescendAnimation(blocks); - }); + if (this.destroyed) return; + const zone = this.stackZone()?.nativeElement; + if (!zone) return; + const pending: string[] = []; + for (const id of blockIds) { + const selector = `lt-block[data-block-id="${this.cssEscape(id)}"]`; + const els = zone.querySelectorAll(selector); + if (els.length === 0) { + // The block hasn't rendered yet (change detection can land a frame + // late); retry a couple of frames before giving up. + pending.push(id); + continue; + } + els.forEach((el) => this.playFall(el)); + } + if (pending.length > 0 && attempt < 3) this.scheduleFall(pending, attempt + 1); }); } + private playFall(el: HTMLElement): void { + if (typeof el.animate !== 'function') return; + // `fill: 'backwards'` holds the off-screen start frame until the animation + // begins, so the block never flashes at rest first; once it ends the element + // reverts to its committed resting style (no snap-back needed). Opacity fades + // in over the first third, matching the legacy descend transition. + const transformAnim = el.animate( + [{ transform: 'translateY(500%)' }, { transform: 'translateY(0)' }], + { duration: 1500, easing: 'cubic-bezier(0.5, 0, 1, 0)', fill: 'backwards' }, + ); + const opacityAnim = el.animate( + [{ opacity: 0 }, { opacity: 1 }], + { duration: 500, easing: 'cubic-bezier(0.5, 0, 1, 0)', fill: 'backwards' }, + ); + for (const anim of [transformAnim, opacityAnim]) { + this.runningAnimations.add(anim); + const drop = () => this.runningAnimations.delete(anim); + anim.finished.then(drop, drop); + } + } + + private cssEscape(value: string): string { + if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') { + return CSS.escape(value); + } + return value.replace(/["\\]/g, '\\$&'); + } + private requestFrame(callback: () => void): void { if (typeof requestAnimationFrame !== 'function') { callback(); @@ -817,15 +900,6 @@ export class TowerComponent implements AfterViewInit, OnDestroy { this.animationFrames.add(id); } - private finishDescendAnimation(blocks: StyledBlock[]): void { - for (const block of blocks) { - block._anim = 'descend'; - block._transform = 'translateY(0)'; - block._opacity = '1'; - } - this._visibleBlocks.set([...this._visibleBlocks()]); - } - // ── Event handlers ───────────────────────────────────────────────────────── onRename(event: Event): void { diff --git a/frontend/src/app/components/tower/tower.component.vitest.ts b/frontend/src/app/components/tower/tower.component.vitest.ts index 495f141..dacf111 100644 --- a/frontend/src/app/components/tower/tower.component.vitest.ts +++ b/frontend/src/app/components/tower/tower.component.vitest.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import type { StyledBlock } from './tower.component'; -import { editEntryForNewBlock, selectVisibleStyledBlocks } from './tower.component'; +import { decideFalls, editEntryForNewBlock, selectVisibleStyledBlocks } from './tower.component'; function block(id: string, opacity = '1', difficulty = 1): StyledBlock { return { @@ -128,6 +128,63 @@ describe('selectVisibleStyledBlocks', () => { }); }); +describe('decideFalls', () => { + it('never falls on the first render of real data (no fall on page load)', () => { + expect( + decideFalls({ + firstRender: true, + animateInitialStack: false, + newIds: ['a', 'b', 'c'], + restingVisibleIds: new Set(['a', 'b', 'c']), + }), + ).toEqual([]); + }); + + it('falls the whole initial stack only for the example showcase', () => { + expect( + decideFalls({ + firstRender: true, + animateInitialStack: true, + newIds: ['a', 'b'], + restingVisibleIds: new Set(['a', 'b']), + }), + ).toEqual(['a', 'b']); + }); + + it('falls a newly ticked/added block once the stack has rendered', () => { + expect( + decideFalls({ + firstRender: false, + animateInitialStack: false, + newIds: ['new-1'], + restingVisibleIds: new Set(['old-1', 'old-2', 'new-1']), + }), + ).toEqual(['new-1']); + }); + + it('does not fall a new block that is capped out of view or out of range', () => { + expect( + decideFalls({ + firstRender: false, + animateInitialStack: false, + newIds: ['new-hidden'], + restingVisibleIds: new Set(['old-1', 'old-2']), + }), + ).toEqual([]); + }); + + it('falls nothing when no ids are new (e.g. a date-range reshuffle)', () => { + expect( + decideFalls({ + firstRender: false, + animateInitialStack: false, + newIds: [], + restingVisibleIds: new Set(['old-1', 'old-2', 'old-3']), + }), + ).toEqual([]); + }); +}); + describe('editEntryForNewBlock', () => { it('opens the create card in the pending task view when tasks are kept open', () => { expect(editEntryForNewBlock(true)).toEqual({ filter: 'pending', activeId: null });